SHRINKGOD v1: local GLB optimizer — synced compare viewer, rig-protected simplify, texture crunch
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
ab8f5ee383
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
public/test-*.glb
|
||||||
44
README.md
Normal file
44
README.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# 🗜️ SHRINKGOD — local GLB optimizer
|
||||||
|
|
||||||
|
Drop in a heavy GLB, pick a target, compare original vs optimized side-by-side (synced camera, animation playback, wireframe), export. **Everything runs client-side in the browser** — gltf-transform + meshoptimizer WASM. No uploads, files never leave the machine.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # http://localhost:5173
|
||||||
|
npm run build # static build in dist/ — host anywhere, still fully local
|
||||||
|
```
|
||||||
|
|
||||||
|
Dev test hook: `?test=<file-in-public>.glb&run=1` auto-loads and optimizes.
|
||||||
|
|
||||||
|
## Pipeline
|
||||||
|
|
||||||
|
dedup → flatten+join (static models only) → weld → per-primitive meshopt simplify → animation resample → prune → texture resize/re-encode → quantize → (optional) meshopt compression.
|
||||||
|
|
||||||
|
### Rig protection (automatic)
|
||||||
|
- Skinned meshes never go below **50% kept geometry** and error is capped at 0.5% — vertex collapse near joints wrecks skin weights in motion.
|
||||||
|
- Morph-target (blendshape) primitives are left untouched.
|
||||||
|
- Mesh joining is disabled for rigged/animated models (would break node animation).
|
||||||
|
- Best results: **optimize before rigging** — static geometry can be crunched far harder.
|
||||||
|
|
||||||
|
### Textures
|
||||||
|
- Longest side clamped to the chosen max (canvas re-encode).
|
||||||
|
- Color/emissive maps optionally converted to WebP (quality slider); normal/roughness/occlusion maps always stay lossless PNG.
|
||||||
|
- Re-encode is skipped if it wouldn't shrink the file.
|
||||||
|
|
||||||
|
### Presets
|
||||||
|
| | geometry kept | error | max texture | webp |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Light | 50% | 0.5% | 2048 | no |
|
||||||
|
| Balanced | 25% | 1% | 1024 | yes |
|
||||||
|
| Crunch | 10% | 5% | 512 | yes |
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
- Draco-compressed input not supported yet (friendly error; re-export without Draco).
|
||||||
|
- Meshopt compression output (`EXT_meshopt_compression`) reads fine in three.js but **not in Blender** — leave it off for assets going back into a DCC.
|
||||||
|
- KTX2/basis textures pass through untouched.
|
||||||
|
|
||||||
|
## Bench (M5 MacBook Pro, Balanced preset)
|
||||||
|
- 15.8 MB photogrammetry room scan (151k tris, 13 MB textures) → **1.5 MB** (−90%) in 5 s.
|
||||||
|
- 3.5 MB rigged character (18.6k tris, 20 clips) → **2.7 MB** (−24%), clips + skinning intact (rig clamp limits geometry reduction by design).
|
||||||
106
index.html
Normal file
106
index.html
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>SHRINKGOD — local GLB optimizer</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🗜️</text></svg>" />
|
||||||
|
<link rel="stylesheet" href="/src/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="brand">🗜️ <b>SHRINKGOD</b><span class="sub">GLB optimizer</span></div>
|
||||||
|
<div class="local-badge" title="Everything runs in this browser tab. No uploads.">🔒 100% local — files never leave this machine</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="layout">
|
||||||
|
<aside id="sidebar">
|
||||||
|
<section id="file-info" class="panel hidden">
|
||||||
|
<div id="file-name" class="mono"></div>
|
||||||
|
<div id="file-meta" class="dim"></div>
|
||||||
|
<div id="rig-badge" class="badge hidden">🦴 rig detected — bones & skin weights protected</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3>Target</h3>
|
||||||
|
<div class="preset-row">
|
||||||
|
<button class="preset" data-preset="light">Light</button>
|
||||||
|
<button class="preset active" data-preset="balanced">Balanced</button>
|
||||||
|
<button class="preset" data-preset="crunch">Crunch</button>
|
||||||
|
</div>
|
||||||
|
<label class="slider-label">Geometry kept <span id="ratio-val" class="mono accent">25%</span>
|
||||||
|
<input id="ratio" type="range" min="2" max="100" value="25" />
|
||||||
|
</label>
|
||||||
|
<label class="slider-label">Error tolerance <span id="error-val" class="mono accent">1.0%</span>
|
||||||
|
<input id="error" type="range" min="1" max="100" value="32" />
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3>Textures</h3>
|
||||||
|
<label class="row">Max size
|
||||||
|
<select id="max-tex">
|
||||||
|
<option value="99999">Original</option>
|
||||||
|
<option value="2048">2048</option>
|
||||||
|
<option value="1024" selected>1024</option>
|
||||||
|
<option value="512">512</option>
|
||||||
|
<option value="256">256</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="row check"><input id="webp" type="checkbox" checked /> Convert color maps to WebP</label>
|
||||||
|
<label class="slider-label">Quality <span id="quality-val" class="mono accent">85</span>
|
||||||
|
<input id="quality" type="range" min="40" max="100" value="85" />
|
||||||
|
</label>
|
||||||
|
<div class="dim tiny">Normal / roughness maps stay lossless PNG.</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3>Structure</h3>
|
||||||
|
<label class="row check"><input id="join" type="checkbox" checked /> Join meshes <span class="dim tiny">(static only)</span></label>
|
||||||
|
<label class="row check"><input id="quantize" type="checkbox" checked /> Quantize vertex data</label>
|
||||||
|
<label class="row check"><input id="meshopt" type="checkbox" /> Meshopt compression <span class="dim tiny">(web loaders only — Blender can't read it)</span></label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<button id="optimize" class="big-btn" disabled>Optimize</button>
|
||||||
|
<button id="export" class="big-btn secondary" disabled>Export GLB</button>
|
||||||
|
<div id="notes" class="panel hidden"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="main">
|
||||||
|
<div id="dropzone">
|
||||||
|
<div class="dz-inner">
|
||||||
|
<div class="dz-icon">⬇︎</div>
|
||||||
|
<div>Drop a <b>.glb</b> here<br /><span class="dim">or</span></div>
|
||||||
|
<button id="browse">Choose file</button>
|
||||||
|
<input id="file-input" type="file" accept=".glb,model/gltf-binary" hidden />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="viewer-ui" class="hidden">
|
||||||
|
<div id="toolbar">
|
||||||
|
<label class="row check"><input id="wireframe" type="checkbox" /> Wireframe</label>
|
||||||
|
<label class="row" id="anim-row" hidden>Clip
|
||||||
|
<select id="anim-select"></select>
|
||||||
|
<label class="row check"><input id="anim-play" type="checkbox" checked /> Play</label>
|
||||||
|
</label>
|
||||||
|
<div id="status" class="dim"></div>
|
||||||
|
</div>
|
||||||
|
<div id="viewers">
|
||||||
|
<div class="pane-wrap">
|
||||||
|
<div class="pane-label">ORIGINAL</div>
|
||||||
|
<div id="pane-left" class="pane"></div>
|
||||||
|
<div id="stats-left" class="stats"></div>
|
||||||
|
</div>
|
||||||
|
<div class="pane-wrap">
|
||||||
|
<div class="pane-label">OPTIMIZED</div>
|
||||||
|
<div id="pane-right" class="pane"><div id="right-empty" class="pane-empty">hit <b>Optimize</b></div></div>
|
||||||
|
<div id="stats-right" class="stats"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1601
package-lock.json
generated
Normal file
1601
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
package.json
Normal file
25
package.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "shrinkgod",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"@gltf-transform/core": "^4.4.2",
|
||||||
|
"@gltf-transform/extensions": "^4.4.2",
|
||||||
|
"@gltf-transform/functions": "^4.4.2",
|
||||||
|
"meshoptimizer": "^1.2.0",
|
||||||
|
"three": "^0.185.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^8.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
239
src/main.js
Normal file
239
src/main.js
Normal file
@ -0,0 +1,239 @@
|
|||||||
|
import { CompareViewer } from './viewer.js';
|
||||||
|
import { analyzeBytes, optimize, PRESETS } from './optimize.js';
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
name: null,
|
||||||
|
origBytes: null,
|
||||||
|
origReport: null,
|
||||||
|
optBytes: null,
|
||||||
|
optReport: null,
|
||||||
|
viewer: null,
|
||||||
|
busy: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- error slider is log-scale: 0.01% .. 10% of mesh radius ----
|
||||||
|
const sliderToError = (s) => Math.pow(10, -4 + 3 * (s / 100));
|
||||||
|
const errorToSlider = (e) => Math.round(((Math.log10(e) + 4) / 3) * 100);
|
||||||
|
|
||||||
|
function fmtBytes(n) {
|
||||||
|
if (n >= 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB';
|
||||||
|
if (n >= 1024) return (n / 1024).toFixed(0) + ' KB';
|
||||||
|
return n + ' B';
|
||||||
|
}
|
||||||
|
const fmtInt = (n) => n.toLocaleString('en-US');
|
||||||
|
|
||||||
|
function updateLabels() {
|
||||||
|
$('ratio-val').textContent = $('ratio').value + '%';
|
||||||
|
const e = sliderToError(+$('error').value);
|
||||||
|
$('error-val').textContent = (e * 100).toPrecision(2) + '%';
|
||||||
|
$('quality-val').textContent = $('quality').value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(name) {
|
||||||
|
const p = PRESETS[name];
|
||||||
|
$('ratio').value = Math.round(p.ratio * 100);
|
||||||
|
$('error').value = errorToSlider(p.error);
|
||||||
|
$('max-tex').value = String(p.maxTex <= 4096 ? p.maxTex : 99999);
|
||||||
|
$('webp').checked = p.webp;
|
||||||
|
document.querySelectorAll('.preset').forEach((b) =>
|
||||||
|
b.classList.toggle('active', b.dataset.preset === name)
|
||||||
|
);
|
||||||
|
updateLabels();
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentOpts() {
|
||||||
|
return {
|
||||||
|
ratio: +$('ratio').value / 100,
|
||||||
|
error: sliderToError(+$('error').value),
|
||||||
|
maxTex: +$('max-tex').value,
|
||||||
|
webp: $('webp').checked,
|
||||||
|
texQuality: +$('quality').value / 100,
|
||||||
|
join: $('join').checked,
|
||||||
|
quantize: $('quantize').checked,
|
||||||
|
meshopt: $('meshopt').checked,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function statsTable(report, base) {
|
||||||
|
const rows = [
|
||||||
|
['file', fmtBytes(report.bytes), base && base.bytes],
|
||||||
|
['triangles', fmtInt(report.tris), base && base.tris],
|
||||||
|
['vertices', fmtInt(report.verts), base && base.verts],
|
||||||
|
['draw calls', fmtInt(report.drawCalls), base && base.drawCalls],
|
||||||
|
['textures', `${report.textures.length} (${fmtBytes(report.texBytes)})`, base && base.texBytes],
|
||||||
|
];
|
||||||
|
const rawVals = [report.bytes, report.tris, report.verts, report.drawCalls, report.texBytes];
|
||||||
|
let html = '<table>';
|
||||||
|
rows.forEach(([label, val, baseVal], i) => {
|
||||||
|
let delta = '';
|
||||||
|
if (baseVal != null && baseVal > 0) {
|
||||||
|
const pct = ((rawVals[i] - baseVal) / baseVal) * 100;
|
||||||
|
const cls = pct <= 0 ? 'delta-good' : 'delta-bad';
|
||||||
|
delta = `<td class="num ${cls}">${pct > 0 ? '+' : ''}${pct.toFixed(0)}%</td>`;
|
||||||
|
}
|
||||||
|
html += `<tr><td class="dim">${label}</td><td class="num">${val}</td>${delta}</tr>`;
|
||||||
|
});
|
||||||
|
html += '</table>';
|
||||||
|
const extras = [];
|
||||||
|
if (report.animations.length) extras.push(`${report.animations.length} clip(s)`);
|
||||||
|
if (report.skins) extras.push(`${report.skins} skin(s)`);
|
||||||
|
if (report.morphPrims) extras.push(`${report.morphPrims} morph prim(s)`);
|
||||||
|
if (extras.length) html += `<div class="dim tiny">${extras.join(' · ')}</div>`;
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text) {
|
||||||
|
$('status').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFile(file) {
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
setStatus('reading…');
|
||||||
|
let report;
|
||||||
|
try {
|
||||||
|
report = await analyzeBytes(bytes);
|
||||||
|
} catch (e) {
|
||||||
|
if (/draco/i.test(String(e))) {
|
||||||
|
alert('This GLB is Draco-compressed — not supported yet. Re-export without Draco (it will still come out smaller here).');
|
||||||
|
} else {
|
||||||
|
alert('Could not read this file as a GLB:\n' + (e.message || e));
|
||||||
|
}
|
||||||
|
setStatus('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.name = file.name;
|
||||||
|
state.origBytes = bytes;
|
||||||
|
state.origReport = report;
|
||||||
|
state.optBytes = null;
|
||||||
|
state.optReport = null;
|
||||||
|
|
||||||
|
$('dropzone').classList.add('hidden');
|
||||||
|
$('viewer-ui').classList.remove('hidden');
|
||||||
|
$('file-info').classList.remove('hidden');
|
||||||
|
$('file-name').textContent = file.name;
|
||||||
|
$('file-meta').textContent = `${fmtBytes(report.bytes)} · ${fmtInt(report.tris)} tris · ${report.meshes} mesh(es) · ${report.materials} material(s)`;
|
||||||
|
|
||||||
|
const rigged = report.skins > 0 || report.animations.length > 0;
|
||||||
|
$('rig-badge').classList.toggle('hidden', !rigged);
|
||||||
|
$('join').disabled = rigged;
|
||||||
|
if (rigged) $('join').checked = false;
|
||||||
|
|
||||||
|
if (!state.viewer) {
|
||||||
|
state.viewer = new CompareViewer($('pane-left'), $('pane-right'), $('viewers'));
|
||||||
|
}
|
||||||
|
state.viewer.clear('right');
|
||||||
|
$('right-empty').style.display = 'flex';
|
||||||
|
await state.viewer.load('left', bytes.slice(0));
|
||||||
|
|
||||||
|
// animation UI
|
||||||
|
const names = state.viewer.clipNames();
|
||||||
|
$('anim-row').hidden = names.length === 0;
|
||||||
|
if (names.length) {
|
||||||
|
$('anim-select').innerHTML = names
|
||||||
|
.map((n, i) => `<option value="${i}">${n}</option>`)
|
||||||
|
.join('');
|
||||||
|
state.viewer.playClip(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('stats-left').innerHTML = statsTable(report, null);
|
||||||
|
$('stats-right').innerHTML = '';
|
||||||
|
$('notes').classList.add('hidden');
|
||||||
|
$('optimize').disabled = false;
|
||||||
|
$('export').disabled = true;
|
||||||
|
setStatus('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOptimize() {
|
||||||
|
if (state.busy || !state.origBytes) return;
|
||||||
|
state.busy = true;
|
||||||
|
$('optimize').disabled = true;
|
||||||
|
$('optimize').textContent = 'Crunching…';
|
||||||
|
try {
|
||||||
|
const t0 = performance.now();
|
||||||
|
const { bytes, report, notes } = await optimize(state.origBytes, currentOpts(), (s) =>
|
||||||
|
setStatus(s + '…')
|
||||||
|
);
|
||||||
|
const secs = ((performance.now() - t0) / 1000).toFixed(1);
|
||||||
|
state.optBytes = bytes;
|
||||||
|
state.optReport = report;
|
||||||
|
|
||||||
|
$('right-empty').style.display = 'none';
|
||||||
|
await state.viewer.load('right', bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
|
||||||
|
const sel = +($('anim-select').value || 0);
|
||||||
|
if (state.viewer.clipNames().length) state.viewer.playClip(sel);
|
||||||
|
|
||||||
|
$('stats-right').innerHTML = statsTable(report, state.origReport);
|
||||||
|
const saved = 1 - report.bytes / state.origReport.bytes;
|
||||||
|
setStatus(`done in ${secs}s — ${(saved * 100).toFixed(0)}% smaller`);
|
||||||
|
|
||||||
|
const notesEl = $('notes');
|
||||||
|
notesEl.classList.toggle('hidden', notes.length === 0);
|
||||||
|
notesEl.innerHTML = notes.map((n) => `<div>· ${n}</div>`).join('');
|
||||||
|
|
||||||
|
$('export').disabled = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Optimize failed:\n' + (e.message || e));
|
||||||
|
setStatus('failed');
|
||||||
|
} finally {
|
||||||
|
state.busy = false;
|
||||||
|
$('optimize').disabled = false;
|
||||||
|
$('optimize').textContent = 'Optimize';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportGlb() {
|
||||||
|
if (!state.optBytes) return;
|
||||||
|
const base = state.name.replace(/\.glb$/i, '');
|
||||||
|
const blob = new Blob([state.optBytes], { type: 'model/gltf-binary' });
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `${base}.opt.glb`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- wiring ----
|
||||||
|
document.querySelectorAll('.preset').forEach((b) =>
|
||||||
|
b.addEventListener('click', () => applyPreset(b.dataset.preset))
|
||||||
|
);
|
||||||
|
['ratio', 'error', 'quality'].forEach((id) => $(id).addEventListener('input', updateLabels));
|
||||||
|
$('optimize').addEventListener('click', runOptimize);
|
||||||
|
$('export').addEventListener('click', exportGlb);
|
||||||
|
$('browse').addEventListener('click', () => $('file-input').click());
|
||||||
|
$('file-input').addEventListener('change', (e) => {
|
||||||
|
if (e.target.files[0]) loadFile(e.target.files[0]);
|
||||||
|
});
|
||||||
|
$('wireframe').addEventListener('change', (e) => state.viewer?.setWireframe(e.target.checked));
|
||||||
|
$('anim-select').addEventListener('change', (e) => state.viewer?.playClip(+e.target.value));
|
||||||
|
$('anim-play').addEventListener('change', (e) => state.viewer?.setPlaying(e.target.checked));
|
||||||
|
|
||||||
|
window.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
$('dropzone').classList.add('drag');
|
||||||
|
});
|
||||||
|
window.addEventListener('dragleave', () => $('dropzone').classList.remove('drag'));
|
||||||
|
window.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
$('dropzone').classList.remove('drag');
|
||||||
|
const file = [...(e.dataTransfer?.files || [])].find((f) => /\.glb$/i.test(f.name));
|
||||||
|
if (file) loadFile(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
applyPreset('balanced');
|
||||||
|
|
||||||
|
// ---- dev test hook: ?test=file.glb&run=1 loads from /public and optimizes ----
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const testFile = params.get('test');
|
||||||
|
if (testFile) {
|
||||||
|
(async () => {
|
||||||
|
const res = await fetch('/' + testFile);
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
await loadFile(new File([buf], testFile));
|
||||||
|
if (params.get('run')) await runOptimize();
|
||||||
|
window.__testDone = true;
|
||||||
|
})();
|
||||||
|
}
|
||||||
274
src/optimize.js
Normal file
274
src/optimize.js
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
import { WebIO } from '@gltf-transform/core';
|
||||||
|
import {
|
||||||
|
ALL_EXTENSIONS,
|
||||||
|
EXTTextureWebP,
|
||||||
|
EXTMeshoptCompression,
|
||||||
|
} from '@gltf-transform/extensions';
|
||||||
|
import {
|
||||||
|
dedup,
|
||||||
|
prune,
|
||||||
|
weld,
|
||||||
|
weldPrimitive,
|
||||||
|
simplifyPrimitive,
|
||||||
|
resample,
|
||||||
|
flatten,
|
||||||
|
join,
|
||||||
|
quantize,
|
||||||
|
listTextureSlots,
|
||||||
|
} from '@gltf-transform/functions';
|
||||||
|
import { MeshoptDecoder, MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer';
|
||||||
|
|
||||||
|
const TRIANGLES = 4;
|
||||||
|
|
||||||
|
let _io = null;
|
||||||
|
async function getIO() {
|
||||||
|
if (!_io) {
|
||||||
|
await Promise.all([MeshoptDecoder.ready, MeshoptEncoder.ready]);
|
||||||
|
_io = new WebIO()
|
||||||
|
.registerExtensions(ALL_EXTENSIONS)
|
||||||
|
.registerDependencies({
|
||||||
|
'meshopt.decoder': MeshoptDecoder,
|
||||||
|
'meshopt.encoder': MeshoptEncoder,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return _io;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRESETS = {
|
||||||
|
light: { ratio: 0.5, error: 0.005, maxTex: 2048, webp: false },
|
||||||
|
balanced: { ratio: 0.25, error: 0.01, maxTex: 1024, webp: true },
|
||||||
|
crunch: { ratio: 0.1, error: 0.05, maxTex: 512, webp: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Skinned meshes never go below this keep-ratio, and error is capped —
|
||||||
|
// collapsing verts near joints wrecks skin weights and silhouettes in motion.
|
||||||
|
const SKINNED_MIN_RATIO = 0.5;
|
||||||
|
const SKINNED_MAX_ERROR = 0.005;
|
||||||
|
|
||||||
|
function skinnedMeshSet(root) {
|
||||||
|
const set = new Set();
|
||||||
|
for (const node of root.listNodes()) {
|
||||||
|
if (node.getSkin() && node.getMesh()) set.add(node.getMesh());
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reportDoc(doc, byteLength) {
|
||||||
|
const root = doc.getRoot();
|
||||||
|
let tris = 0;
|
||||||
|
let verts = 0;
|
||||||
|
let prims = 0;
|
||||||
|
let morphPrims = 0;
|
||||||
|
for (const mesh of root.listMeshes()) {
|
||||||
|
for (const prim of mesh.listPrimitives()) {
|
||||||
|
prims++;
|
||||||
|
if (prim.listTargets().length > 0) morphPrims++;
|
||||||
|
const pos = prim.getAttribute('POSITION');
|
||||||
|
if (!pos) continue;
|
||||||
|
verts += pos.getCount();
|
||||||
|
const idx = prim.getIndices();
|
||||||
|
const count = idx ? idx.getCount() : pos.getCount();
|
||||||
|
if (prim.getMode() === TRIANGLES) tris += Math.floor(count / 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let drawCalls = 0;
|
||||||
|
for (const node of root.listNodes()) {
|
||||||
|
const mesh = node.getMesh();
|
||||||
|
if (mesh) drawCalls += mesh.listPrimitives().length;
|
||||||
|
}
|
||||||
|
const textures = root.listTextures().map((tex) => {
|
||||||
|
let size = null;
|
||||||
|
try { size = tex.getSize(); } catch { /* unknown mime */ }
|
||||||
|
return {
|
||||||
|
name: tex.getName() || '',
|
||||||
|
mime: tex.getMimeType(),
|
||||||
|
bytes: tex.getImage() ? tex.getImage().byteLength : 0,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
bytes: byteLength,
|
||||||
|
tris,
|
||||||
|
verts,
|
||||||
|
prims,
|
||||||
|
morphPrims,
|
||||||
|
drawCalls,
|
||||||
|
meshes: root.listMeshes().length,
|
||||||
|
materials: root.listMaterials().length,
|
||||||
|
textures,
|
||||||
|
texBytes: textures.reduce((s, t) => s + t.bytes, 0),
|
||||||
|
animations: root.listAnimations().map((a) => a.getName() || 'clip'),
|
||||||
|
skins: root.listSkins().length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function analyzeBytes(bytes) {
|
||||||
|
const io = await getIO();
|
||||||
|
const doc = await io.readBinary(new Uint8Array(bytes));
|
||||||
|
return reportDoc(doc, bytes.byteLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isColorSlot(slot) {
|
||||||
|
return /base|diffuse|emissive|sheenColor|specularColor/i.test(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processTextures(doc, opts, notes) {
|
||||||
|
const textures = doc.getRoot().listTextures();
|
||||||
|
let touched = 0;
|
||||||
|
for (const tex of textures) {
|
||||||
|
const image = tex.getImage();
|
||||||
|
if (!image) continue;
|
||||||
|
const mime = tex.getMimeType();
|
||||||
|
if (!/^image\/(png|jpeg|webp)$/.test(mime)) continue; // ktx2 etc: leave alone
|
||||||
|
|
||||||
|
let bitmap;
|
||||||
|
try {
|
||||||
|
bitmap = await createImageBitmap(new Blob([image], { type: mime }), {
|
||||||
|
colorSpaceConversion: 'none',
|
||||||
|
premultiplyAlpha: 'none',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
notes.push(`could not decode texture "${tex.getName() || mime}" — left as-is`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const longest = Math.max(bitmap.width, bitmap.height);
|
||||||
|
const scale = Math.min(1, opts.maxTex / longest);
|
||||||
|
const w = Math.max(1, Math.round(bitmap.width * scale));
|
||||||
|
const h = Math.max(1, Math.round(bitmap.height * scale));
|
||||||
|
const needsResize = scale < 1;
|
||||||
|
|
||||||
|
// Data textures (normal/ORM/etc) stay lossless; color can go webp/jpeg.
|
||||||
|
const slots = listTextureSlots(tex);
|
||||||
|
const color = slots.length === 0 || slots.some(isColorSlot);
|
||||||
|
let targetMime;
|
||||||
|
if (!color) targetMime = 'image/png';
|
||||||
|
else if (opts.webp) targetMime = 'image/webp';
|
||||||
|
else targetMime = mime === 'image/webp' ? 'image/webp' : mime; // never png->jpeg (alpha)
|
||||||
|
|
||||||
|
if (!needsResize && targetMime === mime && mime === 'image/png') {
|
||||||
|
bitmap.close();
|
||||||
|
continue; // nothing to gain re-encoding png->png at same size
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = new OffscreenCanvas(w, h);
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.drawImage(bitmap, 0, 0, w, h);
|
||||||
|
bitmap.close();
|
||||||
|
const lossy = targetMime !== 'image/png';
|
||||||
|
const blob = await canvas.convertToBlob({
|
||||||
|
type: targetMime,
|
||||||
|
quality: lossy ? opts.texQuality : undefined,
|
||||||
|
});
|
||||||
|
const out = new Uint8Array(await blob.arrayBuffer());
|
||||||
|
|
||||||
|
// Keep the original if we somehow made it bigger without shrinking dims.
|
||||||
|
if (!needsResize && out.byteLength >= image.byteLength) continue;
|
||||||
|
tex.setImage(out).setMimeType(blob.type);
|
||||||
|
touched++;
|
||||||
|
}
|
||||||
|
if (doc.getRoot().listTextures().some((t) => t.getMimeType() === 'image/webp')) {
|
||||||
|
doc.createExtension(EXTTextureWebP).setRequired(true);
|
||||||
|
}
|
||||||
|
return touched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bytes: ArrayBuffer of a .glb
|
||||||
|
* opts: { ratio, error, maxTex, webp, texQuality, join, quantize, meshopt }
|
||||||
|
*/
|
||||||
|
export async function optimize(bytes, opts, onStatus = () => {}) {
|
||||||
|
const io = await getIO();
|
||||||
|
await MeshoptSimplifier.ready;
|
||||||
|
const notes = [];
|
||||||
|
|
||||||
|
onStatus('parsing');
|
||||||
|
const doc = await io.readBinary(new Uint8Array(bytes.slice(0)));
|
||||||
|
const root = doc.getRoot();
|
||||||
|
const rigged = root.listSkins().length > 0 || root.listAnimations().length > 0;
|
||||||
|
|
||||||
|
onStatus('deduplicating');
|
||||||
|
await doc.transform(dedup());
|
||||||
|
|
||||||
|
if (opts.join && !rigged) {
|
||||||
|
onStatus('joining meshes');
|
||||||
|
try {
|
||||||
|
await doc.transform(flatten(), join());
|
||||||
|
} catch (e) {
|
||||||
|
notes.push(`join skipped: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus('welding');
|
||||||
|
await doc.transform(weld());
|
||||||
|
|
||||||
|
onStatus('simplifying geometry');
|
||||||
|
const skinned = skinnedMeshSet(root);
|
||||||
|
let skippedMorph = 0;
|
||||||
|
let clampedSkinned = 0;
|
||||||
|
for (const mesh of root.listMeshes()) {
|
||||||
|
const isSkinned = skinned.has(mesh);
|
||||||
|
for (const prim of mesh.listPrimitives()) {
|
||||||
|
if (prim.getMode() !== TRIANGLES) continue;
|
||||||
|
if (prim.listTargets().length > 0) {
|
||||||
|
skippedMorph++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let { ratio, error } = opts;
|
||||||
|
let lockBorder = false;
|
||||||
|
if (isSkinned) {
|
||||||
|
if (ratio < SKINNED_MIN_RATIO) {
|
||||||
|
ratio = SKINNED_MIN_RATIO;
|
||||||
|
clampedSkinned++;
|
||||||
|
}
|
||||||
|
error = Math.min(error, SKINNED_MAX_ERROR);
|
||||||
|
lockBorder = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
weldPrimitive(prim);
|
||||||
|
simplifyPrimitive(prim, { simplifier: MeshoptSimplifier, ratio, error, lockBorder });
|
||||||
|
} catch (e) {
|
||||||
|
notes.push(`simplify skipped on "${mesh.getName() || 'mesh'}": ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (root.listAnimations().length > 0) {
|
||||||
|
onStatus('resampling animations');
|
||||||
|
try {
|
||||||
|
await doc.transform(resample());
|
||||||
|
} catch (e) {
|
||||||
|
notes.push(`animation resample skipped: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatus('pruning');
|
||||||
|
await doc.transform(prune());
|
||||||
|
|
||||||
|
onStatus('processing textures');
|
||||||
|
await processTextures(doc, opts, notes);
|
||||||
|
|
||||||
|
if (opts.quantize || opts.meshopt) {
|
||||||
|
onStatus('quantizing');
|
||||||
|
try {
|
||||||
|
await doc.transform(quantize());
|
||||||
|
} catch (e) {
|
||||||
|
notes.push(`quantize skipped: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.meshopt) {
|
||||||
|
doc
|
||||||
|
.createExtension(EXTMeshoptCompression)
|
||||||
|
.setRequired(true)
|
||||||
|
.setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.FILTER });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skippedMorph) notes.push(`${skippedMorph} morph-target primitive(s) left untouched (blendshape protection)`);
|
||||||
|
if (clampedSkinned) notes.push(`${clampedSkinned} skinned primitive(s) clamped to keep ≥${SKINNED_MIN_RATIO * 100}% (rig protection)`);
|
||||||
|
|
||||||
|
onStatus('writing glb');
|
||||||
|
const out = await io.writeBinary(doc);
|
||||||
|
const report = reportDoc(doc, out.byteLength);
|
||||||
|
return { bytes: out, report, notes, rigged };
|
||||||
|
}
|
||||||
221
src/style.css
Normal file
221
src/style.css
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
* { box-sizing: border-box; margin: 0; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #0c0e11;
|
||||||
|
--panel: #14171c;
|
||||||
|
--panel-2: #1a1e24;
|
||||||
|
--border: #262b33;
|
||||||
|
--text: #d7dce3;
|
||||||
|
--dim: #8a93a0;
|
||||||
|
--accent: #7ee787;
|
||||||
|
--accent-dim: #2e5138;
|
||||||
|
--bad: #ff7b72;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px/1.45 -apple-system, system-ui, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||||
|
.dim { color: var(--dim); }
|
||||||
|
.tiny { font-size: 11px; }
|
||||||
|
.accent { color: var(--accent); }
|
||||||
|
.hidden, [hidden] { display: none !important; }
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.brand { font-size: 16px; letter-spacing: 0.5px; }
|
||||||
|
.brand .sub { color: var(--dim); font-size: 12px; margin-left: 10px; }
|
||||||
|
.local-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-dim);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#layout { display: flex; flex: 1 1 auto; min-height: 0; }
|
||||||
|
|
||||||
|
#sidebar {
|
||||||
|
width: 270px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
.panel h3 {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
color: var(--dim);
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#file-name { font-size: 13px; word-break: break-all; }
|
||||||
|
#file-meta { font-size: 12px; margin-top: 4px; }
|
||||||
|
.badge {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #2a2416;
|
||||||
|
color: #e3b341;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preset-row { display: flex; gap: 6px; margin-bottom: 12px; }
|
||||||
|
.preset {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.preset.active { border-color: var(--accent); color: var(--accent); }
|
||||||
|
|
||||||
|
.slider-label { display: block; font-size: 12px; color: var(--dim); margin-top: 8px; }
|
||||||
|
.slider-label span { float: right; }
|
||||||
|
input[type="range"] { width: 100%; accent-color: var(--accent); margin-top: 4px; }
|
||||||
|
|
||||||
|
.row { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-top: 6px; }
|
||||||
|
.row.check { cursor: pointer; }
|
||||||
|
.row select {
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
input[type="checkbox"] { accent-color: var(--accent); }
|
||||||
|
|
||||||
|
.big-btn {
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #08110a;
|
||||||
|
}
|
||||||
|
.big-btn.secondary { background: var(--panel-2); color: var(--text); border: 1px solid var(--border); }
|
||||||
|
.big-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
|
|
||||||
|
#notes { font-size: 12px; color: var(--dim); }
|
||||||
|
#notes div { margin-top: 4px; }
|
||||||
|
|
||||||
|
#main { flex: 1 1 auto; position: relative; display: flex; min-width: 0; }
|
||||||
|
|
||||||
|
#dropzone {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
#dropzone.drag { background: rgba(126, 231, 135, 0.06); }
|
||||||
|
.dz-inner {
|
||||||
|
text-align: center;
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 60px 80px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.dz-icon { font-size: 42px; margin-bottom: 12px; }
|
||||||
|
#browse {
|
||||||
|
margin-top: 14px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#viewer-ui { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
#toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
#toolbar .row { margin-top: 0; }
|
||||||
|
#status { margin-left: auto; font-size: 12px; }
|
||||||
|
|
||||||
|
#viewers { flex: 1; display: flex; min-height: 0; }
|
||||||
|
.pane-wrap {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.pane-wrap + .pane-wrap { border-left: 1px solid var(--border); }
|
||||||
|
.pane-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 2;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--dim);
|
||||||
|
background: rgba(12, 14, 17, 0.7);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.pane { flex: 1; min-height: 0; position: relative; }
|
||||||
|
.pane canvas { display: block; width: 100%; height: 100%; }
|
||||||
|
.pane-empty {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--dim);
|
||||||
|
gap: 4px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
min-height: 76px;
|
||||||
|
}
|
||||||
|
.stats table { width: 100%; border-collapse: collapse; }
|
||||||
|
.stats td { padding: 1px 6px 1px 0; }
|
||||||
|
.stats td.num { text-align: right; }
|
||||||
|
.delta-good { color: var(--accent); }
|
||||||
|
.delta-bad { color: var(--bad); }
|
||||||
189
src/viewer.js
Normal file
189
src/viewer.js
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
import * as THREE from 'three';
|
||||||
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
|
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
|
||||||
|
import { MeshoptDecoder } from 'meshoptimizer';
|
||||||
|
|
||||||
|
const BG = new THREE.Color(0x0c0e11);
|
||||||
|
|
||||||
|
function disposeObject(obj) {
|
||||||
|
obj.traverse((child) => {
|
||||||
|
if (child.geometry) child.geometry.dispose();
|
||||||
|
const mats = Array.isArray(child.material) ? child.material : child.material ? [child.material] : [];
|
||||||
|
for (const m of mats) {
|
||||||
|
for (const key of Object.keys(m)) {
|
||||||
|
if (m[key] && m[key].isTexture) m[key].dispose();
|
||||||
|
}
|
||||||
|
m.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class Pane {
|
||||||
|
constructor(el) {
|
||||||
|
this.el = el;
|
||||||
|
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||||
|
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||||
|
el.appendChild(this.renderer.domElement);
|
||||||
|
|
||||||
|
this.scene = new THREE.Scene();
|
||||||
|
this.scene.background = BG;
|
||||||
|
const pmrem = new THREE.PMREMGenerator(this.renderer);
|
||||||
|
this.scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
|
||||||
|
pmrem.dispose();
|
||||||
|
|
||||||
|
this.grid = new THREE.GridHelper(4, 20, 0x2c3138, 0x1b1f24);
|
||||||
|
this.scene.add(this.grid);
|
||||||
|
|
||||||
|
this.model = null;
|
||||||
|
this.mixer = null;
|
||||||
|
this.clips = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
setModel(gltf) {
|
||||||
|
if (this.model) {
|
||||||
|
this.scene.remove(this.model);
|
||||||
|
disposeObject(this.model);
|
||||||
|
}
|
||||||
|
this.model = gltf.scene;
|
||||||
|
this.clips = gltf.animations || [];
|
||||||
|
this.mixer = this.clips.length ? new THREE.AnimationMixer(this.model) : null;
|
||||||
|
this.scene.add(this.model);
|
||||||
|
}
|
||||||
|
|
||||||
|
resize() {
|
||||||
|
const w = this.el.clientWidth;
|
||||||
|
const h = this.el.clientHeight;
|
||||||
|
if (w && h) this.renderer.setSize(w, h, false);
|
||||||
|
return w / Math.max(1, h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CompareViewer {
|
||||||
|
constructor(leftEl, rightEl, dragEl) {
|
||||||
|
this.left = new Pane(leftEl);
|
||||||
|
this.right = new Pane(rightEl);
|
||||||
|
this.panes = [this.left, this.right];
|
||||||
|
|
||||||
|
this.camera = new THREE.PerspectiveCamera(45, 1, 0.01, 1000);
|
||||||
|
this.camera.position.set(2, 1.5, 3);
|
||||||
|
|
||||||
|
// One controls instance on the shared container: both panes stay in sync.
|
||||||
|
this.controls = new OrbitControls(this.camera, dragEl);
|
||||||
|
this.controls.enableDamping = true;
|
||||||
|
this.controls.dampingFactor = 0.12;
|
||||||
|
|
||||||
|
this.loader = new GLTFLoader();
|
||||||
|
this.loader.setMeshoptDecoder(MeshoptDecoder);
|
||||||
|
|
||||||
|
this.clock = new THREE.Clock();
|
||||||
|
this.playing = true;
|
||||||
|
this.wireframe = false;
|
||||||
|
this.framed = false;
|
||||||
|
|
||||||
|
const onResize = () => {
|
||||||
|
const aspect = this.left.resize();
|
||||||
|
this.right.resize();
|
||||||
|
this.camera.aspect = aspect;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
};
|
||||||
|
new ResizeObserver(onResize).observe(leftEl);
|
||||||
|
onResize();
|
||||||
|
|
||||||
|
this.renderer = this.renderLoop.bind(this);
|
||||||
|
requestAnimationFrame(this.renderer);
|
||||||
|
}
|
||||||
|
|
||||||
|
parse(arrayBuffer) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.loader.parse(arrayBuffer, '', resolve, reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(side, arrayBuffer) {
|
||||||
|
const gltf = await this.parse(arrayBuffer);
|
||||||
|
const pane = side === 'left' ? this.left : this.right;
|
||||||
|
pane.setModel(gltf);
|
||||||
|
this.applyWireframe(pane);
|
||||||
|
if (side === 'left') {
|
||||||
|
this.frame(gltf.scene);
|
||||||
|
}
|
||||||
|
return gltf;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(side) {
|
||||||
|
const pane = side === 'left' ? this.left : this.right;
|
||||||
|
if (pane.model) {
|
||||||
|
pane.scene.remove(pane.model);
|
||||||
|
disposeObject(pane.model);
|
||||||
|
pane.model = null;
|
||||||
|
pane.mixer = null;
|
||||||
|
pane.clips = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
frame(object) {
|
||||||
|
const box = new THREE.Box3().setFromObject(object);
|
||||||
|
if (box.isEmpty()) return;
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z);
|
||||||
|
const dist = maxDim * 1.6;
|
||||||
|
this.camera.position.set(center.x + dist * 0.7, center.y + dist * 0.45, center.z + dist * 0.85);
|
||||||
|
this.camera.near = maxDim / 100;
|
||||||
|
this.camera.far = maxDim * 100;
|
||||||
|
this.camera.updateProjectionMatrix();
|
||||||
|
this.controls.target.copy(center);
|
||||||
|
this.controls.update();
|
||||||
|
for (const pane of this.panes) {
|
||||||
|
pane.grid.position.y = box.min.y;
|
||||||
|
pane.grid.scale.setScalar(Math.max(1, maxDim / 2));
|
||||||
|
}
|
||||||
|
this.framed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
clipNames() {
|
||||||
|
return this.left.clips.map((c) => c.name || 'clip');
|
||||||
|
}
|
||||||
|
|
||||||
|
playClip(index) {
|
||||||
|
for (const pane of this.panes) {
|
||||||
|
if (!pane.mixer) continue;
|
||||||
|
pane.mixer.stopAllAction();
|
||||||
|
if (index < 0) continue;
|
||||||
|
// Match by name first (optimized file may reorder), fall back to index.
|
||||||
|
const want = this.left.clips[index];
|
||||||
|
const clip =
|
||||||
|
(want && pane.clips.find((c) => c.name === want.name)) || pane.clips[index];
|
||||||
|
if (clip) pane.mixer.clipAction(clip).reset().play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlaying(v) {
|
||||||
|
this.playing = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyWireframe(pane) {
|
||||||
|
if (!pane.model) return;
|
||||||
|
pane.model.traverse((child) => {
|
||||||
|
const mats = Array.isArray(child.material) ? child.material : child.material ? [child.material] : [];
|
||||||
|
for (const m of mats) if ('wireframe' in m) m.wireframe = this.wireframe;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setWireframe(v) {
|
||||||
|
this.wireframe = v;
|
||||||
|
this.panes.forEach((p) => this.applyWireframe(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
renderLoop() {
|
||||||
|
const delta = this.clock.getDelta();
|
||||||
|
this.controls.update();
|
||||||
|
for (const pane of this.panes) {
|
||||||
|
if (pane.mixer && this.playing) pane.mixer.update(delta);
|
||||||
|
pane.renderer.render(pane.scene, this.camera);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(this.renderer);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
vite.config.js
Normal file
5
vite.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
server: { port: 5173, strictPort: true },
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user