UNPKG

@gltf-transform/functions

Version:

Functions for common glTF modifications, written using the core API

383 lines (334 loc) 12.1 kB
import { Document, Transform, Primitive, TypedArrayConstructor, vec2, Mesh, Node, vec3 } from '@gltf-transform/core'; import { Accessor } from '@gltf-transform/core'; import { createTransform, isUsed, shallowCloneAccessor } from './utils.js'; import type * as watlas from 'watlas'; import { dequantizeAttributeArray } from './dequantize.js'; import { compactPrimitive } from './compact-primitive.js'; const NAME = 'unwrap'; interface IWatlas { Initialize(): Promise<void>; Atlas: { new (): watlas.Atlas; }; } /** Options for the {@link unwrap} transform. */ export interface UnwrapOptions { /** watlas instance. */ watlas: unknown; /** * Target texture coordinate index (0, 1, 2, ...) for generated unwrapping. * Default: 0. */ texcoord?: number; /** * Whether to overwrite existing attributes at the target texCoord index, if * any. Default: false. */ overwrite?: boolean; /** * Methods of grouping texcoords with the {@link unwrap} function. * Default: 'mesh'. */ groupBy?: 'primitive' | 'mesh' | 'scene'; } /** Options for the {@link unwrapPrimitives} function. */ export interface UnwrapPrimitivesOptions { /** watlas instance. */ watlas: unknown; /** * Target texture coordinate index (0, 1, 2, ...) for generated unwrapping. * Default: 0. */ texcoord?: number; /** * Whether to overwrite existing attributes at the target texCoord index, if * any. Default: false. */ overwrite?: boolean; /** * Per-primitive texel density weights. Texel space in the atlas is allocated * proportionally with geometry dimensions in local space. If specified, * weights scale the allocation. Default: [1, 1, 1, ...]. */ weights?: number[]; } export const UNWRAP_DEFAULTS: Required<Omit<UnwrapOptions, 'watlas'>> = { texcoord: 0, overwrite: false, groupBy: 'mesh', }; /** * Generate new texture coordinates (“UV mappings”) for {@link Primitive Primitives}. * Useful for adding texture coordinates in scenes without existing UVs, or for * creating a second set of texture coordinates for baked textures such as ambient * occlusion maps and lightmaps. Operation may increase vertex count to * accommodate UV seams. * * UV layouts may be grouped, reducing the number of textures required. Available * groupings: * * - `"primitive"`: Each primitive is given it's own texcoord atlas. * - `"mesh"`: All primitives in a mesh share a texcoord atlas. (default) * - `"scene"`: All primitives in the scene share a texcoord atlas. * * Example: * * ```ts * import * as watlas from 'watlas'; * import { unwrap } from '@gltf-transform/functions'; * * // Generate a TEXCOORD_1 attribute for all primitives. * await document.transform( * unwrap({ watlas, texCoord: 1, overwrite: true, groupBy: 'scene' }) * ); * ``` * * For more control and customization, see {@link unwrapPrimitives}. * * @experimental * @category Transforms */ export function unwrap(_options: UnwrapOptions): Transform { const options = { ...UNWRAP_DEFAULTS, ..._options } as Required<UnwrapOptions>; const watlas = options.watlas as IWatlas | undefined; if (!watlas) { throw new Error(`${NAME}: dependency required — install "watlas".`); } return createTransform(NAME, async (document: Document): Promise<void> => { await watlas!.Initialize(); switch (options.groupBy) { case 'primitive': { for (const mesh of document.getRoot().listMeshes()) { for (const prim of mesh.listPrimitives()) { unwrapPrimitives([prim], options); } } break; } case 'mesh': { for (const mesh of document.getRoot().listMeshes()) { unwrapPrimitives(mesh.listPrimitives(), options); } break; } case 'scene': { const prims: Primitive[] = []; const weights: number[] = []; for (const mesh of document.getRoot().listMeshes()) { const weight = getNodeScaleMax(mesh); for (const prim of mesh.listPrimitives()) { prims.push(prim); weights.push(weight); } } unwrapPrimitives(prims, { ...options, weights }); break; } } const logger = document.getLogger(); logger.debug(`${NAME}: Complete.`); }); } /** * Generate new texture coordinates (“UV mappings”) for {@link Primitive Primitives}. * Useful for adding texture coordinates in scenes without existing UVs, or for * creating a second set of texture coordinates for baked textures such as ambient * occlusion maps and lightmaps. Operation may increase vertex count to * accommodate UV seams. * * UV layouts may be grouped, reducing the number of textures required. Available * groupings: * * - `"primitive"`: Each primitive is given it's own texcoord atlas. * - `"mesh"`: All primitives in a mesh share a texcoord atlas. (default) * - `"scene"`: All primitives in the scene share a texcoord atlas. * * watlas must be initialized before calling this function. * * Example: * * ```ts * import * as watlas from 'watlas'; * import { unwrapPrimitives } from '@gltf-transform/functions'; * * // Initialize watlas. * await watlas.Initialize(); * * // Generate a TEXCOORD_1 attribute for the specified primitives. * unwrapPrimitives(mesh.listPrimitives(), { * watlas, * texCoord: 1, * overwrite: true * }); * ``` * * To create texture coordinates for an entire Document, see {@link unwrap}. * * @experimental */ export function unwrapPrimitives(primitives: Primitive[], options: UnwrapPrimitivesOptions) { const document = Document.fromGraph(primitives[0].getGraph())!; const watlas = options.watlas as IWatlas | undefined; const dstTexCoordIndex = options.texcoord ?? 0; const dstSemantic = `TEXCOORD_${dstTexCoordIndex}`; if (!watlas) { throw new Error(`${NAME}: dependency required — install "watlas".`); } const atlas = new watlas.Atlas(); const unwrapPrims = []; for (let i = 0; i < primitives.length; i++) { const prim = primitives[i]; const primWeight = options.weights ? options.weights[i] : 1; // Don't process primitives that already have the desired TEXCOORD index // if overwrite is false. if (!options.overwrite && prim.getAttribute(dstSemantic)) { continue; } const unwrapPrim = compactPrimitive(prim); // Always pass vertex position data const position = unwrapPrim.getAttribute('POSITION')!; const meshDecl: watlas.MeshDecl = { vertexCount: position.getCount(), vertexPositionData: getScaledAttributeFloat32Array(position, primWeight), vertexPositionStride: position.getElementSize() * Float32Array.BYTES_PER_ELEMENT, }; // Pass normal data if available to improve unwrapping const normal = unwrapPrim.getAttribute('NORMAL'); if (normal) { meshDecl.vertexNormalData = getAttributeFloat32Array(normal); meshDecl.vertexNormalStride = normal.getElementSize() * Float32Array.BYTES_PER_ELEMENT; } // Pass texcoord data from set 0 if it's available and not the set that // is being generated. if (options.texcoord !== 0) { const texcoord = unwrapPrim.getAttribute('TEXCOORD_0'); if (texcoord) { meshDecl.vertexUvData = getAttributeFloat32Array(texcoord); meshDecl.vertexUvStride = texcoord.getElementSize() * Float32Array.BYTES_PER_ELEMENT; } } // Pass indices if available const indices = unwrapPrim.getIndices(); if (indices) { const indicesArray = indices.getArray()!; meshDecl.indexCount = indices.getCount(); meshDecl.indexData = indicesArray instanceof Uint8Array ? new Uint16Array(indicesArray) : (indicesArray as Uint16Array | Uint32Array); } unwrapPrims.push(unwrapPrim); atlas.addMesh(meshDecl); } // Don't proceed if we skipped every primitive in this group. if (unwrapPrims.length === 0) { return; } atlas.generate(); if (atlas.meshCount !== unwrapPrims.length) { throw new Error( `${NAME}: Generated an unexpected number of atlas meshes. (got: ${atlas.meshCount}, expected: ${unwrapPrims.length})`, ); } // xatlas UVs are in texels, so they need to be normalized before saving to // the glTF attribute. const scale: vec2 = [1 / atlas.width, 1 / atlas.height]; for (let i = 0; i < atlas.meshCount; i++) { const prim = unwrapPrims[i]; const atlasMesh = atlas.getMesh(i); // Clean up previous TEXCOORD_* attribute, if there was any. const srcTexCoord = prim.getAttribute(dstSemantic); if (srcTexCoord) { prim.setAttribute(dstSemantic, null); if (!isUsed(srcTexCoord)) srcTexCoord.dispose(); } // Remap Vertex attributes. for (const srcAttribute of prim.listAttributes()) { prim.swap(srcAttribute, remapAttribute(document, srcAttribute, atlasMesh)); // Clean up. if (!isUsed(srcAttribute)) srcAttribute.dispose(); } // Remap morph target vertex attributes. for (const target of prim.listTargets()) { for (const srcAttribute of target.listAttributes()) { target.swap(srcAttribute, remapAttribute(document, srcAttribute, atlasMesh)); // Clean up. if (!isUsed(srcAttribute)) srcAttribute.dispose(); } } // Add new TEXCOORD_* attribute. const dstTexCoord = document .createAccessor() .setArray(new Float32Array(atlasMesh.vertexCount * 2)) .setType('VEC2'); for (let j = 0; j < atlasMesh.vertexCount; j++) { const vertex = atlasMesh.getVertex(j); dstTexCoord.setElement(j, [vertex.uv[0] * scale[0], vertex.uv[1] * scale[1]]); } prim.setAttribute(dstSemantic, dstTexCoord); // The glTF spec says that if TEXCOORD_N (where N > 0) exists then // TEXCOORD_N-1...TEXCOORD_0 must also exist. If any prior TEXCOORD // attributes are missing, copy this attribute to satisfy that requirement. for (let j = dstTexCoordIndex - 1; j >= 0; j--) { const semantic = `TEXCOORD_${j}`; if (!prim.getAttribute(semantic)) { prim.setAttribute(semantic, dstTexCoord); } } // Update Indices. const dstIndicesArray = new Uint32Array(atlasMesh.indexCount); atlasMesh.getIndexArray(dstIndicesArray); const dstIndices = document.createAccessor().setArray(dstIndicesArray).setType('SCALAR'); const srcIndices = prim.getIndices(); prim.setIndices(dstIndices); if (srcIndices && !isUsed(srcIndices)) { srcIndices.dispose(); } } atlas.delete(); } // Returns a new attribute with the same values at as source attribute, but // re-ordered according to the vertex order output by xatlas to account for // vertex splitting. function remapAttribute(document: Document, srcAttribute: Accessor, atlasMesh: watlas.Mesh): Accessor { const dstAttribute = shallowCloneAccessor(document, srcAttribute); const ArrayCtor = srcAttribute.getArray()!.constructor as TypedArrayConstructor; dstAttribute.setArray(new ArrayCtor(atlasMesh.vertexCount * srcAttribute.getElementSize())); const el: number[] = []; for (let i = 0; i < atlasMesh.vertexCount; i++) { const vertex = atlasMesh.getVertex(i); dstAttribute.setElement(i, srcAttribute.getElement(vertex.xref, el)); } return dstAttribute; } // Returns the values of the given attribute as a Float32Array. function getAttributeFloat32Array(attribute: Accessor): Float32Array { if (attribute.getComponentType() === Accessor.ComponentType.FLOAT) { return attribute.getArray() as Float32Array; } return dequantizeAttributeArray(attribute.getArray()!, attribute.getComponentType(), attribute.getNormalized()); } // Returns scaled values of the given attribute as a Float32Array. function getScaledAttributeFloat32Array(attribute: Accessor, scale: number): Float32Array { const array = dequantizeAttributeArray( attribute.getArray()!, attribute.getComponentType(), attribute.getNormalized(), ); for (let i = 0; i < array.length; i++) { array[i] *= scale; } return array; } function getNodeScaleMax(mesh: Mesh): number { let scale = -Infinity; for (const parent of mesh.listParents()) { if (parent instanceof Node) { const s = parent.getWorldScale(); scale = Number.isFinite(s[0]) ? Math.max(scale, Math.abs(s[0])) : scale; scale = Number.isFinite(s[1]) ? Math.max(scale, Math.abs(s[1])) : scale; scale = Number.isFinite(s[2]) ? Math.max(scale, Math.abs(s[2])) : scale; } } return scale > 0 && Number.isFinite(scale) ? scale : 1; }