feat(Editor): glTFHandler 处理优化

This commit is contained in:
2025-11-13 01:44:26 +08:00
parent fb97114207
commit c84bc60d8c
9 changed files with 632 additions and 135 deletions
@@ -33,6 +33,7 @@ import {
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import {Session} from "./session";
import {loadScript} from "@/utils/common/utils";
import { optimizePNG } from "@/plugin/glTFHandler/optimizePng";
//使用'micromatch',因为'contains: true'没有像预期的那样在minimatch中工作。需要确保'*'匹配的模式,如'image/png'。
export const MICROMATCH_OPTIONS = { nocase: true, contains: true };
@@ -91,7 +92,6 @@ export default class GLTFHandler implements Plugin{
}
this.GLTFHandlerComponentRef = ref();
const finishFn = this.finish.bind(this);
this.modalInstance = window.$modal.create({
title: this.name,
preset:"card",
@@ -100,12 +100,12 @@ export default class GLTFHandler implements Plugin{
width: '90%',
maxWidth: '800px'
},
onAfterLeave: finishFn,
onAfterLeave: () => this.finish(),
content: () => {
return h(GLTFHandlerComponent,{
onOptimize:this.optimize.bind(this),
onFinish: finishFn,
ref:this.GLTFHandlerComponentRef
onOptimize: this.optimize.bind(this),
onFinish: () => this.finish(),
ref: this.GLTFHandlerComponentRef
},"")
},
})
@@ -137,7 +137,6 @@ export default class GLTFHandler implements Plugin{
/* 下面是实现的自定义的处理器方法 */
async optimize(opts:IPlugin.GLTFHandlerOptimizeModel,inputFile:File,outputFileName = ""){
// console.log("调用优化处理器,",opts,inputFile)
this.setLogger(`Optimize ${inputFile.name}`);
if(this.dracoScript.failMsg){
@@ -151,7 +150,10 @@ export default class GLTFHandler implements Plugin{
/* 文件准备就绪,开始优化 */
const transforms: Transform[] = [dedup()];
const transforms: Transform[] = [
optimizePNG(),
dedup()
];
if (opts.instance) transforms.push(instance({ min: opts.instanceMin }));
@@ -0,0 +1,26 @@
import type { Transform } from '@gltf-transform/core';
import { encodePNG } from './util';
function asUint8Array(data: unknown): Uint8Array {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
throw new Error('Unsupported texture image type');
}
export const optimizePNG = (): Transform => async (doc) => {
const textures = doc.getRoot().listTextures();
for (const tex of textures) {
// 仅处理 PNG
if (tex.getMimeType() !== 'image/png') continue;
const image = tex.getImage();
if (!image || image.byteLength === 0) continue;
// 归一化为 Uint8Array
const imgU8 = asUint8Array(image);
const stamped = await encodePNG(imgU8);
tex.setImage(stamped);
tex.setMimeType('image/png');
}
};
+110 -99
View File
@@ -1,125 +1,136 @@
import {Document, WebIO, FileUtils, Transform, Format, Logger} from '@gltf-transform/core';
import { Document, WebIO, FileUtils, Transform, Format, Logger } from '@gltf-transform/core';
import type { Packet, KHRXMP } from '@gltf-transform/extensions';
import { unpartition } from '@gltf-transform/functions';
import {Listr} from "./Listr";
import { formatBytes, XMPContext } from './util.js';
import { Listr } from "./Listr";
import { formatBytes, encodeGLB, XMPContext } from './util.js';
import GLTFHandler from "./glTFHandler";
export class Session {
private _outputFormat: Format;
private _display = false;
private _outputFormat: Format;
private _display = false;
constructor(
private _io: WebIO,
private _logger: Logger,
private setLogger: (log:string) => void,
private _input: string,
private _inputName: string,
private _output: string,
) {
_io.setLogger(_logger);
this._outputFormat = FileUtils.extension(_output) === 'glb' ? Format.GLB : Format.GLTF;
}
constructor(
private _io: WebIO,
private _logger: Logger,
private setLogger: (log: string) => void,
private _input: string,
private _inputName: string,
private _output: string,
) {
_io.setLogger(_logger);
this._outputFormat = FileUtils.extension(_output) === 'glb' ? Format.GLB : Format.GLTF;
}
public static create(handler:GLTFHandler, inputFileUrl: string,inputName:string, output: string): Session {
return new Session(handler.io, handler.logger, handler.setLogger.bind(handler),inputFileUrl, inputName,output);
}
public static create(handler: GLTFHandler, inputFileUrl: string, inputName: string, output: string): Session {
return new Session(handler.io, handler.logger, handler.setLogger.bind(handler), inputFileUrl, inputName, output);
}
public setDisplay(display: boolean): this {
this._display = display;
return this;
}
public setDisplay(display: boolean): this {
this._display = display;
return this;
}
public async transform(...transforms: Transform[]): Promise<File> {
this.setLogger("Start");
public async transform(...transforms: Transform[]): Promise<File> {
this.setLogger("Start");
let _document = this._input
? (await this._io.read(this._input)).setLogger(this._logger)
: new Document().setLogger(this._logger);
let _document = this._input
? (await this._io.read(this._input)).setLogger(this._logger)
: new Document().setLogger(this._logger);
// Warn and remove lossy compression, to avoid increasing loss on round trip.
for (const extensionName of ['KHR_draco_mesh_compression', 'EXT_meshopt_compression']) {
const extension = _document
.getRoot()
.listExtensionsUsed()
.find((extension) => extension.extensionName === extensionName);
if (extension) {
extension.dispose();
this._logger.warn(`Decoded ${extensionName}. Further compression will be lossy.`);
}
}
// 警告和消除有损压缩,以避免增加往返的损失。
for (const extensionName of ['KHR_draco_mesh_compression', 'EXT_meshopt_compression']) {
const extension = _document
.getRoot()
.listExtensionsUsed()
.find((extension) => extension.extensionName === extensionName);
if (extension) {
extension.dispose();
this._logger.warn(`Decoded ${extensionName}. Further compression will be lossy.`);
}
}
if (this._display) {
const tasks = [] as { title:string,task:(task: any) => Promise<void> }[];
for (const transform of transforms) {
tasks.push({
title: transform.name,
task: async (task) => {
try{
this.setLogger(task.title)
let time = performance.now();
_document = await _document.transform(transform);
time = Math.round(performance.now() - time);
this.setLogger(task.title.padEnd(20) + ` ${time}ms`)
}catch (error:unknown){
// @ts-ignore
this.setLogger(`${task.title} run fail: ${error?.message || error}`)
}
},
});
}
if (this._display) {
const tasks = [] as { title: string, task: (task: any) => Promise<void> }[];
for (const transform of transforms) {
tasks.push({
title: transform.name,
task: async (task) => {
try {
this.setLogger(task.title)
let time = performance.now();
_document = await _document.transform(transform);
time = Math.round(performance.now() - time);
this.setLogger(task.title.padEnd(20) + ` ${time}ms`)
} catch (error: unknown) {
// @ts-ignore
this.setLogger(`${task.title} run fail: ${error?.message || error}`)
}
},
});
}
await new Listr(tasks).run();
} else {
await _document.transform(...transforms);
}
await new Listr(tasks).run();
} else {
await _document.transform(...transforms);
}
await _document.transform(updateMetadata);
await _document.transform(updateMetadata);
if (this._outputFormat === Format.GLB) {
await _document.transform(unpartition());
}
if (this._outputFormat === Format.GLB) {
await _document.transform(unpartition());
}
const outputUint8Array = await this._io.writeBinary(_document);
// Uint8Array转file
const mimeType = this._outputFormat === Format.GLB ? "model/gltf-binary" : "model/gltf+json";
const blob = new Blob([outputUint8Array], { type: mimeType});
const outputFile = new File([blob], this._output, { type: mimeType });
const rawU8 = await this._io.writeBinary(_document);
const { lastReadBytes, lastWriteBytes } = this._io;
if (!this._input) {
const output = FileUtils.basename(this._output) + '.' + FileUtils.extension(this._output);
this._logger.info(`${output} (${formatBytes(lastWriteBytes)})`);
} else {
const input = FileUtils.basename(this._inputName) + '.' + FileUtils.extension(this._inputName);
const output = FileUtils.basename(this._output) + '.' + FileUtils.extension(this._output);
this._logger.info(
`${input} (${formatBytes(lastReadBytes)})` + `${output} (${formatBytes(lastWriteBytes)})`,
);
}
// 插入 WASM 水印
let outputUint8Array = rawU8;
try {
outputUint8Array = await encodeGLB(rawU8, {});
} catch (e: any) {
this._logger.warn('EncodeGLB skipped: ' + (e?.message || e));
}
this.setLogger("Done")
// Uint8Array转file
const mimeType = this._outputFormat === Format.GLB ? "model/gltf-binary" : "model/gltf+json";
const blob = new Blob([outputUint8Array], { type: mimeType });
const outputFile = new File([blob], this._output, { type: mimeType });
return outputFile;
}
const { lastReadBytes } = this._io;
const lastWriteBytes = outputUint8Array.byteLength;
if (!this._input) {
const output = FileUtils.basename(this._output) + '.' + FileUtils.extension(this._output);
this._logger.info(`${output} (${formatBytes(lastWriteBytes)})`);
} else {
const input = FileUtils.basename(this._inputName) + '.' + FileUtils.extension(this._inputName);
const output = FileUtils.basename(this._output) + '.' + FileUtils.extension(this._output);
this._logger.info(
`${input} (${formatBytes(lastReadBytes)})` + `${output} (${formatBytes(lastWriteBytes)})`,
);
}
this.setLogger("Done")
return outputFile;
}
}
function updateMetadata(_document: Document): void {
const root = _document.getRoot();
const xmpExtension = root
.listExtensionsUsed()
.find((ext) => ext.extensionName === 'KHR_xmp_json_ld') as KHRXMP | null;
const root = _document.getRoot();
const xmpExtension = root
.listExtensionsUsed()
.find((ext) => ext.extensionName === 'KHR_xmp_json_ld') as KHRXMP | null;
// 不要将KHR_xmp_json_ld添加到尚未使用它的资产中。
if (!xmpExtension) return;
// 不要将KHR_xmp_json_ld添加到尚未使用它的资产中。
if (!xmpExtension) return;
const rootPacket = root.getExtension<Packet>('KHR_xmp_json_ld') || xmpExtension.createPacket();
const rootPacket = root.getExtension<Packet>('KHR_xmp_json_ld') || xmpExtension.createPacket();
// xmp:MetadataDate should be the same as, or more recent than, xmp:ModifyDate.
// https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/xmp.md
const date = new Date().toISOString().substring(0, 10);
rootPacket
.setContext({ ...rootPacket.getContext(), xmp: XMPContext.xmp })
.setProperty('xmp:ModifyDate', date)
.setProperty('xmp:MetadataDate', date);
// xmp:MetadataDate should be the same as, or more recent than, xmp:ModifyDate.
// https://github.com/adobe/xmp-docs/blob/master/XMPNamespaces/xmp.md
const date = new Date().toISOString().substring(0, 10);
rootPacket
.setContext({ ...rootPacket.getContext(), xmp: XMPContext.xmp })
.setProperty('xmp:ModifyDate', date)
.setProperty('xmp:MetadataDate', date);
}
+39 -13
View File
@@ -1,27 +1,53 @@
import { injectWasm } from "@/utils/wasm/inject";
export const XMPContext: Record<string, string> = {
dc: 'http://purl.org/dc/elements/1.1/',
model3d: 'https://schema.khronos.org/model3d/xsd/1.0/',
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
xmp: 'http://ns.adobe.com/xap/1.0/',
xmpRights: 'http://ns.adobe.com/xap/1.0/rights/',
dc: 'http://purl.org/dc/elements/1.1/',
model3d: 'https://schema.khronos.org/model3d/xsd/1.0/',
rdf: 'http://www.w3.org/1999/02/22-rdf-syntax-ns#',
xmp: 'http://ns.adobe.com/xap/1.0/',
xmpRights: 'http://ns.adobe.com/xap/1.0/rights/',
};
export function formatLong(x: number): string {
return x.toString();
return x.toString();
}
export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes';
if (bytes === 0) return '0 Bytes';
const k = 1000;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const k = 1000;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
export function dim(str: string): string {
return `\x1b[2m${str}\x1b[0m`;
return `\x1b[2m${str}\x1b[0m`;
}
/* wasm内优化处理 */
let wasmReady = false;
async function ensureWasmReady() {
if (wasmReady) return;
await injectWasm({ wasmUrl: "/wasm/Astral3DglTFHandler.wasm" });
wasmReady = true;
}
export async function encodeGLB(u8: Uint8Array, meta: Record<string, any> = {}) {
await ensureWasmReady();
const out = window.glTFHandlerEncodeGLB(u8, JSON.stringify(meta || {}));
return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
}
export async function encodePNG(png: Uint8Array) {
await ensureWasmReady();
const out = window.glTFHandlerEncodePNG(png);
return new Uint8Array(out.buffer, out.byteOffset, out.byteLength);
}
/* wasm内优化处理 End */