240 lines
8.1 KiB
JavaScript
240 lines
8.1 KiB
JavaScript
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;
|
|
})();
|
|
}
|