chore: 初始化glTF模型压缩工具包

添加了完整的项目配置、工具函数、纹理压缩与模型优化实现,以及文档说明
This commit is contained in:
2026-05-18 16:51:56 +08:00
commit 18eaaff65f
13 changed files with 4525 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
dist
demo
node_modules

3
.npmrc Normal file
View File

@@ -0,0 +1,3 @@
registry=https://npm.blendercg.art/
always-auth=true
//npm.blendercg.art/:_authToken=MjVhMDVlN2ZiODUxMjFkNjhmYzVhZWFmOTZhNTA3Mzc6ZjkyNTYxN2RhM2QwOTc5NTFiMTU3MGEyM2U3Zjc0NzQ4YmUzYTJiOTc1Yzg0OWUwMzEzNjgyZDAwMjdlNDA2ZmZkYjJmODFhYWQ2MjVjYmFlZGJj

40
README.md Normal file
View File

@@ -0,0 +1,40 @@
# @g1sir8b/gltf-model-compressor
用于:
- 优化 `.gltf` / `.glb`
- 自动补全法线和切线
- 可选纹理压缩
- 输出为 `gltf + buffer.bin``glb`
## 构建
```bash
npm run build
```
## 使用
```ts
import optimizeModel from '@g1sir8b/gltf-model-compressor';
await optimizeModel(
'D:/input/model.glb',
'D:/output',
'medium',
true
);
```
也支持对象参数:
```ts
import { optimizeModelWithOptions } from '@g1sir8b/gltf-model-compressor';
await optimizeModelWithOptions({
input: 'D:/input/model.gltf',
output: 'D:/output',
compressTex: 'off',
toGLB: false
});
```

1458
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "@g1sir8b/gltf-model-compressor",
"version": "1.0.1",
"description": "Standalone npm package for GLTF/GLB optimization and texture compression.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"files": [
"dist",
"README.md"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"gl-matrix": "^3.4.3",
"gltf-pipeline": "^4.1.0",
"image-size": "^2.0.2",
"jimp": "^1.6.1",
"mime": "^3.0.0",
"seinjs-texture-compressor": "^1.0.3"
},
"devDependencies": {
"@types/mime": "^3.0.0",
"@types/node": "^20.0.0",
"typescript": "^5.2.2"
}
}

1549
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,6 @@
onlyBuiltDependencies:
- '@webassembly/sharp'
- protobufjs
supportedArchitectures:
os: [win32]
cpu: [x64]

286
src/compressTexture.ts Normal file
View File

@@ -0,0 +1,286 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawn } from 'child_process';
import { readImageMetadata } from './imageTools';
import { showInfo } from './utils';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { getBinaryDirectory } = require('seinjs-texture-compressor/dist/cli/lib/utilities');
export type TextureCompressionQuality = 'high' | 'medium' | 'low';
function resolveTmpDir(): string {
const explicit = (process.env.XR_TEX_TMP_DIR || '').trim();
if (explicit) {
return path.resolve(explicit);
}
const preferRam = process.env.XR_TMP_PREFER_RAM !== '0';
if (preferRam && process.platform !== 'win32') {
const shmBase = '/dev/shm';
try {
if (fs.existsSync(shmBase) && fs.statSync(shmBase).isDirectory()) {
return path.join(shmBase, 'xr-frame-toolkit-tmp');
}
} catch {
// Ignore tmp dir probing errors.
}
}
return path.resolve(os.tmpdir(), 'xr-frame-toolkit-tmp');
}
const TMP_DIR = resolveTmpDir();
if (!fs.existsSync(TMP_DIR)) {
fs.mkdirSync(TMP_DIR, { recursive: true });
}
showInfo(`[tex-tmp] using tmp dir: ${TMP_DIR}`);
const HEARTBEAT_MS = Math.max(5_000, Number(process.env.XR_TEX_HEARTBEAT_MS || 30_000));
const BASE_STEP_TIMEOUT_MS = Math.max(30_000, Number(process.env.XR_TEX_STEP_TIMEOUT_MS || 20 * 60 * 1000));
const ETCSLOW_STEP_TIMEOUT_MS = Math.max(60_000, Number(process.env.XR_TEX_ETCSLOW_TIMEOUT_MS || 120 * 60 * 1000));
function mipLevels(width: number): number {
return Math.floor(Math.log2(Math.max(1, width))) + 1;
}
async function runToolWithWatch(
src: string,
destFormat: 'astc' | 'etc' | 'pvrtc' | 's3tc',
destEncoding: string,
destQuality: string,
inputPath: string,
outputPath: string,
widthForMips: number
) {
const binDir = await getBinaryDirectory();
const isS3TC = destFormat === 's3tc';
const toolName = isS3TC ? 'crunch' : 'PVRTexToolCLI';
const toolPath = path.join(binDir, toolName);
const flags = isS3TC
? [
'-file',
inputPath,
'-out',
outputPath,
'-fileformat',
'ktx',
`-${destEncoding}`,
'-dxtQuality',
`${destQuality}`,
'-helperThreads',
os.cpus().length.toString(),
'-mipMode',
'Generate',
'-maxmips',
`${mipLevels(widthForMips)}`
]
: [
'-i',
inputPath,
'-o',
outputPath,
'-f',
`${destEncoding}`,
'-q',
`${destQuality}`,
'-pot',
'+',
'-m',
`${mipLevels(widthForMips)}`
];
if (!isS3TC && destFormat === 'pvrtc') {
flags.push('-square', '+');
}
showInfo(`Using flags: ${flags.join(',')}`);
const startedAt = Date.now();
const timeoutMs = destFormat === 'etc' && destQuality === 'etcslow'
? ETCSLOW_STEP_TIMEOUT_MS
: BASE_STEP_TIMEOUT_MS;
await new Promise<void>((resolve, reject) => {
const child = spawn(toolPath, flags, {
env: {
...process.env,
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`
},
windowsHide: true
});
let timedOut = false;
const heartbeatTimer = setInterval(() => {
const elapsed = Math.round((Date.now() - startedAt) / 1000);
showInfo(`[tex-watch] running ${src} -> ${destFormat} (${destEncoding}/${destQuality}), ${elapsed}s elapsed`);
}, HEARTBEAT_MS);
const timeoutTimer = setTimeout(() => {
timedOut = true;
const elapsed = Math.round((Date.now() - startedAt) / 1000);
showInfo(`[tex-watch] timeout ${src} -> ${destFormat} after ${elapsed}s, killing pid=${child.pid || -1}`);
try {
child.kill('SIGKILL');
} catch {
// Ignore kill errors.
}
if (process.platform === 'win32' && child.pid) {
try {
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
stdio: 'ignore',
windowsHide: true
});
killer.unref();
} catch {
// Ignore taskkill errors.
}
}
}, timeoutMs);
child.stdout?.on('data', (data) => {
const text = String(data || '').trim();
if (text) {
showInfo(`[tex-cli][${destFormat}] ${text}`);
}
});
child.stderr?.on('data', (data) => {
const text = String(data || '').trim();
if (text) {
showInfo(`[tex-cli][${destFormat}] ${text}`);
}
});
child.once('error', (error) => {
clearInterval(heartbeatTimer);
clearTimeout(timeoutTimer);
reject(error);
});
child.once('exit', (code) => {
clearInterval(heartbeatTimer);
clearTimeout(timeoutTimer);
if (timedOut) {
reject(
new Error(
`Compression timeout: ${src} -> ${destFormat}, quality=${destQuality}, limit=${Math.round(timeoutMs / 1000)}s`
)
);
return;
}
if (code !== 0) {
reject(new Error(`Compression tool exited with error code ${code}`));
return;
}
resolve();
});
});
}
export default async function compressTexture(
src: string,
buffer: Buffer,
quality: TextureCompressionQuality,
isNormalMap: boolean
): Promise<{ astc: Buffer; pvrtc: Buffer; s3tc: Buffer; etc: Buffer }> {
const isTransparent = /(png|exr)$/i.test(src);
const metadata = readImageMetadata(buffer);
const widthForMips = metadata?.width || 1;
if (!metadata?.width) {
showInfo(`[tex-debug][mips] ${src}: width unknown, fallback width=1`);
}
let destEncoding = '';
let destQuality = '';
const runId = `${process.pid}_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
const tmpSrcPath = path.resolve(TMP_DIR, `compress_tex_${runId}_src${path.extname(src)}`);
fs.writeFileSync(tmpSrcPath, buffer);
const tmpDestPaths: string[] = [];
const result = {} as { astc: Buffer; pvrtc: Buffer; s3tc: Buffer; etc: Buffer };
try {
for (const destFormat of ['astc', 'etc', 'pvrtc', 's3tc'] as const) {
if (destFormat === 'astc') {
if (quality === 'high') {
destEncoding = (isTransparent || isNormalMap) ? 'ASTC_4x4' : 'ASTC_5x5';
destQuality = 'astcthorough';
} else if (quality === 'medium') {
destEncoding = (isTransparent || isNormalMap) ? 'ASTC_5x5' : 'ASTC_6x6';
destQuality = 'astcmedium';
} else {
destEncoding = (isTransparent || isNormalMap) ? 'ASTC_6x6' : 'ASTC_8x6';
destQuality = 'astcfast';
}
} else if (destFormat === 'pvrtc') {
if (quality === 'low') {
destEncoding = (isTransparent && !isNormalMap) ? 'PVRTC1_2' : 'PVRTC1_2_RGB';
destQuality = 'pvrtcbest';
} else {
destEncoding = (isTransparent && !isNormalMap) ? 'PVRTC1_4' : 'PVRTC1_4_RGB';
destQuality = 'pvrtcnormal';
}
} else if (destFormat === 'etc') {
destEncoding = (isTransparent && !isNormalMap) ? 'ETC2_RGBA' : 'ETC2_RGB';
destQuality = 'etcfast';
} else if (destFormat === 's3tc') {
if (quality === 'low') {
destEncoding = (isTransparent && !isNormalMap) ? 'DXT3' : 'DXT1';
destQuality = 'better';
} else {
destEncoding = (isTransparent && !isNormalMap) ? 'DXT5' : 'DXT3';
destQuality = 'fast';
}
}
showInfo(`packing: ${src} to ${destFormat}`);
const tmpDestPath = path.resolve(TMP_DIR, `compress_tex_${runId}_${destFormat}.ktx`);
tmpDestPaths.push(tmpDestPath);
try {
await runToolWithWatch(
src,
destFormat,
destEncoding,
destQuality,
tmpSrcPath,
tmpDestPath,
widthForMips
);
result[destFormat] = fs.readFileSync(tmpDestPath);
} catch (error: any) {
throw new Error(`Compress error: '${error.message}'`);
}
}
} finally {
try {
if (fs.existsSync(tmpSrcPath)) {
fs.unlinkSync(tmpSrcPath);
}
} catch {
// Ignore cleanup errors.
}
for (const tmpDestPath of tmpDestPaths) {
try {
if (fs.existsSync(tmpDestPath)) {
fs.unlinkSync(tmpDestPath);
}
} catch {
// Ignore cleanup errors.
}
}
}
return result;
}

46
src/imageTools.ts Normal file
View File

@@ -0,0 +1,46 @@
import * as path from 'path';
import { imageSize } from 'image-size';
import { Jimp, JimpMime } from 'jimp';
export interface ImageMetadata {
width?: number;
height?: number;
type?: string;
}
const MIME_BY_EXT: Record<string, string> = {
'.bmp': JimpMime.bmp,
'.gif': JimpMime.gif,
'.jpeg': JimpMime.jpeg,
'.jpg': JimpMime.jpeg,
'.png': JimpMime.png,
'.tif': JimpMime.tiff,
'.tiff': JimpMime.tiff
};
export function readImageMetadata(buffer: Buffer): ImageMetadata {
const result = imageSize(buffer);
return {
width: result.width,
height: result.height,
type: result.type
};
}
export async function resizeImageBuffer(
buffer: Buffer,
relativePath: string,
width: number,
height: number
): Promise<Buffer | null> {
const ext = path.extname(relativePath).toLowerCase();
const mime = MIME_BY_EXT[ext];
if (!mime) {
return null;
}
const image = await Jimp.read(buffer);
image.resize({ w: width, h: height });
return image.getBuffer(mime as any);
}

4
src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export { default, optimizeModel, optimizeModelWithOptions } from './modelCompressor';
export { optimizeModel as compressModel } from './modelCompressor';
export type { CompressTex, OptimizeModelOptions } from './modelCompressor';
export { default as compressTexture } from './compressTexture';

1015
src/modelCompressor.ts Normal file

File diff suppressed because it is too large Load Diff

65
src/utils.ts Normal file
View File

@@ -0,0 +1,65 @@
import * as fs from 'fs';
import * as path from 'path';
export function showWarn(message: string) {
console.error('\x1b[33m%s\x1b[0m', `Warn: ${message}`);
}
export function showInfo(message: string) {
console.info('\x1b[32m%s\x1b[0m', message);
}
const GLTF_EXTENSIONS = new Set(['.gltf', '.glb']);
export function isGLTFFile(filePath: string) {
return GLTF_EXTENSIONS.has(path.extname(filePath).toLowerCase());
}
export async function readFileJson(filePath: string): Promise<object> {
return new Promise((resolve, reject) => {
fs.readFile(filePath, { encoding: 'utf8' }, (error, content) => {
if (error) {
reject(error);
return;
}
resolve(JSON.parse(content));
});
});
}
export async function readFileBuffer(filePath: string): Promise<Buffer> {
return new Promise((resolve, reject) => {
fs.readFile(filePath, (error, content) => {
if (error) {
reject(error);
return;
}
resolve(content);
});
});
}
export async function writeFile(filePath: string, buffer: Buffer | string): Promise<string> {
return new Promise((resolve, reject) => {
const dirPath = path.dirname(filePath);
if (!fs.existsSync(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true });
}
fs.writeFile(
filePath,
buffer,
typeof buffer === 'string' ? { encoding: 'utf8' } : {},
(error) => {
if (error) {
reject(error);
return;
}
resolve(filePath);
}
);
});
}

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": false,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": [
"src/**/*"
]
}