chore: 初始化glTF模型压缩工具包
添加了完整的项目配置、工具函数、纹理压缩与模型优化实现,以及文档说明
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
demo
|
||||
node_modules
|
||||
3
.npmrc
Normal file
3
.npmrc
Normal file
@@ -0,0 +1,3 @@
|
||||
registry=https://npm.blendercg.art/
|
||||
always-auth=true
|
||||
//npm.blendercg.art/:_authToken=MjVhMDVlN2ZiODUxMjFkNjhmYzVhZWFmOTZhNTA3Mzc6ZjkyNTYxN2RhM2QwOTc5NTFiMTU3MGEyM2U3Zjc0NzQ4YmUzYTJiOTc1Yzg0OWUwMzEzNjgyZDAwMjdlNDA2ZmZkYjJmODFhYWQ2MjVjYmFlZGJj
|
||||
40
README.md
Normal file
40
README.md
Normal 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
1458
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal 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
1549
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
onlyBuiltDependencies:
|
||||
- '@webassembly/sharp'
|
||||
- protobufjs
|
||||
supportedArchitectures:
|
||||
os: [win32]
|
||||
cpu: [x64]
|
||||
286
src/compressTexture.ts
Normal file
286
src/compressTexture.ts
Normal 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
46
src/imageTools.ts
Normal 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
4
src/index.ts
Normal 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
1015
src/modelCompressor.ts
Normal file
File diff suppressed because it is too large
Load Diff
65
src/utils.ts
Normal file
65
src/utils.ts
Normal 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
17
tsconfig.json
Normal 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/**/*"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user