palette.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. import {
  2. ColorUtils,
  3. type Document,
  4. type Material,
  5. type Primitive,
  6. PropertyType,
  7. type Texture,
  8. TextureInfo,
  9. type Transform,
  10. type vec4,
  11. } from '@gltf-transform/core';
  12. import ndarray, { type NdArray, type TypedArray } from 'ndarray';
  13. import { savePixels } from 'ndarray-pixels';
  14. import { prune } from './prune.js';
  15. import { assignDefaults, createTransform } from './utils.js';
  16. const NAME = 'palette';
  17. type TexturableProp = 'baseColor' | 'emissive' | 'metallicRoughness';
  18. export interface PaletteOptions {
  19. /** Size (in pixels) of a single block within each palette texture. Default: 4. */
  20. blockSize?: number;
  21. /**
  22. * Minimum number of blocks in the palette texture. If fewer unique
  23. * material values are found, no palettes will be generated. Default: 5.
  24. */
  25. min?: number;
  26. /**
  27. * Whether to keep unused vertex attributes, such as UVs without an assigned
  28. * texture. If kept, unused UV coordinates may prevent palette texture
  29. * creation. Default: false.
  30. */
  31. keepAttributes?: boolean;
  32. /**
  33. * Whether to perform cleanup steps after completing the operation. Recommended, and enabled by
  34. * default. Cleanup removes temporary resources created during the operation, but may also remove
  35. * pre-existing unused or duplicate resources in the {@link Document}. Applications that require
  36. * keeping these resources may need to disable cleanup, instead calling {@link dedup} and
  37. * {@link prune} manually (with customized options) later in the processing pipeline.
  38. * @experimental
  39. */
  40. cleanup?: boolean;
  41. }
  42. export const PALETTE_DEFAULTS: Required<PaletteOptions> = {
  43. blockSize: 4,
  44. min: 5,
  45. keepAttributes: false,
  46. cleanup: true,
  47. };
  48. /**
  49. * Creates palette textures containing all unique values of scalar
  50. * {@link Material} properties within the scene, then merges materials. For
  51. * scenes with many solid-colored materials (often found in CAD, architectural,
  52. * or low-poly styles), texture palettes can reduce the number of materials
  53. * used, and significantly increase the number of {@link Mesh} objects eligible
  54. * for {@link join} operations.
  55. *
  56. * Materials already containing texture coordinates (UVs) are not eligible for
  57. * texture palette optimizations. Currently only a material's base color,
  58. * alpha, emissive factor, metallic factor, and roughness factor are converted
  59. * to palette textures.
  60. *
  61. * Example:
  62. *
  63. * ```typescript
  64. * import { palette, flatten, dequantize, join } from '@gltf-transform/functions';
  65. *
  66. * await document.transform(
  67. * palette({ min: 5 }),
  68. * flatten(),
  69. * dequantize(),
  70. * join()
  71. * );
  72. * ```
  73. *
  74. * The illustration below shows a typical base color palette texture:
  75. *
  76. * <img
  77. * src="/media/functions/palette.png"
  78. * alt="Row of colored blocks"
  79. * style="width: 100%; max-width: 320px; image-rendering: pixelated;">
  80. *
  81. * @category Transforms
  82. */
  83. export function palette(_options: PaletteOptions = PALETTE_DEFAULTS): Transform {
  84. const options = assignDefaults(PALETTE_DEFAULTS, _options);
  85. const blockSize = Math.max(options.blockSize, 1);
  86. const min = Math.max(options.min, 1);
  87. return createTransform(NAME, async (document: Document): Promise<void> => {
  88. const logger = document.getLogger();
  89. const root = document.getRoot();
  90. // Find and remove unused TEXCOORD_n attributes.
  91. if (!options.keepAttributes) {
  92. await document.transform(
  93. prune({
  94. propertyTypes: [PropertyType.ACCESSOR],
  95. keepAttributes: false,
  96. keepIndices: true,
  97. keepLeaves: true,
  98. }),
  99. );
  100. }
  101. const prims = new Set<Primitive>();
  102. const materials = new Set<Material>();
  103. // (1) Gather list of eligible prims and materials.
  104. for (const mesh of root.listMeshes()) {
  105. for (const prim of mesh.listPrimitives()) {
  106. const material = prim.getMaterial();
  107. if (!material || !!prim.getAttribute('TEXCOORD_0')) continue;
  108. prims.add(prim);
  109. materials.add(material);
  110. }
  111. }
  112. // (2) Gather list of distinct material properties.
  113. const materialKeys = new Set<string>();
  114. const materialKeyMap = new Map<Material, string>();
  115. const materialProps: Record<TexturableProp, Set<string>> = {
  116. baseColor: new Set<string>(),
  117. emissive: new Set<string>(),
  118. metallicRoughness: new Set<string>(),
  119. };
  120. for (const material of materials) {
  121. const baseColor = encodeRGBA(material.getBaseColorFactor().slice() as vec4);
  122. const emissive = encodeRGBA([...material.getEmissiveFactor(), 1]);
  123. const roughness = encodeFloat(material.getRoughnessFactor());
  124. const metallic = encodeFloat(material.getMetallicFactor());
  125. const key = `baseColor:${baseColor},emissive:${emissive},metallicRoughness:${metallic}${roughness}`;
  126. materialProps.baseColor.add(baseColor);
  127. materialProps.emissive.add(emissive);
  128. materialProps.metallicRoughness.add(metallic + '+' + roughness);
  129. materialKeys.add(key);
  130. materialKeyMap.set(material, key);
  131. }
  132. // logger.debug(`${NAME}:\n${Array.from(materialKeys.values()).join('\n')}`);
  133. const keyCount = materialKeys.size;
  134. if (keyCount < min) {
  135. logger.debug(`${NAME}: Found <${min} unique material properties. Exiting.`);
  136. return;
  137. }
  138. // (3) Allocate palette textures.
  139. const w = ceilPowerOfTwo(keyCount * blockSize);
  140. const h = ceilPowerOfTwo(blockSize);
  141. const padWidth = w - keyCount * blockSize;
  142. const paletteTexturePixels: Record<TexturableProp, NdArray<Uint8Array> | null> = {
  143. baseColor: null,
  144. emissive: null,
  145. metallicRoughness: null,
  146. };
  147. // Properties skipped for material equality comparisons.
  148. const skipProps = new Set(['name', 'extras']);
  149. const skip = (...props: string[]) => props.forEach((prop) => skipProps.add(prop));
  150. let baseColorTexture: Texture | null = null;
  151. let emissiveTexture: Texture | null = null;
  152. let metallicRoughnessTexture: Texture | null = null;
  153. if (materialProps.baseColor.size >= min) {
  154. const name = 'PaletteBaseColor';
  155. baseColorTexture = document.createTexture(name).setURI(`${name}.png`);
  156. paletteTexturePixels.baseColor = ndarray(new Uint8Array(w * h * 4), [w, h, 4]);
  157. skip('baseColorFactor', 'baseColorTexture', 'baseColorTextureInfo');
  158. }
  159. if (materialProps.emissive.size >= min) {
  160. const name = 'PaletteEmissive';
  161. emissiveTexture = document.createTexture(name).setURI(`${name}.png`);
  162. paletteTexturePixels.emissive = ndarray(new Uint8Array(w * h * 4), [w, h, 4]);
  163. skip('emissiveFactor', 'emissiveTexture', 'emissiveTextureInfo');
  164. }
  165. if (materialProps.metallicRoughness.size >= min) {
  166. const name = 'PaletteMetallicRoughness';
  167. metallicRoughnessTexture = document.createTexture(name).setURI(`${name}.png`);
  168. paletteTexturePixels.metallicRoughness = ndarray(new Uint8Array(w * h * 4), [w, h, 4]);
  169. skip('metallicFactor', 'roughnessFactor', 'metallicRoughnessTexture', 'metallicRoughnessTextureInfo');
  170. }
  171. if (!(baseColorTexture || emissiveTexture || metallicRoughnessTexture)) {
  172. logger.debug(`${NAME}: No material property has >=${min} unique values. Exiting.`);
  173. return;
  174. }
  175. // (4) Write blocks to palette textures.
  176. const visitedKeys = new Set<string>();
  177. const materialIndices = new Map<string, number>();
  178. const paletteMaterials: Material[] = [];
  179. let nextIndex = 0;
  180. for (const material of materials) {
  181. const key = materialKeyMap.get(material)!;
  182. if (visitedKeys.has(key)) continue;
  183. const index = nextIndex++;
  184. if (paletteTexturePixels.baseColor) {
  185. const pixels = paletteTexturePixels.baseColor;
  186. const baseColor = [...material.getBaseColorFactor()] as vec4;
  187. ColorUtils.convertLinearToSRGB(baseColor, baseColor);
  188. writeBlock(pixels, index, baseColor, blockSize);
  189. }
  190. if (paletteTexturePixels.emissive) {
  191. const pixels = paletteTexturePixels.emissive;
  192. const emissive = [...material.getEmissiveFactor(), 1] as vec4;
  193. ColorUtils.convertLinearToSRGB(emissive, emissive);
  194. writeBlock(pixels, index, emissive, blockSize);
  195. }
  196. if (paletteTexturePixels.metallicRoughness) {
  197. const pixels = paletteTexturePixels.metallicRoughness;
  198. const metallic = material.getMetallicFactor();
  199. const roughness = material.getRoughnessFactor();
  200. writeBlock(pixels, index, [0, roughness, metallic, 1], blockSize);
  201. }
  202. visitedKeys.add(key);
  203. materialIndices.set(key, index);
  204. }
  205. // (5) Compress palette textures and assign to palette materials.
  206. const mimeType = 'image/png';
  207. if (baseColorTexture) {
  208. const image = await savePixels(paletteTexturePixels.baseColor!, mimeType);
  209. baseColorTexture.setImage(image).setMimeType(mimeType);
  210. }
  211. if (emissiveTexture) {
  212. const image = await savePixels(paletteTexturePixels.emissive!, mimeType);
  213. emissiveTexture.setImage(image).setMimeType(mimeType);
  214. }
  215. if (metallicRoughnessTexture) {
  216. const image = await savePixels(paletteTexturePixels.metallicRoughness!, mimeType);
  217. metallicRoughnessTexture.setImage(image).setMimeType(mimeType);
  218. }
  219. // (6) Create palette materials, generate UVs, and assign both to prims.
  220. let nextPaletteMaterialIndex = 1;
  221. for (const prim of prims) {
  222. const srcMaterial = prim.getMaterial()!;
  223. const key = materialKeyMap.get(srcMaterial)!;
  224. const blockIndex = materialIndices.get(key)!;
  225. // UVs are centered horizontally in each block, descending vertically
  226. // to form a diagonal line in the UV layout. Easy and compressible.
  227. const baseUV = (blockIndex + 0.5) / keyCount;
  228. const padUV = (baseUV * (w - padWidth)) / w;
  229. const position = prim.getAttribute('POSITION')!;
  230. const buffer = position.getBuffer();
  231. const array = new Float32Array(position.getCount() * 2).fill(padUV);
  232. const uv = document.createAccessor().setType('VEC2').setArray(array).setBuffer(buffer);
  233. let dstMaterial;
  234. for (const material of paletteMaterials) {
  235. if (material.equals(srcMaterial, skipProps)) {
  236. dstMaterial = material;
  237. }
  238. }
  239. if (!dstMaterial) {
  240. const suffix = (nextPaletteMaterialIndex++).toString().padStart(3, '0');
  241. dstMaterial = srcMaterial.clone().setName(`PaletteMaterial${suffix}`);
  242. if (baseColorTexture) {
  243. dstMaterial
  244. .setBaseColorFactor([1, 1, 1, 1])
  245. .setBaseColorTexture(baseColorTexture)
  246. .getBaseColorTextureInfo()!
  247. .setMinFilter(TextureInfo.MinFilter.NEAREST)
  248. .setMagFilter(TextureInfo.MagFilter.NEAREST);
  249. }
  250. if (emissiveTexture) {
  251. dstMaterial
  252. .setEmissiveFactor([1, 1, 1])
  253. .setEmissiveTexture(emissiveTexture)
  254. .getEmissiveTextureInfo()!
  255. .setMinFilter(TextureInfo.MinFilter.NEAREST)
  256. .setMagFilter(TextureInfo.MagFilter.NEAREST);
  257. }
  258. if (metallicRoughnessTexture) {
  259. dstMaterial
  260. .setMetallicFactor(1)
  261. .setRoughnessFactor(1)
  262. .setMetallicRoughnessTexture(metallicRoughnessTexture)
  263. .getMetallicRoughnessTextureInfo()!
  264. .setMinFilter(TextureInfo.MinFilter.NEAREST)
  265. .setMagFilter(TextureInfo.MagFilter.NEAREST);
  266. }
  267. paletteMaterials.push(dstMaterial);
  268. }
  269. prim.setMaterial(dstMaterial).setAttribute('TEXCOORD_0', uv);
  270. }
  271. if (options.cleanup) {
  272. await document.transform(prune({ propertyTypes: [PropertyType.MATERIAL] }));
  273. }
  274. logger.debug(`${NAME}: Complete.`);
  275. });
  276. }
  277. /** Encodes a floating-point value on the interval [0,1] at 8-bit precision. */
  278. function encodeFloat(value: number): string {
  279. const hex = Math.round(value * 255).toString(16);
  280. return hex.length === 1 ? '0' + hex : hex;
  281. }
  282. /** Encodes an RGBA color in Linear-sRGB-D65 color space. */
  283. function encodeRGBA(value: vec4): string {
  284. ColorUtils.convertLinearToSRGB(value, value);
  285. return value.map(encodeFloat).join('');
  286. }
  287. /** Returns the nearest higher power of two. */
  288. function ceilPowerOfTwo(value: number): number {
  289. return Math.pow(2, Math.ceil(Math.log(value) / Math.LN2));
  290. }
  291. /** Writes an NxN block of pixels to an image, at the given block index. */
  292. function writeBlock(pixels: NdArray<TypedArray>, index: number, value: vec4, blockSize: number): void {
  293. for (let i = 0; i < blockSize; i++) {
  294. for (let j = 0; j < blockSize; j++) {
  295. pixels.set(index * blockSize + i, j, 0, value[0] * 255);
  296. pixels.set(index * blockSize + i, j, 1, value[1] * 255);
  297. pixels.set(index * blockSize + i, j, 2, value[2] * 255);
  298. pixels.set(index * blockSize + i, j, 3, value[3] * 255);
  299. }
  300. }
  301. }