From 920355a933b606f10d866775bf646f19dbe0bf14 Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Mon, 15 Aug 2022 18:11:52 -0400 Subject: [PATCH 1/9] [functions] Per-attribute tolerance in weld() --- packages/functions/src/utils.ts | 6 + packages/functions/src/weld.ts | 194 +++++++++++++++++++++----------- 2 files changed, 134 insertions(+), 66 deletions(-) diff --git a/packages/functions/src/utils.ts b/packages/functions/src/utils.ts index 8e9925ca3..11e9406df 100644 --- a/packages/functions/src/utils.ts +++ b/packages/functions/src/utils.ts @@ -157,3 +157,9 @@ export function remapAttribute(attribute: Accessor, remap: Uint32Array, dstCount attribute.setArray(dstArray); } + +export function createIndices(end: number): Uint16Array | Uint32Array { + const array = end <= 65534 ? new Uint16Array(end) : new Uint32Array(end); + for (let i = 0; i < array.length; i++) array[i] = i; + return array; +} diff --git a/packages/functions/src/weld.ts b/packages/functions/src/weld.ts index ef9440dca..585a04693 100644 --- a/packages/functions/src/weld.ts +++ b/packages/functions/src/weld.ts @@ -7,9 +7,10 @@ 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'; @@ -23,6 +24,8 @@ export interface WeldOptions { const WELD_DEFAULTS: Required = { tolerance: 1e-4, overwrite: true }; +const KEEP = Math.pow(2, 32) - 1; + /** * Index {@link Primitive}s and (optionally) merge similar vertices. */ @@ -74,86 +77,99 @@ function weldOnly(doc: Document, prim: Primitive): void { */ function weldAndMerge(doc: Document, prim: Primitive, options: Required): void { const logger = doc.getLogger(); - 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 srcPosition = prim.getAttribute('POSITION')!; const srcIndices = prim.getIndices(); - const vertexCount = srcIndices ? srcIndices.getCount() : prim.listAttributes()[0].getCount(); + const uniqueIndicesArray = srcIndices + ? new Uint32Array(new Set(srcIndices.getArray()!)) + : createIndices(srcPosition.getCount()); + + // (1) Compute per-attribute tolerances, pre-sort vertices. + + const tolerance = Math.max(options.tolerance, Number.EPSILON); + const attributeTolerance: Record = {}; + for (const semantic of prim.listSemantics()) { + attributeTolerance[semantic] = getAttributeTolerance(semantic, prim.getAttribute(semantic)!, tolerance); + } + + const posA: vec3 = [0, 0, 0]; + const posB: vec3 = [0, 0, 0]; - // Prepare storage for new elements of each attribute. - const dstAttributes = new Map(); - prim.listAttributes().forEach((attr) => dstAttributes.set(attr, [])); - prim.listTargets().forEach((target) => { - target.listAttributes().forEach((attr) => dstAttributes.set(attr, [])); + uniqueIndicesArray.sort((a, b) => { + srcPosition.getElement(a, posA); + srcPosition.getElement(b, posB); + return posA[0] > posB[0] ? 1 : -1; // TODO(test): if this order is reversed... }); - const dstIndicesArray = []; - let nextIndex = 0; + console.log({ sorted: uniqueIndicesArray }); // TODO(cleanup) - // 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; + // (2) Compare and identify vertices to weld. Use sort to keep iterations below O(n²), - 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 reorder = new Uint32Array(uniqueIndicesArray.length).fill(KEEP); + let weldCount = 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 (const target of prim.listTargets()) { - for (const attr of target.listAttributes()) { - dstAttributes.get(attr)!.push(attr.getElement(index, [])); - } + for (let i = 0; i < uniqueIndicesArray.length; i++) { + const a = uniqueIndicesArray[i]; + for (let j = i - 1; j >= 0; j--) { + let b = uniqueIndicesArray[j]; + if (reorder[b] < KEEP) b = reorder[b]; + + srcPosition.getElement(a, posA); + srcPosition.getElement(b, posB); + + // Sort order allows early exit on X-axis distance. + if (posA[0] < posB[0] - attributeTolerance['POSITION']) { + break; } - hashToIndex[hash] = nextIndex; - dstIndicesArray.push(nextIndex); - nextIndex++; + // 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); + }); + 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); + }); + }); + if (isBaseMatch && isTargetMatch) { + reorder[a] = b; + weldCount++; + } } } - const srcVertexCount = prim.listAttributes()[0].getCount(); - const dstVertexCount = dstAttributes.get(prim.getAttribute('POSITION')!)!.length; + const srcVertexCount = srcPosition.getCount(); + const dstVertexCount = srcVertexCount - weldCount; 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(); - } - 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(); - } + const dstIndicesCount = srcIndices ? srcIndices.getCount() : srcVertexCount * 3; + const dstIndicesArray = createIndices(dstIndicesCount); + for (let i = 0; i < dstIndicesCount; i++) { + const srcIndex = srcIndices ? srcIndices.getScalar(i) : i; + dstIndicesArray[i] = reorder[srcIndex]; } if (srcIndices) { - const dstIndicesTypedArray = createArrayOfType(srcIndices.getArray()!, dstIndicesArray.length); - dstIndicesTypedArray.set(dstIndicesArray); - prim.setIndices(srcIndices.clone().setArray(dstIndicesTypedArray)); - - // Clean up. + prim.setIndices(srcIndices.clone().setArray(dstIndicesArray)); if (srcIndices.listParents().length === 1) srcIndices.dispose(); } else { - const indicesArray = - srcVertexCount <= 65534 ? new Uint16Array(dstIndicesArray) : new Uint32Array(dstIndicesArray); - prim.setIndices(doc.createAccessor().setArray(indicesArray)); + prim.setIndices(doc.createAccessor().setArray(dstIndicesArray)); + } + + // (4) Update vertex attributes. + + for (const srcAttr of prim.listAttributes()) { + swapAttributes(prim, srcAttr, reorder, dstVertexCount); + } + for (const target of prim.listTargets()) { + for (const srcAttr of target.listAttributes()) { + swapAttributes(target, srcAttr, reorder, dstVertexCount); + } } } @@ -164,14 +180,60 @@ function createArrayOfType(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, + dstCount: number +): void { + const dstAttrArray = createArrayOfType(srcAttr.getArray()!, dstCount * srcAttr.getElementSize()); const dstAttr = srcAttr.clone().setArray(dstAttrArray); - for (let i = 0; i < dstAttrElements.length; i++) { - dstAttr.setElement(i, dstAttrElements[i]); + for (let i = 0, j = 0, el = [] as number[]; i < reorder.length; i++) { + if (reorder[i] === KEEP) { + dstAttr.setElement(j++, srcAttr.getElement(i, el)); + } } parent.swap(srcAttr, dstAttr); + + // Clean up. + if (srcAttr.listParents().length === 1) srcAttr.dispose(); +} + +const BASE_TOLERANCE = 0.0001; +const BASE_TOLERANCE_TEXCOORD = 0.0001; // [0, 1] +const BASE_TOLERANCE_COLOR = 1.0; // [0, 256] +const BASE_TOLERANCE_NORMAL = 0.01; // [-1, 1] +const BASE_TOLERANCE_JOINTS = 0.0; // [0, ∞] +const BASE_TOLERANCE_WEIGHTS = 0.01; // [0, ∞] + +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, toleranceFactor: number): number { + if (semantic === 'NORMAL' || semantic === 'TANGENT') return BASE_TOLERANCE_NORMAL * toleranceFactor; + if (semantic.startsWith('COLOR_')) return BASE_TOLERANCE_COLOR * toleranceFactor; + if (semantic.startsWith('TEXCOORD_')) return BASE_TOLERANCE_TEXCOORD * toleranceFactor; + if (semantic.startsWith('JOINTS_')) return BASE_TOLERANCE_JOINTS * toleranceFactor; + if (semantic.startsWith('WEIGHTS_')) return BASE_TOLERANCE_WEIGHTS * toleranceFactor; + + _a.length = _b.length = 0; + attribute.getMinNormalized(_a); + attribute.getMaxNormalized(_b); + const range = Math.max(..._b) - Math.min(..._a) || 1; + return BASE_TOLERANCE * range * toleranceFactor; +} + +/** Compares two vertex attributes against a tolerance threshold. */ +function compareAttributes(attribute: Accessor, a: number, b: number, tolerance: number): 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; } From 84db521e1ea7fd0499a410d17eed567c8c23af14 Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Tue, 16 Aug 2022 09:54:27 -0400 Subject: [PATCH 2/9] [functions][weld] fixes and improvements --- packages/cli/src/cli.ts | 15 ++-- packages/functions/src/simplify.ts | 2 + packages/functions/src/utils.ts | 4 +- packages/functions/src/weld.ts | 109 ++++++++++++----------- packages/functions/test/simplify.test.ts | 14 ++- packages/functions/test/weld.test.ts | 57 ++++++++++-- 6 files changed, 137 insertions(+), 64 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e901eba76..b774a8fa9 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, 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'; @@ -543,14 +543,19 @@ 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. + +With --tolerance=1 (default), a default tolerance is applied to each vertex +attribute when deciding which to weld, based on the type of attribute and +(in some cases) the min/max values of the attribute. Higher or lower tolerance +values scale the default thresholds. With --tolerance=0, geometry is indexed +in place, without merging. `.trim()) .argument('', INPUT_DESC) .argument('', OUTPUT_DESC) - .option('--tolerance', 'Per-attribute tolerance to merge similar vertices', { + .option('--tolerance', 'Tolerance factor applied to per-attribute weld thresholds', { validator: program.NUMBER, - default: 1e-4, + default: WELD_DEFAULTS.tolerance, }) .action(({args, options, logger}) => Session.create(io, logger, args.input, args.output) diff --git a/packages/functions/src/simplify.ts b/packages/functions/src/simplify.ts index 5efdb0656..e2cb02c14 100644 --- a/packages/functions/src/simplify.ts +++ b/packages/functions/src/simplify.ts @@ -67,7 +67,9 @@ const simplify = (_options: SimplifyOptions): Transform => { const logger = document.getLogger(); await simplifier.ready; + console.time('simplify::weld'); await document.transform(weld({ overwrite: false })); + console.timeEnd('simplify::weld'); // Simplify mesh primitives. for (const mesh of document.getRoot().listMeshes()) { diff --git a/packages/functions/src/utils.ts b/packages/functions/src/utils.ts index 11e9406df..9fceec6b5 100644 --- a/packages/functions/src/utils.ts +++ b/packages/functions/src/utils.ts @@ -158,8 +158,8 @@ export function remapAttribute(attribute: Accessor, remap: Uint32Array, dstCount attribute.setArray(dstArray); } -export function createIndices(end: number): Uint16Array | Uint32Array { - const array = end <= 65534 ? new Uint16Array(end) : new Uint32Array(end); +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; } diff --git a/packages/functions/src/weld.ts b/packages/functions/src/weld.ts index 585a04693..b7925a092 100644 --- a/packages/functions/src/weld.ts +++ b/packages/functions/src/weld.ts @@ -22,9 +22,16 @@ export interface WeldOptions { overwrite?: boolean; } -const WELD_DEFAULTS: Required = { tolerance: 1e-4, overwrite: true }; +export const WELD_DEFAULTS: Required = { tolerance: 1.0, overwrite: true }; -const KEEP = Math.pow(2, 32) - 1; +const Tolerance = { + DEFAULT: 0.0001, + TEXCOORD: 0.0001, // [0, 1] + COLOR: 1.0, // [0, 256] + NORMAL: 0.01, // [-1, 1] + JOINTS: 0.0, // [0, ∞] + WEIGHTS: 0.01, // [0, ∞] +}; /** * Index {@link Primitive}s and (optionally) merge similar vertices. @@ -78,11 +85,11 @@ function weldOnly(doc: Document, prim: Primitive): void { function weldAndMerge(doc: Document, prim: Primitive, options: Required): void { const logger = doc.getLogger(); + // TODO(bug): shaderball looking really bad here + const srcPosition = prim.getAttribute('POSITION')!; - const srcIndices = prim.getIndices(); - const uniqueIndicesArray = srcIndices - ? new Uint32Array(new Set(srcIndices.getArray()!)) - : createIndices(srcPosition.getCount()); + 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. @@ -95,80 +102,86 @@ function weldAndMerge(doc: Document, prim: Primitive, options: Required { + uniqueIndices.sort((a, b) => { srcPosition.getElement(a, posA); srcPosition.getElement(b, posB); return posA[0] > posB[0] ? 1 : -1; // TODO(test): if this order is reversed... }); - console.log({ sorted: uniqueIndicesArray }); // TODO(cleanup) - // (2) Compare and identify vertices to weld. Use sort to keep iterations below O(n²), - const reorder = new Uint32Array(uniqueIndicesArray.length).fill(KEEP); - let weldCount = 0; + const welds = createIndices(uniqueIndices.length); // oldIndex → oldCommonIndex + const reorder = createIndices(uniqueIndices.length); // oldIndex → newIndex + + const srcVertexCount = srcPosition.getCount(); + let dstVertexCount = 0; + let backIters = 0; + + for (let i = 0; i < uniqueIndices.length; i++) { + const a = uniqueIndices[i]; - for (let i = 0; i < uniqueIndicesArray.length; i++) { - const a = uniqueIndicesArray[i]; for (let j = i - 1; j >= 0; j--) { - let b = uniqueIndicesArray[j]; - if (reorder[b] < KEEP) b = reorder[b]; + const b = welds[uniqueIndices[j]]; srcPosition.getElement(a, posA); srcPosition.getElement(b, posB); // Sort order allows early exit on X-axis distance. - if (posA[0] < posB[0] - attributeTolerance['POSITION']) { + if (Math.abs(posA[0] - posB[0]) > attributeTolerance['POSITION']) { break; } + 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); + 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); + return compareAttributes(attribute, a, b, tolerance, semantic); }); }); + if (isBaseMatch && isTargetMatch) { - reorder[a] = b; - weldCount++; + welds[a] = b; + break; } } + + // Output the vertex if we didn't find a match, else record the index of the match. + if (welds[a] === a) { + reorder[a] = dstVertexCount++; + } else { + reorder[a] = reorder[welds[a]]; + } } - const srcVertexCount = srcPosition.getCount(); - const dstVertexCount = srcVertexCount - weldCount; + console.info(`${NAME}: Average iterations per vertex: ${Math.round(backIters / uniqueIndices.length)}`); logger.debug(`${NAME}: ${formatDeltaOp(srcVertexCount, dstVertexCount)} vertices.`); // (3) Update indices. - const dstIndicesCount = srcIndices ? srcIndices.getCount() : srcVertexCount * 3; - const dstIndicesArray = createIndices(dstIndicesCount); + const dstIndicesCount = srcIndices.getCount(); + const dstIndicesArray = createIndices(dstIndicesCount, uniqueIndices.length); for (let i = 0; i < dstIndicesCount; i++) { - const srcIndex = srcIndices ? srcIndices.getScalar(i) : i; - dstIndicesArray[i] = reorder[srcIndex]; - } - if (srcIndices) { - prim.setIndices(srcIndices.clone().setArray(dstIndicesArray)); - if (srcIndices.listParents().length === 1) srcIndices.dispose(); - } else { - prim.setIndices(doc.createAccessor().setArray(dstIndicesArray)); + dstIndicesArray[i] = reorder[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, reorder, dstVertexCount); + swapAttributes(prim, srcAttr, welds, dstVertexCount); } for (const target of prim.listTargets()) { for (const srcAttr of target.listAttributes()) { - swapAttributes(target, srcAttr, reorder, dstVertexCount); + swapAttributes(target, srcAttr, welds, dstVertexCount); } } } @@ -183,14 +196,14 @@ function createArrayOfType(array: T, length: number): T { function swapAttributes( parent: Primitive | PrimitiveTarget, srcAttr: Accessor, - reorder: Uint32Array, + reorder: Uint32Array | Uint16Array, dstCount: number ): void { const dstAttrArray = createArrayOfType(srcAttr.getArray()!, dstCount * srcAttr.getElementSize()); const dstAttr = srcAttr.clone().setArray(dstAttrArray); for (let i = 0, j = 0, el = [] as number[]; i < reorder.length; i++) { - if (reorder[i] === KEEP) { + if (reorder[i] === i) { dstAttr.setElement(j++, srcAttr.getElement(i, el)); } } @@ -201,37 +214,33 @@ function swapAttributes( if (srcAttr.listParents().length === 1) srcAttr.dispose(); } -const BASE_TOLERANCE = 0.0001; -const BASE_TOLERANCE_TEXCOORD = 0.0001; // [0, 1] -const BASE_TOLERANCE_COLOR = 1.0; // [0, 256] -const BASE_TOLERANCE_NORMAL = 0.01; // [-1, 1] -const BASE_TOLERANCE_JOINTS = 0.0; // [0, ∞] -const BASE_TOLERANCE_WEIGHTS = 0.01; // [0, ∞] - 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, toleranceFactor: number): number { - if (semantic === 'NORMAL' || semantic === 'TANGENT') return BASE_TOLERANCE_NORMAL * toleranceFactor; - if (semantic.startsWith('COLOR_')) return BASE_TOLERANCE_COLOR * toleranceFactor; - if (semantic.startsWith('TEXCOORD_')) return BASE_TOLERANCE_TEXCOORD * toleranceFactor; - if (semantic.startsWith('JOINTS_')) return BASE_TOLERANCE_JOINTS * toleranceFactor; - if (semantic.startsWith('WEIGHTS_')) return BASE_TOLERANCE_WEIGHTS * toleranceFactor; + if (semantic === 'NORMAL' || semantic === 'TANGENT') return Tolerance.NORMAL * toleranceFactor; + if (semantic.startsWith('COLOR_')) return Tolerance.COLOR * toleranceFactor; + if (semantic.startsWith('TEXCOORD_')) return Tolerance.TEXCOORD * toleranceFactor; + if (semantic.startsWith('JOINTS_')) return Tolerance.JOINTS * toleranceFactor; + if (semantic.startsWith('WEIGHTS_')) return Tolerance.WEIGHTS * toleranceFactor; _a.length = _b.length = 0; attribute.getMinNormalized(_a); attribute.getMaxNormalized(_b); const range = Math.max(..._b) - Math.min(..._a) || 1; - return BASE_TOLERANCE * range * toleranceFactor; + return Tolerance.DEFAULT * range * toleranceFactor; } /** Compares two vertex attributes against a tolerance threshold. */ -function compareAttributes(attribute: Accessor, a: number, b: number, tolerance: number): boolean { +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) { + if (Math.random() < 0.01) { + console.log(`failed on: ${semantic}, tolerance: ${tolerance}`); + } return false; } } diff --git a/packages/functions/test/simplify.test.ts b/packages/functions/test/simplify.test.ts index 96b1aaf90..74c27fcc6 100644 --- a/packages/functions/test/simplify.test.ts +++ b/packages/functions/test/simplify.test.ts @@ -10,7 +10,7 @@ import draco3d from 'draco3dgltf'; async function createIO(): Promise { const io = new NodeIO() - .setLogger(new Logger(Logger.Verbosity.SILENT)) + .setLogger(new Logger(Logger.Verbosity.DEBUG)) .registerExtensions([DracoMeshCompression, MeshQuantization]) .registerDependencies({ 'draco3d.decoder': await draco3d.createDecoderModule(), @@ -27,11 +27,21 @@ test('@gltf-transform/functions::simplify | welded', async (t) => { const srcCount = getVertexCount(document); const srcBounds = roundBbox(bounds(scene), 2); - await document.transform(weld(), simplify({ simplifier: MeshoptSimplifier, ratio: 0.5 })); + // TODO: 10x increase in tolerance, no change in outcome? + // TODO: Why is this so slow? Is the sort order broken? Or hashmap was just better? + // TODO: Verify that new weld() does not regress. + + console.time('weld::outer'); + await document.transform(weld({ tolerance: 10 })); + console.timeEnd('weld::outer'); + + await document.transform(simplify({ simplifier: MeshoptSimplifier, ratio: 0.5 })); const dstCount = getVertexCount(document); const dstBounds = roundBbox(bounds(scene), 2); + console.log({ srcCount, dstCount }); + t.ok((srcCount - dstCount) / srcCount > 0.5, '≥50% reduction'); t.ok(srcCount > dstCount, 'src.count > dst.count'); t.deepEqual(srcBounds, dstBounds, 'src.bounds = dst.bounds'); diff --git a/packages/functions/test/weld.test.ts b/packages/functions/test/weld.test.ts index 5a156045e..ff8557d15 100644 --- a/packages/functions/test/weld.test.ts +++ b/packages/functions/test/weld.test.ts @@ -3,7 +3,7 @@ require('source-map-support').install(); import test from 'tape'; import fs from 'fs/promises'; import path from 'path'; -import { Accessor, Document, Primitive } from '@gltf-transform/core'; +import { Accessor, Document, Logger, Primitive } from '@gltf-transform/core'; import { weld } from '../'; test('@gltf-transform/functions::weld | tolerance=0', async (t) => { @@ -28,8 +28,55 @@ test('@gltf-transform/functions::weld | tolerance=0', async (t) => { t.end(); }); +test('@gltf-transform/functions::weld | TEMP', async (t) => { + const doc = new Document().setLogger(new Logger(Logger.Verbosity.DEBUG)); + // prettier-ignore + const positionArray = new Float32Array([ + 5, 0, 0, + 0, 0, 0, + 5.000001, 0, 0, + 1, 0, 0, + 10, 0, 0, + 0, 0, 0, + ]); + const position = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(positionArray); + + const prim = doc.createPrimitive().setAttribute('POSITION', position).setMode(Primitive.Mode.TRIANGLES); + + // prettier-ignore + const primIndices = doc.createAccessor().setArray(new Uint32Array([ + 0, 1, 2, + 3, 4, 5, + 0, 2, 4, + ])); + + doc.createMesh().addPrimitive(prim); + + await doc.transform(weld()); + + // prettier-ignore + t.deepEquals( + Array.from(prim.getIndices()!.getArray()!), [ + 2, 0, 2, 1, 3, 0 + ], + 'indices on prim1' + ); + // prettier-ignore + t.deepEquals( + Array.from(prim.getAttribute('POSITION')!.getArray()!), + [ + 5, 0, 0, + 0, 0, 0, + 1, 0, 0, + 10, 0, 0, + ], + 'vertices on prim1' + ); + t.end(); +}); + test('@gltf-transform/functions::weld | tolerance>0', async (t) => { - const doc = new Document(); + const doc = new Document().setLogger(new Logger(Logger.Verbosity.DEBUG)); const positionArray = new Float32Array([0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, -1]); const positionTargetArray = new Float32Array([0, 10, 0, 0, 10, 1, 0, 10, -1, 0, 15, 0, 0, 15, 1, 0, 15, -1]); const position = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(positionArray); @@ -47,10 +94,10 @@ test('@gltf-transform/functions::weld | tolerance>0', async (t) => { .addTarget(prim2Target); doc.createMesh().addPrimitive(prim1).addPrimitive(prim2); - await doc.transform(weld({ tolerance: 1e-8 })); + await doc.transform(weld()); t.deepEquals(prim1.getIndices().getArray(), new Uint16Array([0, 1, 2, 0, 1, 2]), 'indices on prim1'); - t.deepEquals(prim2.getIndices().getArray(), new Uint32Array([0, 1, 2, 0, 1, 2]), 'indices on prim2'); + t.deepEquals(prim2.getIndices().getArray(), new Uint16Array([0, 1, 2, 0, 1, 2]), 'indices on prim2'); t.deepEquals(prim1.getAttribute('POSITION').getArray(), positionArray.slice(0, 9), 'vertices on prim1'); t.deepEquals(prim2.getAttribute('POSITION').getArray(), positionArray.slice(0, 9), 'vertices on prim2'); t.deepEquals( @@ -58,7 +105,7 @@ test('@gltf-transform/functions::weld | tolerance>0', async (t) => { positionTargetArray.slice(9, 18), // Uses later targets, because of index order. 'morph targets on prim2' ); - t.equals(doc.getRoot().listAccessors().length, 4, 'keeps only needed accessors'); + t.equals(doc.getRoot().listAccessors().length, 3, 'keeps only needed accessors'); t.end(); }); From 501080b8f919a26adc64ffdba5b22b98b24d821b Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Tue, 16 Aug 2022 20:08:48 -0400 Subject: [PATCH 3/9] Fix welding bugs --- packages/functions/src/weld.ts | 46 ++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/packages/functions/src/weld.ts b/packages/functions/src/weld.ts index b7925a092..fbf5ba75a 100644 --- a/packages/functions/src/weld.ts +++ b/packages/functions/src/weld.ts @@ -85,8 +85,6 @@ function weldOnly(doc: Document, prim: Primitive): void { function weldAndMerge(doc: Document, prim: Primitive, options: Required): void { const logger = doc.getLogger(); - // TODO(bug): shaderball looking really bad here - const srcPosition = prim.getAttribute('POSITION')!; const srcIndices = prim.getIndices() || doc.createAccessor().setArray(createIndices(srcPosition.getCount())); const uniqueIndices = new Uint32Array(new Set(srcIndices.getArray()!)); @@ -99,19 +97,21 @@ function weldAndMerge(doc: Document, prim: Primitive, options: Required { srcPosition.getElement(a, posA); srcPosition.getElement(b, posB); - return posA[0] > posB[0] ? 1 : -1; // TODO(test): if this order is reversed... + return posA[0] > posB[0] ? 1 : -1; }); // (2) Compare and identify vertices to weld. Use sort to keep iterations below O(n²), - const welds = createIndices(uniqueIndices.length); // oldIndex → oldCommonIndex - const reorder = createIndices(uniqueIndices.length); // oldIndex → newIndex + const weldMap = createIndices(uniqueIndices.length); // oldIndex → oldCommonIndex + const writeMap = createIndices(uniqueIndices.length); // oldIndex → newIndex const srcVertexCount = srcPosition.getCount(); let dstVertexCount = 0; @@ -121,7 +121,7 @@ function weldAndMerge(doc: Document, prim: Primitive, options: Required= 0; j--) { - const b = welds[uniqueIndices[j]]; + const b = weldMap[uniqueIndices[j]]; srcPosition.getElement(a, posA); srcPosition.getElement(b, posB); @@ -148,28 +148,28 @@ function weldAndMerge(doc: Document, prim: Primitive, options: Required tolerance) { - if (Math.random() < 0.01) { - console.log(`failed on: ${semantic}, tolerance: ${tolerance}`); - } return false; } } return true; } + +function formatKV(kv: Record): string { + return Object.entries(kv) + .map(([k, v]) => `${k}=${v}`) + .join(', '); +} From e206011d0f9f987f4c1d36875167d771586da42d Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Tue, 16 Aug 2022 20:19:06 -0400 Subject: [PATCH 4/9] Clean up, fix tests --- packages/functions/src/weld.ts | 7 ++- packages/functions/test/simplify.test.ts | 11 +---- packages/functions/test/weld.test.ts | 59 +++--------------------- 3 files changed, 13 insertions(+), 64 deletions(-) diff --git a/packages/functions/src/weld.ts b/packages/functions/src/weld.ts index fbf5ba75a..3674961e2 100644 --- a/packages/functions/src/weld.ts +++ b/packages/functions/src/weld.ts @@ -201,10 +201,13 @@ function swapAttributes( ): void { const dstAttrArray = createArrayOfType(srcAttr.getArray()!, dstCount * srcAttr.getElementSize()); const dstAttr = srcAttr.clone().setArray(dstAttrArray); + const done = new Uint8Array(dstCount); for (let i = 0, el = [] as number[]; i < reorder.length; i++) { - // TODO(perf): Will overwrite welded vertices more than once. - dstAttr.setElement(reorder[i], srcAttr.getElement(i, el)); + if (!done[reorder[i]]) { + dstAttr.setElement(reorder[i], srcAttr.getElement(i, el)); + done[reorder[i]] = 1; + } } parent.swap(srcAttr, dstAttr); diff --git a/packages/functions/test/simplify.test.ts b/packages/functions/test/simplify.test.ts index 74c27fcc6..c0be33d6e 100644 --- a/packages/functions/test/simplify.test.ts +++ b/packages/functions/test/simplify.test.ts @@ -10,7 +10,7 @@ import draco3d from 'draco3dgltf'; async function createIO(): Promise { const io = new NodeIO() - .setLogger(new Logger(Logger.Verbosity.DEBUG)) + .setLogger(new Logger(Logger.Verbosity.SILENT)) .registerExtensions([DracoMeshCompression, MeshQuantization]) .registerDependencies({ 'draco3d.decoder': await draco3d.createDecoderModule(), @@ -27,21 +27,12 @@ test('@gltf-transform/functions::simplify | welded', async (t) => { const srcCount = getVertexCount(document); const srcBounds = roundBbox(bounds(scene), 2); - // TODO: 10x increase in tolerance, no change in outcome? - // TODO: Why is this so slow? Is the sort order broken? Or hashmap was just better? - // TODO: Verify that new weld() does not regress. - - console.time('weld::outer'); await document.transform(weld({ tolerance: 10 })); - console.timeEnd('weld::outer'); - await document.transform(simplify({ simplifier: MeshoptSimplifier, ratio: 0.5 })); const dstCount = getVertexCount(document); const dstBounds = roundBbox(bounds(scene), 2); - console.log({ srcCount, dstCount }); - t.ok((srcCount - dstCount) / srcCount > 0.5, '≥50% reduction'); t.ok(srcCount > dstCount, 'src.count > dst.count'); t.deepEqual(srcBounds, dstBounds, 'src.bounds = dst.bounds'); diff --git a/packages/functions/test/weld.test.ts b/packages/functions/test/weld.test.ts index ff8557d15..8b690a336 100644 --- a/packages/functions/test/weld.test.ts +++ b/packages/functions/test/weld.test.ts @@ -6,8 +6,10 @@ import path from 'path'; import { Accessor, Document, Logger, Primitive } from '@gltf-transform/core'; import { weld } from '../'; +const LOGGER = new Logger(Logger.Verbosity.SILENT); + test('@gltf-transform/functions::weld | tolerance=0', async (t) => { - const doc = new Document(); + const doc = new Document().setLogger(LOGGER); const positionArray = new Float32Array([0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, -1]); const position = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(positionArray); const indices = doc.createAccessor().setArray(new Uint32Array([3, 4, 5, 0, 1, 2])); @@ -28,55 +30,8 @@ test('@gltf-transform/functions::weld | tolerance=0', async (t) => { t.end(); }); -test('@gltf-transform/functions::weld | TEMP', async (t) => { - const doc = new Document().setLogger(new Logger(Logger.Verbosity.DEBUG)); - // prettier-ignore - const positionArray = new Float32Array([ - 5, 0, 0, - 0, 0, 0, - 5.000001, 0, 0, - 1, 0, 0, - 10, 0, 0, - 0, 0, 0, - ]); - const position = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(positionArray); - - const prim = doc.createPrimitive().setAttribute('POSITION', position).setMode(Primitive.Mode.TRIANGLES); - - // prettier-ignore - const primIndices = doc.createAccessor().setArray(new Uint32Array([ - 0, 1, 2, - 3, 4, 5, - 0, 2, 4, - ])); - - doc.createMesh().addPrimitive(prim); - - await doc.transform(weld()); - - // prettier-ignore - t.deepEquals( - Array.from(prim.getIndices()!.getArray()!), [ - 2, 0, 2, 1, 3, 0 - ], - 'indices on prim1' - ); - // prettier-ignore - t.deepEquals( - Array.from(prim.getAttribute('POSITION')!.getArray()!), - [ - 5, 0, 0, - 0, 0, 0, - 1, 0, 0, - 10, 0, 0, - ], - 'vertices on prim1' - ); - t.end(); -}); - test('@gltf-transform/functions::weld | tolerance>0', async (t) => { - const doc = new Document().setLogger(new Logger(Logger.Verbosity.DEBUG)); + const doc = new Document().setLogger(LOGGER); const positionArray = new Float32Array([0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, -1]); const positionTargetArray = new Float32Array([0, 10, 0, 0, 10, 1, 0, 10, -1, 0, 15, 0, 0, 15, 1, 0, 15, -1]); const position = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(positionArray); @@ -102,7 +57,7 @@ test('@gltf-transform/functions::weld | tolerance>0', async (t) => { t.deepEquals(prim2.getAttribute('POSITION').getArray(), positionArray.slice(0, 9), 'vertices on prim2'); t.deepEquals( prim2.listTargets()[0].getAttribute('POSITION').getArray(), - positionTargetArray.slice(9, 18), // Uses later targets, because of index order. + positionTargetArray.slice(0, 9), // Uses later targets, because of index order. 'morph targets on prim2' ); t.equals(doc.getRoot().listAccessors().length, 3, 'keeps only needed accessors'); @@ -110,7 +65,7 @@ test('@gltf-transform/functions::weld | tolerance>0', async (t) => { }); test('@gltf-transform/functions::weld | u16 vs u32', async (t) => { - const doc = new Document(); + const doc = new Document().setLogger(LOGGER); const smArray = new Float32Array(65534 * 3); const lgArray = new Float32Array(65535 * 3); const smPosition = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(smArray); @@ -135,7 +90,7 @@ test('@gltf-transform/functions::weld | modes', async (t) => { for (let i = 0; i < dataset.length; i++) { const primDef = dataset[i]; - const document = new Document(); + const document = new Document().setLogger(LOGGER); const position = document .createAccessor() .setArray(new Float32Array(primDef.attributes.POSITION)) From da65a33a45f0f310dfe046997966885550b74211 Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Tue, 16 Aug 2022 20:20:37 -0400 Subject: [PATCH 5/9] Clean up --- packages/functions/src/simplify.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/functions/src/simplify.ts b/packages/functions/src/simplify.ts index e2cb02c14..5efdb0656 100644 --- a/packages/functions/src/simplify.ts +++ b/packages/functions/src/simplify.ts @@ -67,9 +67,7 @@ const simplify = (_options: SimplifyOptions): Transform => { const logger = document.getLogger(); await simplifier.ready; - console.time('simplify::weld'); await document.transform(weld({ overwrite: false })); - console.timeEnd('simplify::weld'); // Simplify mesh primitives. for (const mesh of document.getRoot().listMeshes()) { From c593d7d56e1482a551e247cd1278bf308b182d7d Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Tue, 16 Aug 2022 20:45:02 -0400 Subject: [PATCH 6/9] Clean up documentation --- packages/cli/src/cli.ts | 13 ++--- packages/functions/src/weld.ts | 62 ++++++++++++++++-------- packages/functions/test/simplify.test.ts | 3 +- 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b774a8fa9..d1a53b749 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -545,15 +545,16 @@ 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=1 (default), a default tolerance is applied to each vertex -attribute when deciding which to weld, based on the type of attribute and -(in some cases) the min/max values of the attribute. Higher or lower tolerance -values scale the default thresholds. With --tolerance=0, geometry is indexed -in place, without merging. +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_DESC) .argument('', OUTPUT_DESC) - .option('--tolerance', 'Tolerance factor applied to per-attribute weld thresholds', { + .option('--tolerance', 'Tolerance for vertex welding', { validator: program.NUMBER, default: WELD_DEFAULTS.tolerance, }) diff --git a/packages/functions/src/weld.ts b/packages/functions/src/weld.ts index 3674961e2..0bf7939e8 100644 --- a/packages/functions/src/weld.ts +++ b/packages/functions/src/weld.ts @@ -14,6 +14,15 @@ import { createIndices, createTransform, formatDeltaOp, isTransformPending } fro const NAME = 'weld'; +const Tolerance = { + DEFAULT: 0.0001, + TEXCOORD: 0.0001, // [0, 1] + COLOR: 1.0, // [0, 256] + 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. */ @@ -22,19 +31,32 @@ export interface WeldOptions { overwrite?: boolean; } -export const WELD_DEFAULTS: Required = { tolerance: 1.0, overwrite: true }; - -const Tolerance = { - DEFAULT: 0.0001, - TEXCOORD: 0.0001, // [0, 1] - COLOR: 1.0, // [0, 256] - NORMAL: 0.01, // [-1, 1] - JOINTS: 0.0, // [0, ∞] - WEIGHTS: 0.01, // [0, ∞] +export const WELD_DEFAULTS: Required = { + 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; @@ -78,10 +100,7 @@ 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): void { const logger = doc.getLogger(); @@ -94,7 +113,8 @@ function weldAndMerge(doc: Document, prim: Primitive, options: Required = {}; for (const semantic of prim.listSemantics()) { - attributeTolerance[semantic] = getAttributeTolerance(semantic, prim.getAttribute(semantic)!, tolerance); + const attribute = prim.getAttribute(semantic)!; + attributeTolerance[semantic] = getAttributeTolerance(semantic, attribute, tolerance); } logger.debug(`${NAME}: Tolerance thresholds: ${formatKV(attributeTolerance)}`); @@ -221,11 +241,13 @@ const _b = [] as number[]; /** Computes a per-attribute tolerance, based on domain and usage of the attribute. */ function getAttributeTolerance(semantic: string, attribute: Accessor, toleranceFactor: number): number { - if (semantic === 'NORMAL' || semantic === 'TANGENT') return Tolerance.NORMAL * toleranceFactor; - if (semantic.startsWith('COLOR_')) return Tolerance.COLOR * toleranceFactor; - if (semantic.startsWith('TEXCOORD_')) return Tolerance.TEXCOORD * toleranceFactor; - if (semantic.startsWith('JOINTS_')) return Tolerance.JOINTS * toleranceFactor; - if (semantic.startsWith('WEIGHTS_')) return Tolerance.WEIGHTS * toleranceFactor; + // 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); diff --git a/packages/functions/test/simplify.test.ts b/packages/functions/test/simplify.test.ts index c0be33d6e..96b1aaf90 100644 --- a/packages/functions/test/simplify.test.ts +++ b/packages/functions/test/simplify.test.ts @@ -27,8 +27,7 @@ test('@gltf-transform/functions::simplify | welded', async (t) => { const srcCount = getVertexCount(document); const srcBounds = roundBbox(bounds(scene), 2); - await document.transform(weld({ tolerance: 10 })); - await document.transform(simplify({ simplifier: MeshoptSimplifier, ratio: 0.5 })); + await document.transform(weld(), simplify({ simplifier: MeshoptSimplifier, ratio: 0.5 })); const dstCount = getVertexCount(document); const dstBounds = roundBbox(bounds(scene), 2); From f13f1b935e01db90e20c00bd5a26fbacb656b3b4 Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Tue, 16 Aug 2022 20:48:49 -0400 Subject: [PATCH 7/9] Clean up docs --- packages/functions/src/weld.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/functions/src/weld.ts b/packages/functions/src/weld.ts index 0bf7939e8..82a79e8c7 100644 --- a/packages/functions/src/weld.ts +++ b/packages/functions/src/weld.ts @@ -25,7 +25,7 @@ const Tolerance = { /** 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; From 2da716f37432b234229bab44ef620cbb2fbadf7b Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Tue, 16 Aug 2022 22:07:00 -0400 Subject: [PATCH 8/9] Improve tests, fix bug in vertex color welds --- packages/functions/src/weld.ts | 4 +- packages/functions/test/weld.test.ts | 153 +++++++++++++++++++++++++-- 2 files changed, 144 insertions(+), 13 deletions(-) diff --git a/packages/functions/src/weld.ts b/packages/functions/src/weld.ts index 82a79e8c7..f0e9883c0 100644 --- a/packages/functions/src/weld.ts +++ b/packages/functions/src/weld.ts @@ -17,7 +17,7 @@ const NAME = 'weld'; const Tolerance = { DEFAULT: 0.0001, TEXCOORD: 0.0001, // [0, 1] - COLOR: 1.0, // [0, 256] + COLOR: 0.01, // [0, 1] NORMAL: 0.01, // [-1, 1] JOINTS: 0.0, // [0, ∞] WEIGHTS: 0.01, // [0, ∞] @@ -257,7 +257,7 @@ function getAttributeTolerance(semantic: string, attribute: Accessor, toleranceF } /** Compares two vertex attributes against a tolerance threshold. */ -function compareAttributes(attribute: Accessor, a: number, b: number, tolerance: number, semantic: string): boolean { +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++) { diff --git a/packages/functions/test/weld.test.ts b/packages/functions/test/weld.test.ts index 8b690a336..0ad851278 100644 --- a/packages/functions/test/weld.test.ts +++ b/packages/functions/test/weld.test.ts @@ -11,7 +11,7 @@ const LOGGER = new Logger(Logger.Verbosity.SILENT); test('@gltf-transform/functions::weld | tolerance=0', async (t) => { const doc = new Document().setLogger(LOGGER); const positionArray = new Float32Array([0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, -1]); - const position = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(positionArray); + const position = doc.createAccessor().setType('VEC3').setArray(positionArray); const indices = doc.createAccessor().setArray(new Uint32Array([3, 4, 5, 0, 1, 2])); const prim1 = doc.createPrimitive().setAttribute('POSITION', position).setMode(Primitive.Mode.TRIANGLES); const prim2 = doc @@ -32,20 +32,51 @@ test('@gltf-transform/functions::weld | tolerance=0', async (t) => { test('@gltf-transform/functions::weld | tolerance>0', async (t) => { const doc = new Document().setLogger(LOGGER); - const positionArray = new Float32Array([0, 0, 0, 0, 0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, -1]); - const positionTargetArray = new Float32Array([0, 10, 0, 0, 10, 1, 0, 10, -1, 0, 15, 0, 0, 15, 1, 0, 15, -1]); - const position = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(positionArray); - const positionTarget = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(positionTargetArray); - - const prim1 = doc.createPrimitive().setAttribute('POSITION', position).setMode(Primitive.Mode.TRIANGLES); + // prettier-ignore + const positionArray = new Float32Array([ + 0, 0, 0, + 0, 0, 1, + 0, 0, -1, + 0, 0, 0, + 0, 0, 1, + 0, 0, -1 + ]); + // prettier-ignore + const normalArray = new Float32Array([ + 0, 0, 1, + 0, 0.5, 0.5, + 0.5, 0.5, 0, + 0, 0, 1, + 0, 0.5, 0.50001, // should still be welded + 0.5, 0.5, 0 + ]); + // prettier-ignore + const positionTargetArray = new Float32Array([ + 0, 10, 0, + 0, 10, 1, + 0, 10, -1, + 0, 15, 0, + 0, 15, 1, + 0, 15, -1 + ]); + const position = doc.createAccessor().setType('VEC3').setArray(positionArray); + const normal = doc.createAccessor().setType('VEC3').setArray(normalArray); + const positionTarget = doc.createAccessor().setType('VEC3').setArray(positionTargetArray); + + const prim1 = doc + .createPrimitive() + .setMode(Primitive.Mode.TRIANGLES) + .setAttribute('POSITION', position) + .setAttribute('NORMAL', normal); const prim2Indices = doc.createAccessor().setArray(new Uint32Array([3, 4, 5, 0, 1, 2])); const prim2Target = doc.createPrimitiveTarget().setAttribute('POSITION', positionTarget); const prim2 = doc .createPrimitive() + .setMode(Primitive.Mode.TRIANGLES) .setIndices(prim2Indices) .setAttribute('POSITION', position) - .setMode(Primitive.Mode.TRIANGLES) + .setAttribute('NORMAL', normal) .addTarget(prim2Target); doc.createMesh().addPrimitive(prim1).addPrimitive(prim2); @@ -60,7 +91,107 @@ test('@gltf-transform/functions::weld | tolerance>0', async (t) => { positionTargetArray.slice(0, 9), // Uses later targets, because of index order. 'morph targets on prim2' ); - t.equals(doc.getRoot().listAccessors().length, 3, 'keeps only needed accessors'); + t.equals(doc.getRoot().listAccessors().length, 4, 'keeps only needed accessors'); + t.end(); +}); + +test('@gltf-transform/functions::weld | attributes', async (t) => { + const doc = new Document().setLogger(LOGGER); + // prettier-ignore + const positionArray = new Uint8Array([ + 0, 0, 0, // ⎡ + 0, 0, 0, // ⎢ all match, weld 3 + 0, 0, 0, // ⎣ + 1, 0, 0, // ⎡ + 1, 0, 0, // ⎢ normals differ, weld 2 + 1, 0, 0, // ⎣ ❌ + 0, 1, 1, // ⎡ ❌ + 0, 1, 1, // ⎢ colors differ, weld 2 + 0, 1, 1, // ⎣ + ]); + // prettier-ignore + const normalArray = new Uint8Array([ + 127, 127, 0, + 127, 127, 0, + 127, 127, 0, + 0, 127, 126, + 0, 128, 127, + 0, 150, 127, // ❌ + 127, 0, 127, + 127, 0, 127, + 127, 0, 127, + ]); + // prettier-ignore + const colorArray = new Uint8Array([ + 255, 0, 0, 1, + 255, 0, 0, 1, + 255, 0, 0, 1, + 0, 255, 0, 1, + 0, 255, 0, 1, + 0, 255, 0, 1, + 0, 0, 200, 1, // ❌ + 0, 0, 255, 1, + 0, 0, 255, 1, + ]); + const position = doc.createAccessor().setType('VEC3').setArray(positionArray); + const normal = doc.createAccessor().setType('VEC3').setArray(normalArray).setNormalized(true); + const color = doc.createAccessor().setType('VEC4').setArray(colorArray).setNormalized(true); + const prim = doc + .createPrimitive() + .setMode(Primitive.Mode.TRIANGLES) + .setAttribute('POSITION', position) + .setAttribute('NORMAL', normal) + .setAttribute('COLOR_0', color); + doc.createMesh().addPrimitive(prim); + + await doc.transform(weld({ tolerance: 0.0001 })); + + // prettier-ignore + t.deepEquals( + Array.from(prim.getIndices()!.getArray()!), + [ + 0, 0, 0, + 3, 3, 4, + 1, 2, 2, + ], + 'indices' + ); + // prettier-ignore + t.deepEquals( + Array.from(prim.getAttribute('POSITION')!.getArray()!), + [ + 0, 0, 0, + 0, 1, 1, + 0, 1, 1, + 1, 0, 0, + 1, 0, 0, + ], + 'position' + ); + // prettier-ignore + t.deepEquals( + Array.from(prim.getAttribute('NORMAL')!.getArray()!), + [ + 127, 127, 0, + 127, 0, 127, + 127, 0, 127, + 0, 127, 126, + 0, 150, 127, + ], + 'normal' + ); + // prettier-ignore + t.deepEquals( + Array.from(prim.getAttribute('COLOR_0')!.getArray()!), + [ + 255, 0, 0, 1, + 0, 0, 200, 1, + 0, 0, 255, 1, + 0, 255, 0, 1, + 0, 255, 0, 1, + ], + 'color' + ); t.end(); }); @@ -68,8 +199,8 @@ test('@gltf-transform/functions::weld | u16 vs u32', async (t) => { const doc = new Document().setLogger(LOGGER); const smArray = new Float32Array(65534 * 3); const lgArray = new Float32Array(65535 * 3); - const smPosition = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(smArray); - const lgPosition = doc.createAccessor().setType(Accessor.Type.VEC3).setArray(lgArray); + const smPosition = doc.createAccessor().setType('VEC3').setArray(smArray); + const lgPosition = doc.createAccessor().setType('VEC3').setArray(lgArray); const smPrim = doc.createPrimitive().setAttribute('POSITION', smPosition).setMode(Primitive.Mode.TRIANGLES); const lgPrim = doc.createPrimitive().setAttribute('POSITION', lgPosition).setMode(Primitive.Mode.TRIANGLES); doc.createMesh().addPrimitive(smPrim).addPrimitive(lgPrim); From 9a6003366bd6acff200c44c09f77dafbcccfeb54 Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Tue, 16 Aug 2022 22:24:29 -0400 Subject: [PATCH 9/9] Add error on out-of-range tolerance, fix position tolerance --- packages/functions/src/weld.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/functions/src/weld.ts b/packages/functions/src/weld.ts index f0e9883c0..303c3c651 100644 --- a/packages/functions/src/weld.ts +++ b/packages/functions/src/weld.ts @@ -61,6 +61,10 @@ export const WELD_DEFAULTS: Required = { export function weld(_options: WeldOptions = WELD_DEFAULTS): Transform { const options = { ...WELD_DEFAULTS, ..._options } as Required; + 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 => { const logger = doc.getLogger(); @@ -240,7 +244,7 @@ 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, toleranceFactor: number): number { +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; @@ -253,7 +257,7 @@ function getAttributeTolerance(semantic: string, attribute: Accessor, toleranceF attribute.getMinNormalized(_a); attribute.getMaxNormalized(_b); const range = Math.max(..._b) - Math.min(..._a) || 1; - return Tolerance.DEFAULT * range * toleranceFactor; + return tolerance * range; } /** Compares two vertex attributes against a tolerance threshold. */