glb-pack
v0.3.0
Published
Crop unused texture space and remap UVs in a GLB (Node + Browser)
Maintainers
Readme
glb-pack
Crop unused texture space out of a GLB and remap its UVs to the new 0–1 range. Useful when an external 3D tool exports models whose textures have a lot of empty space, wasting GPU memory and download size.
What it does
input : models/<name>.glb (texture has lots of empty space)
output : outputs/<name>.glb (UVs remapped, all textures cropped + embedded)
outputs/<name>.png (cropped baseColor texture, separate file)
outputs/<name>.aseprite (cropped baseColor as a minimal single-layer .aseprite)
outputs/<name>.zip (the .glb + .png + .aseprite, flat zipped)The tool computes the smallest axis-aligned UV bounding box across every primitive, crops every texture (baseColor, normal, ORM, emissive…) to that pixel rectangle, and rewrites the UVs into the new [0, 1] space.
Install
From npm (recommended):
npm install -g glb-packFor browser/library use (no global install needed):
npm install glb-pack
# then: import { runPack } from "glb-pack/web"Or from source:
git clone https://github.com/mjshin82/glb-pack.git
cd glb-pack
npm install
npm install -g .Requires Node ≥ 20.
Usage
# Read models/JerseyBarrierB.glb, write outputs/JerseyBarrierB.{glb,png,aseprite,zip}
glb-pack JerseyBarrierB
# Read any path directly
glb-pack ./somewhere/else/foo.glb
# Skip the .zip
glb-pack JerseyBarrierB --no-zipExample output:
✓ Loaded models/JerseyBarrierB.glb
✓ UV bbox: [0.00, 0.00] – [0.66, 0.47]
✓ baseColor cropped to 84×60
✓ Wrote outputs/JerseyBarrierB.glb
✓ Wrote outputs/JerseyBarrierB.png
✓ Wrote outputs/JerseyBarrierB.aseprite
✓ Wrote outputs/JerseyBarrierB.zipBrowser Usage
glb-pack also runs entirely in the browser — no server, no Node.js. The same
crop+remap algorithm runs via Canvas2D (image work) and fflate (zip).
import { runPack, ValidationError } from "glb-pack/web";
// drag-dropped or <input type="file"> File
const file: File = /* ... */;
const glbBytes = new Uint8Array(await file.arrayBuffer());
try {
const result = await runPack(glbBytes, {
filename: "model", // optional; used as the stem inside the zip
zip: true, // optional; default true
});
// result.glbBytes — Uint8Array, the new GLB
// result.baseColorPng — Uint8Array, the cropped baseColor PNG
// result.asepriteBytes — Uint8Array, the cropped baseColor as .aseprite
// result.zipBytes — Uint8Array | null
// result.bbox — { uMin, vMin, uMax, vMax }
// result.baseColorSize — { width, height }
} catch (err) {
if (err instanceof ValidationError) {
// user-facing message (e.g., "Multiple materials...")
} else {
throw err;
}
}Browser support: Chrome 91+, Firefox 90+, Safari 15+, Edge 91+.
The library never triggers a download — your app does that:
const blob = new Blob([result.zipBytes!], { type: "application/zip" });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), { href: url, download: "packed.zip" });
a.click();
URL.revokeObjectURL(url);V1 supported / not supported
This is a V1 release with a deliberately narrow scope.
Supported:
- A single material across the whole model (multiple meshes / primitives are fine if they all share that one material)
- Every PBR texture slot on that material (baseColor / normal / ORM / emissive — all cropped together with the same UV bbox)
- UVs in
[0, 1] - Textures embedded in the GLB or referenced as external files next to it (resolved automatically)
Not supported (the tool aborts with a clear error):
- Multiple distinct materials
- Wrap / repeat UVs (any UV outside
[0, 1]) - Non-finite UV values (NaN, ±∞)
- A second UV channel (
TEXCOORD_1)
How it works
- Load the GLB.
- Validate the model meets the V1 constraints above.
- Compute the UV bounding box over every primitive's
TEXCOORD_0. - Crop every texture in the document by
bbox × textureSize(per texture, since resolutions can differ). Outward rounding (floorfor min,ceilfor max) preserves full UV coverage. - Remap each unique UV accessor with
(u − uMin) / (uMax − uMin). - Write the new GLB (textures embedded), a separate baseColor PNG, a minimal RGBA single-layer .aseprite of the same baseColor, and a flat zip.
No padding is added at the bbox boundary — keep this in mind if your engine relies on aggressive mip filtering.
Exit codes
| Code | Meaning | |---|---| | 0 | Success | | 1 | Validation failed (model violates V1 constraints — message describes what) | | 2 | I/O error (input not found, write failed, etc.) |
Development
npm run dev <name> # Run via tsx (no build step)
npm run build # Compile TypeScript to dist/
npm test # Run unit + integration tests (vitest)Project structure:
src/
├─ cli.ts # argv → pipeline → exit code
├─ ports.ts # ImageOps (probe / cropToPng / decodeRgba), ZipOps
├─ core/
│ ├─ aseprite-writer.ts # pure: (w, h, layers) → minimal .aseprite bytes
│ ├─ bbox-to-rect.ts
│ ├─ errors.ts
│ ├─ pipeline-core.ts
│ ├─ remap-uv.ts
│ ├─ uv-bbox.ts
│ └─ validate.ts
├─ node/ # sharp-based image ops, archiver-based zip, NodeIO glTF
└─ web/ # Canvas2D image ops, fflate-based zip, WebIO glTF
tests/
├─ unit/ # uv-bbox, remap-uv, validate
└─ integration/ # full pipeline against a real GLB fixtureThe design and implementation plan live under docs/superpowers/.
