Skip to content

Commit

Permalink
[extensions] Add EXT_texture_avif (#771)
Browse files Browse the repository at this point in the history
  • Loading branch information
donmccurdy authored Feb 3, 2023
1 parent fb97455 commit 8062f29
Show file tree
Hide file tree
Showing 9 changed files with 269 additions and 4 deletions.
23 changes: 23 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1076,6 +1076,29 @@ compression methods are typically more forgiving than GPU texture compression,
and require less tuning to achieve good visual and filesize results.
`.trim();

// AVIF
program
.command('avif', 'AVIF texture compression')
.help(TEXTURE_COMPRESS_SUMMARY.replace(/{VARIANT}/g, 'AVIF'))
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option(
'--formats <formats>',
'Texture formats to include (glob)',
{validator: ['image/png', 'image/jpeg', '*'], default: '*'}
)
.option(
'--slots <slots>',
'Texture slots to include (glob)',
{validator: program.STRING, default: '*'}
)
.action(({args, options, logger}) => {
const formats = micromatch.makeRe(String(options.formats), MICROMATCH_OPTIONS);
const slots = micromatch.makeRe(String(options.slots), MICROMATCH_OPTIONS);
return Session.create(io, logger, args.input, args.output)
.transform(textureCompress({targetFormat: 'avif', encoder: sharp, formats, slots}));
});

// WEBP
program
.command('webp', 'WebP texture compression')
Expand Down
1 change: 1 addition & 0 deletions packages/extensions/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const EXT_MESH_GPU_INSTANCING = 'EXT_mesh_gpu_instancing';
export const EXT_MESHOPT_COMPRESSION = 'EXT_meshopt_compression';
export const EXT_TEXTURE_WEBP = 'EXT_texture_webp';
export const EXT_TEXTURE_AVIF = 'EXT_texture_avif';
export const KHR_DRACO_MESH_COMPRESSION = 'KHR_draco_mesh_compression';
export const KHR_LIGHTS_PUNCTUAL = 'KHR_lights_punctual';
export const KHR_MATERIALS_CLEARCOAT = 'KHR_materials_clearcoat';
Expand Down
1 change: 1 addition & 0 deletions packages/extensions/src/ext-texture-avif/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './texture-avif';
171 changes: 171 additions & 0 deletions packages/extensions/src/ext-texture-avif/texture-avif.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import {
Extension,
ImageUtils,
ImageUtilsFormat,
PropertyType,
ReaderContext,
WriterContext,
vec2,
BufferUtils,
} from '@gltf-transform/core';
import { EXT_TEXTURE_AVIF } from '../constants';

const NAME = EXT_TEXTURE_AVIF;

class AVIFImageUtils implements ImageUtilsFormat {
match(array: Uint8Array): boolean {
return array.length >= 12 && BufferUtils.decodeText(array.slice(4, 12)) === 'ftypavif';
}
/**
* Probes size of AVIF or HEIC image. Assumes a single static image, without
* orientation or other metadata that would affect dimensions.
*/
getSize(array: Uint8Array): vec2 | null {
if (!this.match(array)) return null;

// References:
// - https://stackoverflow.com/questions/66222773/how-to-get-image-dimensions-from-an-avif-file
// - https://github.com/nodeca/probe-image-size/blob/master/lib/parse_sync/avif.js

const view = new DataView(array.buffer, array.byteOffset, array.byteLength);

let box = unbox(view, 0);
if (!box) return null;

let offset = box.end;
while ((box = unbox(view, offset))) {
if (box.type === 'meta') {
offset = box.start + 4; // version + flags
} else if (box.type === 'iprp' || box.type === 'ipco') {
offset = box.start;
} else if (box.type === 'ispe') {
return [view.getUint32(box.start + 4), view.getUint32(box.start + 8)];
} else if (box.type === 'mdat') {
break; // mdat should be last, unlikely to find metadata past here.
} else {
offset = box.end;
}
}

return null;
}
getChannels(_buffer: Uint8Array): number {
return 4;
}
}

/**
* # TextureAVIF
*
* [`EXT_texture_avif`](https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Vendor/EXT_texture_avif/)
* enables AVIF images for any material texture.
*
* [[include:VENDOR_EXTENSIONS_NOTE.md]]
*
* AVIF offers greatly reduced transmission size, but
* [requires browser support](https://caniuse.com/avif). Like PNG and JPEG, an AVIF image is
* *fully decompressed* when uploaded to the GPU, which increases upload time and GPU memory cost.
* For seamless uploads and minimal GPU memory cost, it is necessary to use a GPU texture format
* like Basis Universal, with the `KHR_texture_basisu` extension.
*
* Defining no {@link ExtensionProperty} types, this {@link Extension} is simply attached to the
* {@link Document}, and affects the entire Document by allowing use of the `image/avif` MIME type
* and passing AVIF image data to the {@link Texture.setImage} method. Without the Extension, the
* same MIME types and image data would yield an invalid glTF document, under the stricter core glTF
* specification.
*
* Properties:
* - N/A
*
* ### Example
*
* ```typescript
* import { TextureAVIF } from '@gltf-transform/extensions';
*
* // Create an Extension attached to the Document.
* const avifExtension = document.createExtension(TextureAVIF)
* .setRequired(true);
* document.createTexture('MyAVIFTexture')
* .setMimeType('image/avif')
* .setImage(fs.readFileSync('my-texture.avif'));
* ```
*
* AVIF conversion is not done automatically when adding the extension as shown above — you must
* convert the image data first, then pass the `.avif` payload to {@link Texture.setImage}.
*
* When the `EXT_texture_avif` extension is added to a file by glTF-Transform, the extension should
* always be required. This tool does not support writing assets that "fall back" to optional PNG or
* JPEG image data.
*/
export class EXTTextureAVIF extends Extension {
public readonly extensionName = NAME;
/** @hidden */
public readonly prereadTypes = [PropertyType.TEXTURE];
public static readonly EXTENSION_NAME = NAME;

/** @hidden */
public static register(): void {
ImageUtils.registerFormat('image/avif', new AVIFImageUtils());
}

/** @hidden */
public preread(context: ReaderContext): this {
const textureDefs = context.jsonDoc.json.textures || [];
textureDefs.forEach((textureDef) => {
if (textureDef.extensions && textureDef.extensions[NAME]) {
textureDef.source = (textureDef.extensions[NAME] as { source: number }).source;
}
});
return this;
}

/** @hidden */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public read(context: ReaderContext): this {
return this;
}

/** @hidden */
public write(context: WriterContext): this {
const jsonDoc = context.jsonDoc;

this.document
.getRoot()
.listTextures()
.forEach((texture) => {
if (texture.getMimeType() === 'image/avif') {
const imageIndex = context.imageIndexMap.get(texture);
const textureDefs = jsonDoc.json.textures || [];
textureDefs.forEach((textureDef) => {
if (textureDef.source === imageIndex) {
textureDef.extensions = textureDef.extensions || {};
textureDef.extensions[NAME] = { source: textureDef.source };
delete textureDef.source;
}
});
}
});

return this;
}
}

interface IBox {
type: string;
start: number;
end: number;
}

function unbox(data: DataView, offset: number): IBox | null {
if (data.byteLength < 4 + offset) return null;

// size includes first 4 bytes (length)
const size = data.getUint32(offset);
if (data.byteLength < size + offset || size < 8) return null;

return {
type: BufferUtils.decodeText(new Uint8Array(data.buffer, data.byteOffset + offset + 4, 4)),
start: offset + 8,
end: offset + size,
};
}
4 changes: 2 additions & 2 deletions packages/extensions/src/ext-texture-webp/texture-webp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ class WEBPImageUtils implements ImageUtilsFormat {
*
* [[include:_VENDOR_EXTENSIONS.md]]
*
* WebP typically provides the minimal transmission
* size, but [requires browser support](https://caniuse.com/webp). Like PNG and JPEG, a WebP image is
* WebP offers greatly reduced transmission size, but
* [requires browser support](https://caniuse.com/webp). Like PNG and JPEG, a WebP image is
* *fully decompressed* when uploaded to the GPU, which increases upload time and GPU memory cost.
* For seamless uploads and minimal GPU memory cost, it is necessary to use a GPU texture format
* like Basis Universal, with the `KHR_texture_basisu` extension.
Expand Down
10 changes: 9 additions & 1 deletion packages/extensions/src/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { EXTMeshGPUInstancing } from './ext-mesh-gpu-instancing';
import { EXTMeshoptCompression } from './ext-meshopt-compression';
import { EXTTextureAVIF } from './ext-texture-avif';
import { EXTTextureWebP } from './ext-texture-webp';
import { KHRDracoMeshCompression } from './khr-draco-mesh-compression';
import { KHRLightsPunctual } from './khr-lights-punctual';
Expand Down Expand Up @@ -41,10 +42,17 @@ export const KHRONOS_EXTENSIONS = [
KHRXMP,
];

export const ALL_EXTENSIONS = [EXTMeshGPUInstancing, EXTMeshoptCompression, EXTTextureWebP, ...KHRONOS_EXTENSIONS];
export const ALL_EXTENSIONS = [
EXTMeshGPUInstancing,
EXTMeshoptCompression,
EXTTextureAVIF,
EXTTextureWebP,
...KHRONOS_EXTENSIONS,
];

export * from './ext-mesh-gpu-instancing';
export * from './ext-meshopt-compression';
export * from './ext-texture-avif';
export * from './ext-texture-webp';
export * from './khr-draco-mesh-compression';
export * from './khr-lights-punctual';
Expand Down
Binary file added packages/extensions/test/in/test.avif
Binary file not shown.
61 changes: 61 additions & 0 deletions packages/extensions/test/texture-avif.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import test from 'ava';
import { Document, GLTF, ImageUtils, JSONDocument, NodeIO } from '@gltf-transform/core';
import { EXTTextureAVIF } from '@gltf-transform/extensions';
import path, { dirname } from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';

const WRITER_OPTIONS = { basename: 'extensionTest' };

const io = new NodeIO().registerExtensions([EXTTextureAVIF]);
const __dirname = dirname(fileURLToPath(import.meta.url));

test('@gltf-transform/extensions::texture-avif', async (t) => {
const doc = new Document();
doc.createBuffer();
const avifExtension = doc.createExtension(EXTTextureAVIF);
const tex1 = doc.createTexture('AVIFTexture').setMimeType('image/avif').setImage(new Uint8Array(10));
const tex2 = doc.createTexture('PNGTexture').setMimeType('image/png').setImage(new Uint8Array(15));
doc.createMaterial().setBaseColorTexture(tex1).setEmissiveTexture(tex2);

let jsonDoc: JSONDocument;

jsonDoc = await io.writeJSON(doc, WRITER_OPTIONS);

// Writing to file.
t.deepEqual(jsonDoc.json.extensionsUsed, ['EXT_texture_avif'], 'writes extensionsUsed');
t.is(jsonDoc.json.textures[0].source, undefined, 'omits .source on AVIF texture');
t.is(jsonDoc.json.textures[1].source, 1, 'includes .source on PNG texture');
t.is(
(jsonDoc.json.textures[0].extensions['EXT_texture_avif'] as GLTF.ITexture).source,
0,
'includes .source on AVIF extension'
);

// Read (roundtrip) from file.
const rtDoc = await io.readJSON(jsonDoc);
const rtRoot = rtDoc.getRoot();
t.is(rtRoot.listTextures()[0].getMimeType(), 'image/avif', 'reads AVIF mimetype');
t.is(rtRoot.listTextures()[1].getMimeType(), 'image/png', 'reads PNG mimetype');
t.is(rtRoot.listTextures()[0].getImage().byteLength, 10, 'reads AVIF payload');
t.is(rtRoot.listTextures()[1].getImage().byteLength, 15, 'reads PNG payload');

// Clean up extension data, revert to core glTF.
avifExtension.dispose();
tex1.dispose();
jsonDoc = await io.writeJSON(doc, WRITER_OPTIONS);
t.is(jsonDoc.json.extensionsUsed, undefined, 'clears extensionsUsed');
t.is(jsonDoc.json.textures.length, 1, 'writes only 1 texture');
t.is(jsonDoc.json.textures[0].source, 0, 'includes .source on PNG texture');
});

test('@gltf-transform/core::image-utils | avif', (t) => {
const avif = fs.readFileSync(path.join(__dirname, 'in', 'test.avif'));
const buffer = new Uint8Array([0, 1, 2, 3]);

t.is(ImageUtils.getSize(new Uint8Array(8), 'image/avif'), null, 'invalid');
t.is(ImageUtils.getSize(buffer, 'image/avif'), null, 'no size');
t.deepEqual(ImageUtils.getSize(avif, 'image/avif'), [256, 256], 'size');
t.is(ImageUtils.getChannels(avif, 'image/avif'), 4, 'channels');
t.is(ImageUtils.getGPUByteLength(avif, 'image/avif'), 349524, 'gpuSize');
});
2 changes: 1 addition & 1 deletion packages/functions/src/texture-compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { TextureResizeFilter } from './texture-resize';
const NAME = 'textureCompress';

type Format = (typeof FORMATS)[number];
const FORMATS = ['jpeg', 'png', 'webp'] as const;
const FORMATS = ['jpeg', 'png', 'webp', 'avif'] as const;
const SUPPORTED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

export interface TextureCompressOptions {
Expand Down

0 comments on commit 8062f29

Please sign in to comment.