Implement model optimization and caching for 3D characters
- Added a new modelsProxyRouter to handle requests for optimized 3D character models. - Introduced modelsProxyService to manage GLB file optimization using gltf-transform with Draco compression. - Updated app.js to include the new modelsProxyRouter for API access. - Enhanced .gitignore to exclude model cache files. - Added scripts for optimizing GLB models and updated README with optimization instructions. - Integrated DRACOLoader in Character3D.vue for loading compressed models. - Updated FamilyView.vue to streamline character rendering logic.
This commit is contained in:
@@ -12,6 +12,7 @@ import socialnetworkRouter from './routers/socialnetworkRouter.js';
|
||||
import forumRouter from './routers/forumRouter.js';
|
||||
import falukantRouter from './routers/falukantRouter.js';
|
||||
import friendshipRouter from './routers/friendshipRouter.js';
|
||||
import modelsProxyRouter from './routers/modelsProxyRouter.js';
|
||||
import blogRouter from './routers/blogRouter.js';
|
||||
import match3Router from './routers/match3Router.js';
|
||||
import taxiRouter from './routers/taxiRouter.js';
|
||||
@@ -74,6 +75,7 @@ app.use('/api/vocab', vocabRouter);
|
||||
app.use('/api/forum', forumRouter);
|
||||
app.use('/api/falukant', falukantRouter);
|
||||
app.use('/api/friendships', friendshipRouter);
|
||||
app.use('/api/models', modelsProxyRouter);
|
||||
app.use('/api/blog', blogRouter);
|
||||
app.use('/api/termine', termineRouter);
|
||||
|
||||
|
||||
1514
backend/package-lock.json
generated
1514
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,8 @@
|
||||
"sharp": "^0.34.3",
|
||||
"socket.io": "^4.7.5",
|
||||
"uuid": "^11.1.0",
|
||||
"ws": "^8.18.0"
|
||||
"ws": "^8.18.0",
|
||||
"@gltf-transform/cli": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sequelize-cli": "^6.6.2"
|
||||
|
||||
28
backend/routers/modelsProxyRouter.js
Normal file
28
backend/routers/modelsProxyRouter.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import { getOptimizedModelPath } from '../services/modelsProxyService.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /api/models/3d/falukant/characters/:filename
|
||||
* Liefert die Draco-optimierte GLB-Datei (aus Cache oder nach Optimierung).
|
||||
*/
|
||||
router.get('/3d/falukant/characters/:filename', async (req, res) => {
|
||||
const { filename } = req.params;
|
||||
|
||||
try {
|
||||
const cachePath = await getOptimizedModelPath(filename);
|
||||
res.setHeader('Content-Type', 'model/gltf-binary');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
res.sendFile(cachePath);
|
||||
} catch (e) {
|
||||
if (e.message?.includes('Invalid model filename') || e.message?.includes('not found')) {
|
||||
return res.status(404).send(e.message);
|
||||
}
|
||||
console.error('[models-proxy]', e.message);
|
||||
res.status(500).send('Model optimization failed');
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
111
backend/services/modelsProxyService.js
Normal file
111
backend/services/modelsProxyService.js
Normal file
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Model-Proxy-Service: Lädt GLB-Dateien, komprimiert sie mit gltf-transform (Draco + Textur-Optimierung)
|
||||
* und legt sie im Datei-Cache ab. Weitere Requests werden aus dem Cache bedient.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const BACKEND_DIR = path.join(__dirname, '..');
|
||||
const PROJECT_ROOT = path.join(BACKEND_DIR, '..');
|
||||
const SOURCE_DIR = path.join(PROJECT_ROOT, 'frontend', 'public', 'models', '3d', 'falukant', 'characters');
|
||||
const CACHE_DIR = path.join(BACKEND_DIR, 'data', 'model-cache');
|
||||
const CLI_PATH = path.join(BACKEND_DIR, 'node_modules', '.bin', 'gltf-transform');
|
||||
|
||||
/** Erlaubte Dateinamen (nur [a-z0-9_.-]+.glb) */
|
||||
const FILENAME_RE = /^[a-z0-9_.-]+\.glb$/i;
|
||||
|
||||
/** Laufende Optimierungen pro Dateiname → Promise<string> (Cache-Pfad) */
|
||||
const pending = new Map();
|
||||
|
||||
/**
|
||||
* Stellt sicher, dass der Cache-Ordner existiert.
|
||||
*/
|
||||
function ensureCacheDir() {
|
||||
if (!fs.existsSync(CACHE_DIR)) {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob die Cache-Datei gültig ist (existiert und ist nicht älter als die Quelle).
|
||||
* @param {string} sourcePath
|
||||
* @param {string} cachePath
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isCacheValid(sourcePath, cachePath) {
|
||||
if (!fs.existsSync(cachePath)) return false;
|
||||
if (!fs.existsSync(sourcePath)) return false;
|
||||
const sourceStat = fs.statSync(sourcePath);
|
||||
const cacheStat = fs.statSync(cachePath);
|
||||
return cacheStat.mtimeMs >= sourceStat.mtimeMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt gltf-transform optimize aus (Draco + texture-size 1024).
|
||||
* @param {string} inputPath
|
||||
* @param {string} outputPath
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
function runOptimize(inputPath, outputPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(
|
||||
'node',
|
||||
[CLI_PATH, 'optimize', inputPath, outputPath, '--compress', 'draco', '--texture-size', '1024'],
|
||||
{ cwd: BACKEND_DIR, stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
);
|
||||
let stderr = '';
|
||||
child.stderr?.on('data', (d) => { stderr += d.toString(); });
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`gltf-transform exit ${code}: ${stderr}`));
|
||||
});
|
||||
child.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liefert den Pfad zur optimierten (gecachten) GLB-Datei.
|
||||
* Erstellt die optimierte Datei per gltf-transform, falls nicht (gültig) gecacht.
|
||||
*
|
||||
* @param {string} filename - z.B. "male_child.glb"
|
||||
* @returns {Promise<string>} Absoluter Pfad zur optimierten Datei (Cache)
|
||||
* @throws {Error} Bei ungültigem Dateinamen oder fehlender Quelldatei
|
||||
*/
|
||||
export async function getOptimizedModelPath(filename) {
|
||||
if (!FILENAME_RE.test(filename)) {
|
||||
throw new Error(`Invalid model filename: ${filename}`);
|
||||
}
|
||||
|
||||
const sourcePath = path.join(SOURCE_DIR, filename);
|
||||
const cacheFilename = filename.replace(/\.glb$/, '_opt.glb');
|
||||
const cachePath = path.join(CACHE_DIR, cacheFilename);
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
throw new Error(`Source model not found: ${filename}`);
|
||||
}
|
||||
|
||||
ensureCacheDir();
|
||||
|
||||
if (isCacheValid(sourcePath, cachePath)) {
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
let promise = pending.get(filename);
|
||||
if (!promise) {
|
||||
promise = (async () => {
|
||||
try {
|
||||
await runOptimize(sourcePath, cachePath);
|
||||
return cachePath;
|
||||
} finally {
|
||||
pending.delete(filename);
|
||||
}
|
||||
})();
|
||||
pending.set(filename, promise);
|
||||
}
|
||||
|
||||
return promise;
|
||||
}
|
||||
Reference in New Issue
Block a user