Vollständige Next.js 15 Portfolio-Website mit: - Blog-System mit 100+ Artikeln - Supabase-Integration - Responsive Design mit Tailwind CSS - TypeScript-Konfiguration - Testing-Setup mit Vitest und Playwright Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
540 lines
16 KiB
Markdown
540 lines
16 KiB
Markdown
# GLTF Model Optimization für Web
|
|
|
|
**Meta-Description:** 3D Modelle für Web optimieren. GLTF/GLB Format, Draco Compression, Texture Optimization und LOD Strategien.
|
|
|
|
**Keywords:** GLTF, GLB, Draco, 3D Optimization, Web 3D, Texture Compression, LOD, Three.js
|
|
|
|
---
|
|
|
|
## Einführung
|
|
|
|
**GLTF** (GL Transmission Format) ist das "JPEG der 3D-Welt". Mit **Draco Compression** können Modelle um bis zu 95% verkleinert werden. Optimierte 3D-Assets sind essentiell für schnelle Ladezeiten und gute User Experience.
|
|
|
|
---
|
|
|
|
## GLTF Format Overview
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────────────────┐
|
|
│ GLTF/GLB FORMAT STRUCTURE │
|
|
├─────────────────────────────────────────────────────────────┤
|
|
│ │
|
|
│ GLTF (Text + Binary): │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ model.gltf (JSON - Scene Description) │ │
|
|
│ │ model.bin (Binary - Geometry Data) │ │
|
|
│ │ textures/ │ │
|
|
│ │ ├── diffuse.png (Texture Files) │ │
|
|
│ │ ├── normal.png │ │
|
|
│ │ └── metallic.png │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ GLB (Single Binary): │
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
|
│ │ model.glb │ │
|
|
│ │ ┌─────────────────────────────────────────────┐ │ │
|
|
│ │ │ Header (12 bytes) │ │ │
|
|
│ │ │ JSON Chunk (Scene + Materials) │ │ │
|
|
│ │ │ Binary Chunk (Geometry + Textures) │ │ │
|
|
│ │ └─────────────────────────────────────────────┘ │ │
|
|
│ └─────────────────────────────────────────────────────┘ │
|
|
│ │
|
|
│ File Size Breakdown (typical): │
|
|
│ ├── Textures: 80% │
|
|
│ ├── Geometry: 15% │
|
|
│ └── Metadata: 5% │
|
|
│ │
|
|
│ Supported Extensions: │
|
|
│ ├── KHR_draco_mesh_compression (Geometry) │
|
|
│ ├── KHR_texture_basisu (Textures) │
|
|
│ ├── KHR_mesh_quantization (Vertices) │
|
|
│ └── EXT_meshopt_compression (Alternative) │
|
|
│ │
|
|
└─────────────────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## gltf-transform CLI
|
|
|
|
```bash
|
|
# Installation
|
|
npm install -g @gltf-transform/cli
|
|
|
|
# Basic Optimization
|
|
gltf-transform optimize input.glb output.glb
|
|
|
|
# Mit Draco Compression
|
|
gltf-transform draco input.glb output.glb
|
|
|
|
# Texture Optimization (WebP)
|
|
gltf-transform webp input.glb output.glb --quality 80
|
|
|
|
# Full Optimization Pipeline
|
|
gltf-transform optimize input.glb output.glb \
|
|
--compress draco \
|
|
--texture-compress webp \
|
|
--texture-size 1024
|
|
|
|
# Modell analysieren
|
|
gltf-transform inspect input.glb
|
|
|
|
# Mesh vereinfachen
|
|
gltf-transform simplify input.glb output.glb --ratio 0.5
|
|
|
|
# Texturen resizen
|
|
gltf-transform resize input.glb output.glb --width 1024 --height 1024
|
|
```
|
|
|
|
---
|
|
|
|
## Node.js Optimization Script
|
|
|
|
```typescript
|
|
// scripts/optimize-model.ts
|
|
import { Document, NodeIO, Transform } from '@gltf-transform/core';
|
|
import { dedup, draco, textureCompress, weld, simplify } from '@gltf-transform/functions';
|
|
import { KHRDracoMeshCompression, KHRTextureBasisu } from '@gltf-transform/extensions';
|
|
import draco3d from 'draco3dgltf';
|
|
import sharp from 'sharp';
|
|
|
|
interface OptimizationOptions {
|
|
dracoCompression: boolean;
|
|
textureFormat: 'webp' | 'jpeg' | 'png';
|
|
textureQuality: number;
|
|
maxTextureSize: number;
|
|
simplifyRatio: number;
|
|
weldVertices: boolean;
|
|
}
|
|
|
|
async function optimizeModel(
|
|
inputPath: string,
|
|
outputPath: string,
|
|
options: OptimizationOptions
|
|
): Promise<{ originalSize: number; optimizedSize: number; reduction: number }> {
|
|
const io = new NodeIO()
|
|
.registerExtensions([KHRDracoMeshCompression, KHRTextureBasisu])
|
|
.registerDependencies({
|
|
'draco3d.decoder': await draco3d.createDecoderModule(),
|
|
'draco3d.encoder': await draco3d.createEncoderModule()
|
|
});
|
|
|
|
// Modell laden
|
|
const document = await io.read(inputPath);
|
|
const originalSize = (await io.writeBinary(document)).byteLength;
|
|
|
|
// Transformationen Pipeline
|
|
const transforms: Transform[] = [];
|
|
|
|
// 1. Duplikate entfernen
|
|
transforms.push(dedup());
|
|
|
|
// 2. Vertices zusammenführen
|
|
if (options.weldVertices) {
|
|
transforms.push(weld({ tolerance: 0.0001 }));
|
|
}
|
|
|
|
// 3. Mesh vereinfachen
|
|
if (options.simplifyRatio < 1) {
|
|
transforms.push(simplify({
|
|
ratio: options.simplifyRatio,
|
|
error: 0.001
|
|
}));
|
|
}
|
|
|
|
// 4. Texturen optimieren
|
|
transforms.push(textureCompress({
|
|
encoder: sharp,
|
|
targetFormat: options.textureFormat,
|
|
resize: [options.maxTextureSize, options.maxTextureSize],
|
|
quality: options.textureQuality
|
|
}));
|
|
|
|
// 5. Draco Compression
|
|
if (options.dracoCompression) {
|
|
transforms.push(draco({
|
|
quantizePosition: 14,
|
|
quantizeNormal: 10,
|
|
quantizeTexcoord: 12,
|
|
quantizeColor: 8
|
|
}));
|
|
}
|
|
|
|
// Transformationen anwenden
|
|
await document.transform(...transforms);
|
|
|
|
// Speichern
|
|
await io.write(outputPath, document);
|
|
|
|
const optimizedSize = (await io.readBinary(outputPath)).byteLength;
|
|
const reduction = ((originalSize - optimizedSize) / originalSize) * 100;
|
|
|
|
return {
|
|
originalSize,
|
|
optimizedSize,
|
|
reduction
|
|
};
|
|
}
|
|
|
|
// Verwendung
|
|
const result = await optimizeModel('./models/robot.glb', './models/robot-optimized.glb', {
|
|
dracoCompression: true,
|
|
textureFormat: 'webp',
|
|
textureQuality: 80,
|
|
maxTextureSize: 1024,
|
|
simplifyRatio: 0.75,
|
|
weldVertices: true
|
|
});
|
|
|
|
console.log(`Original: ${(result.originalSize / 1024 / 1024).toFixed(2)} MB`);
|
|
console.log(`Optimized: ${(result.optimizedSize / 1024 / 1024).toFixed(2)} MB`);
|
|
console.log(`Reduction: ${result.reduction.toFixed(1)}%`);
|
|
```
|
|
|
|
---
|
|
|
|
## Batch Optimization
|
|
|
|
```typescript
|
|
// scripts/batch-optimize.ts
|
|
import { glob } from 'glob';
|
|
import path from 'path';
|
|
|
|
async function batchOptimize(
|
|
inputDir: string,
|
|
outputDir: string,
|
|
options: OptimizationOptions
|
|
) {
|
|
const files = await glob(`${inputDir}/**/*.{glb,gltf}`);
|
|
|
|
console.log(`Found ${files.length} models to optimize`);
|
|
|
|
const results: Array<{
|
|
file: string;
|
|
originalSize: number;
|
|
optimizedSize: number;
|
|
reduction: number;
|
|
}> = [];
|
|
|
|
for (const file of files) {
|
|
const relativePath = path.relative(inputDir, file);
|
|
const outputPath = path.join(outputDir, relativePath.replace(/\.gltf$/, '.glb'));
|
|
|
|
// Output Verzeichnis erstellen
|
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
|
|
console.log(`Optimizing: ${relativePath}`);
|
|
|
|
try {
|
|
const result = await optimizeModel(file, outputPath, options);
|
|
results.push({ file: relativePath, ...result });
|
|
} catch (error) {
|
|
console.error(`Failed: ${relativePath}`, error);
|
|
}
|
|
}
|
|
|
|
// Report
|
|
const totalOriginal = results.reduce((sum, r) => sum + r.originalSize, 0);
|
|
const totalOptimized = results.reduce((sum, r) => sum + r.optimizedSize, 0);
|
|
|
|
console.log('\n=== Optimization Report ===');
|
|
console.log(`Total Original: ${(totalOriginal / 1024 / 1024).toFixed(2)} MB`);
|
|
console.log(`Total Optimized: ${(totalOptimized / 1024 / 1024).toFixed(2)} MB`);
|
|
console.log(`Total Reduction: ${((1 - totalOptimized / totalOriginal) * 100).toFixed(1)}%`);
|
|
|
|
return results;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## LOD Generation
|
|
|
|
```typescript
|
|
// scripts/generate-lod.ts
|
|
import { Document, NodeIO } from '@gltf-transform/core';
|
|
import { simplify, dedup, weld } from '@gltf-transform/functions';
|
|
|
|
interface LODConfig {
|
|
levels: Array<{
|
|
suffix: string;
|
|
ratio: number;
|
|
distance: number; // Für Three.js LOD
|
|
}>;
|
|
}
|
|
|
|
const defaultLODConfig: LODConfig = {
|
|
levels: [
|
|
{ suffix: '_lod0', ratio: 1.0, distance: 0 }, // Original
|
|
{ suffix: '_lod1', ratio: 0.5, distance: 10 }, // 50%
|
|
{ suffix: '_lod2', ratio: 0.25, distance: 25 }, // 25%
|
|
{ suffix: '_lod3', ratio: 0.1, distance: 50 } // 10%
|
|
]
|
|
};
|
|
|
|
async function generateLODs(
|
|
inputPath: string,
|
|
outputDir: string,
|
|
config: LODConfig = defaultLODConfig
|
|
) {
|
|
const io = new NodeIO();
|
|
const originalDocument = await io.read(inputPath);
|
|
|
|
const baseName = path.basename(inputPath, path.extname(inputPath));
|
|
const results: string[] = [];
|
|
|
|
for (const level of config.levels) {
|
|
const document = originalDocument.clone();
|
|
|
|
if (level.ratio < 1) {
|
|
await document.transform(
|
|
dedup(),
|
|
weld(),
|
|
simplify({ ratio: level.ratio, error: 0.001 })
|
|
);
|
|
}
|
|
|
|
const outputPath = path.join(outputDir, `${baseName}${level.suffix}.glb`);
|
|
await io.write(outputPath, document);
|
|
|
|
results.push(outputPath);
|
|
console.log(`Generated ${level.suffix}: ${level.ratio * 100}%`);
|
|
}
|
|
|
|
// LOD Manifest
|
|
const manifest = {
|
|
baseName,
|
|
levels: config.levels.map((level, i) => ({
|
|
file: `${baseName}${level.suffix}.glb`,
|
|
ratio: level.ratio,
|
|
distance: level.distance
|
|
}))
|
|
};
|
|
|
|
await fs.writeFile(
|
|
path.join(outputDir, `${baseName}_lod_manifest.json`),
|
|
JSON.stringify(manifest, null, 2)
|
|
);
|
|
|
|
return results;
|
|
}
|
|
|
|
// Three.js LOD Loader
|
|
import * as THREE from 'three';
|
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
|
|
|
async function loadLODModel(manifestUrl: string): Promise<THREE.LOD> {
|
|
const manifest = await fetch(manifestUrl).then(r => r.json());
|
|
const loader = new GLTFLoader();
|
|
|
|
const lod = new THREE.LOD();
|
|
|
|
for (const level of manifest.levels) {
|
|
const gltf = await loader.loadAsync(level.file);
|
|
lod.addLevel(gltf.scene, level.distance);
|
|
}
|
|
|
|
return lod;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Texture Optimization
|
|
|
|
```typescript
|
|
// scripts/optimize-textures.ts
|
|
import sharp from 'sharp';
|
|
import { Document, NodeIO, Texture } from '@gltf-transform/core';
|
|
|
|
interface TextureOptimizationConfig {
|
|
maxSize: number;
|
|
format: 'webp' | 'jpeg' | 'png' | 'basis';
|
|
quality: number;
|
|
normalMapQuality: number;
|
|
generateMipmaps: boolean;
|
|
}
|
|
|
|
async function optimizeTextures(
|
|
document: Document,
|
|
config: TextureOptimizationConfig
|
|
): Promise<void> {
|
|
const textures = document.getRoot().listTextures();
|
|
|
|
for (const texture of textures) {
|
|
const image = texture.getImage();
|
|
if (!image) continue;
|
|
|
|
const mimeType = texture.getMimeType();
|
|
const name = texture.getName() || 'unnamed';
|
|
|
|
// Textur-Typ erkennen
|
|
const isNormalMap = name.toLowerCase().includes('normal');
|
|
const quality = isNormalMap ? config.normalMapQuality : config.quality;
|
|
|
|
// Mit Sharp verarbeiten
|
|
let pipeline = sharp(image);
|
|
|
|
// Metadaten für Größenberechnung
|
|
const metadata = await pipeline.metadata();
|
|
const originalWidth = metadata.width || 0;
|
|
const originalHeight = metadata.height || 0;
|
|
|
|
// Resize wenn nötig
|
|
if (originalWidth > config.maxSize || originalHeight > config.maxSize) {
|
|
const scale = config.maxSize / Math.max(originalWidth, originalHeight);
|
|
pipeline = pipeline.resize(
|
|
Math.round(originalWidth * scale),
|
|
Math.round(originalHeight * scale),
|
|
{ fit: 'inside' }
|
|
);
|
|
}
|
|
|
|
// Format konvertieren
|
|
let outputBuffer: Buffer;
|
|
|
|
switch (config.format) {
|
|
case 'webp':
|
|
outputBuffer = await pipeline
|
|
.webp({ quality, lossless: isNormalMap })
|
|
.toBuffer();
|
|
texture.setMimeType('image/webp');
|
|
break;
|
|
|
|
case 'jpeg':
|
|
outputBuffer = await pipeline
|
|
.jpeg({ quality, mozjpeg: true })
|
|
.toBuffer();
|
|
texture.setMimeType('image/jpeg');
|
|
break;
|
|
|
|
case 'png':
|
|
outputBuffer = await pipeline
|
|
.png({ compressionLevel: 9 })
|
|
.toBuffer();
|
|
texture.setMimeType('image/png');
|
|
break;
|
|
}
|
|
|
|
texture.setImage(new Uint8Array(outputBuffer));
|
|
|
|
console.log(`Optimized texture: ${name} (${originalWidth}x${originalHeight} → compressed)`);
|
|
}
|
|
}
|
|
|
|
// Power-of-Two Konvertierung (für ältere GPUs)
|
|
function toPowerOfTwo(value: number): number {
|
|
let result = 1;
|
|
while (result < value) {
|
|
result *= 2;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function ensurePowerOfTwo(document: Document): Promise<void> {
|
|
const textures = document.getRoot().listTextures();
|
|
|
|
for (const texture of textures) {
|
|
const image = texture.getImage();
|
|
if (!image) continue;
|
|
|
|
const metadata = await sharp(image).metadata();
|
|
const width = metadata.width || 0;
|
|
const height = metadata.height || 0;
|
|
|
|
const pot2Width = toPowerOfTwo(width);
|
|
const pot2Height = toPowerOfTwo(height);
|
|
|
|
if (width !== pot2Width || height !== pot2Height) {
|
|
const resized = await sharp(image)
|
|
.resize(pot2Width, pot2Height, { fit: 'fill' })
|
|
.toBuffer();
|
|
|
|
texture.setImage(new Uint8Array(resized));
|
|
console.log(`Resized to Power-of-2: ${width}x${height} → ${pot2Width}x${pot2Height}`);
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Compression Comparison
|
|
|
|
| Method | Geometry Reduction | Texture Reduction | Decompression |
|
|
|--------|-------------------|-------------------|---------------|
|
|
| **Draco** | 90-95% | - | WASM (~300KB) |
|
|
| **Meshopt** | 70-80% | - | Fast, <50KB |
|
|
| **Quantization** | 30-50% | - | None needed |
|
|
| **WebP** | - | 50-70% | Native browser |
|
|
| **Basis/KTX2** | - | 70-85% | GPU direct |
|
|
|
|
---
|
|
|
|
## React Three Fiber Integration
|
|
|
|
```tsx
|
|
// components/OptimizedModel.tsx
|
|
'use client';
|
|
|
|
import { useGLTF, useProgress, Html } from '@react-three/drei';
|
|
import { Suspense } from 'react';
|
|
|
|
// Mit Draco Loader
|
|
export function OptimizedModel({ url }: { url: string }) {
|
|
const { scene } = useGLTF(url, true); // true = Draco Loader
|
|
return <primitive object={scene} />;
|
|
}
|
|
|
|
// Preload für bessere UX
|
|
useGLTF.preload('/models/robot-optimized.glb', true);
|
|
|
|
// Loading Progress
|
|
function LoadingProgress() {
|
|
const { progress } = useProgress();
|
|
|
|
return (
|
|
<Html center>
|
|
<div className="loading">
|
|
Loading... {progress.toFixed(0)}%
|
|
</div>
|
|
</Html>
|
|
);
|
|
}
|
|
|
|
// Lazy Loading mit LOD
|
|
export function LODModel({ manifest }: { manifest: string }) {
|
|
// Implement LOD loading...
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Fazit
|
|
|
|
GLTF Optimization bietet:
|
|
|
|
1. **Draco**: 90%+ Geometry Reduction
|
|
2. **WebP Textures**: 50-70% Texture Savings
|
|
3. **LOD**: Adaptive Quality nach Distanz
|
|
4. **gltf-transform**: Powerful CLI & API
|
|
|
|
Essentiell für performante 3D Web-Experiences.
|
|
|
|
---
|
|
|
|
## Bildprompts
|
|
|
|
1. "3D model optimization pipeline diagram, before and after comparison"
|
|
2. "Texture compression comparison, WebP vs PNG quality"
|
|
3. "LOD levels visualization, low to high poly transition"
|
|
|
|
---
|
|
|
|
## Quellen
|
|
|
|
- [glTF Transform Documentation](https://gltf-transform.dev/)
|
|
- [Draco 3D Compression](https://google.github.io/draco/)
|
|
- [GLTF Specification](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html)
|
|
- [OptimizeGLB](https://optimizeglb.com/)
|