---
name: gltf_asset_optimization
description: "Optimize glTF and GLB models for web delivery: set size budgets, inspect and validate, compress textures with KTX2/Basis or WebP/AVIF, simplify geometry, apply Draco or meshopt compression, and build LOD variants. Triggers on shrinking a GLB, a model loading slowly on a web page, texture or triangle budgets, or preparing 3D assets for a browser viewer."
metadata: { "includeInPrompt": true }
---

# glTF Asset Optimization

## Purpose

Take a glTF or GLB file that is too big or too heavy for a web page and produce a smaller file that still looks right in the target viewer. The work is asset preparation: budgets, inspection, texture compression, geometry simplification, geometry compression, level of detail variants, and validation. Writing the viewer code that loads the result is a different job.

Cousin skills, one line each:

- threejs-scene-basics covers scene code in the browser (renderer, camera, lights, loading a model), not asset preparation before delivery.
- blender-material-shaders covers authoring materials inside Blender, not compressing a finished export for delivery.
- headless-blender covers running Blender as a render pipeline, not optimizing glTF files for the web.
- 3d-printing-basics covers preparing models for physical printing, not for web delivery.

## Workflow

1. Set the budget before touching the file. Write down limits for total download size, triangle count, texture count, and max texture dimension, based on the weakest target device. Starting examples only, adjust to the project: a single hero product model at 5 MB total, 150k triangles, textures at 2048 px or below; a background prop at 1 MB, 25k triangles, textures at 1024 px or below; a full scene at 15 MB for the first load. These numbers are a place to start a conversation, not a standard.
2. Inspect the source file. Run `gltf-transform inspect input.glb` and read the report: it shows whether the weight sits in geometry, textures, or draw calls. Run the budget checker in step 3 for file size, triangle count, and which extensions the file already uses or requires.
3. Run the budget checker. Save the script below as `budget_check.py`, then run `python3 budget_check.py input.glb --max-mb 5 --max-tris 150000`. It reads only the GLB JSON chunk with the Python standard library, so it gives counts before any tool modifies the file.

   ```python
   #!/usr/bin/env python3
   """Report GLB size and counts, and flag budget breaches.

   Usage: python3 budget_check.py MODEL.glb [--max-mb 5] [--max-tris 100000]
          [--max-textures 16]
   Reads only the GLB JSON chunk. Exit code 1 when any stated budget is breached.
   """
   import argparse, json, struct, sys

   def parse_glb(path):
       data = open(path, "rb").read()
       if len(data) < 20 or data[0:4] != b"glTF":
           sys.exit("not a GLB file (missing glTF magic)")
       version, total = struct.unpack_from("<II", data, 4)
       if version != 2:
           sys.exit(f"unsupported GLB version: {version}")
       clen, ctype = struct.unpack_from("<I4s", data, 12)
       if ctype != b"JSON":
           sys.exit("first chunk is not JSON")
       return data, json.loads(data[20:20 + clen])

   def main():
       ap = argparse.ArgumentParser()
       ap.add_argument("path")
       ap.add_argument("--max-mb", type=float, default=None)
       ap.add_argument("--max-tris", type=int, default=None)
       ap.add_argument("--max-textures", type=int, default=None)
       a = ap.parse_args()
       data, doc = parse_glb(a.path)
       accessors = doc.get("accessors", [])
       tris = 0
       for mesh in doc.get("meshes", []):
           for prim in mesh.get("primitives", []):
               if prim.get("mode", 4) != 4:
                   continue
               if "indices" in prim:
                   tris += accessors[prim["indices"]].get("count", 0) // 3
               elif "POSITION" in prim.get("attributes", {}):
                   tris += accessors[prim["attributes"]["POSITION"]].get("count", 0) // 3
       mb = len(data) / 1_000_000
       print(f"file: {a.path}  size: {mb:.2f} MB ({len(data)} bytes)")
       print(f"nodes: {len(doc.get('nodes', []))}  meshes: {len(doc.get('meshes', []))}  "
             f"materials: {len(doc.get('materials', []))}  textures: {len(doc.get('textures', []))}  "
             f"images: {len(doc.get('images', []))}  animations: {len(doc.get('animations', []))}")
       print(f"triangles (indexed/non-indexed triangle primitives only): {tris}")
       print(f"extensionsUsed: {doc.get('extensionsUsed', [])}")
       print(f"extensionsRequired: {doc.get('extensionsRequired', [])}")
       bad = []
       if a.max_mb is not None and mb > a.max_mb:
           bad.append(f"size {mb:.2f} MB over budget {a.max_mb} MB")
       if a.max_tris is not None and tris > a.max_tris:
           bad.append(f"triangles {tris} over budget {a.max_tris}")
       if a.max_textures is not None and len(doc.get("textures", [])) > a.max_textures:
           bad.append(f"textures {len(doc.get('textures', []))} over budget {a.max_textures}")
       for b in bad:
           print("BREACH:", b)
       sys.exit(1 if bad else 0)

   if __name__ == "__main__":
       main()
   ```

4. Validate the source before optimizing, so pre-existing errors do not get blamed on the optimizer. Two verified routes: `gltf-transform validate input.glb`, or the Khronos validator binary `gltf_validator input.glb`, which writes a report file named `<asset_filename>.report.json` next to the asset and returns a non-zero exit code when it finds at least one error. Fix or record source errors first.
5. Clean up lossless waste first. With glTF Transform, the individual commands are `dedup` (deduplicate accessors and textures), `prune` (remove unreferenced properties), `weld` (merge equivalent vertices), `resample` (losslessly deduplicate animation keyframes), and `sparse` (store mostly-zero arrays as sparse accessors). Run them one at a time, in the shape `gltf-transform dedup input.glb step.glb`, and re-inspect between passes while learning a new asset type.
6. Fix textures, which are the largest cost in most web models. PNG and JPEG textures decode to full uncompressed bitmaps in GPU memory. KTX2 with Basis compression transcodes to a GPU-native compressed format and stays compressed in GPU memory, so it cuts both download size and GPU memory. Commands in glTF Transform: `etc1s` (KTX2 with Basis ETC1S, smaller files, lower quality) and `uastc` (KTX2 with Basis UASTC, higher quality, larger files). Rule of thumb: ETC1S for secondary color maps and data maps that tolerate loss, UASTC for hero color textures and normal maps. Cap dimensions first with `resize`. WebP (extension EXT_texture_webp) and AVIF shrink downloads but still decode to full bitmaps on the GPU, and loader support varies more than KTX2 support, so check the target viewer before choosing them over KTX2. For per-file work outside a GLB, KTX-Software provides the `toktx` tool that creates KTX2 files; check `toktx --help` on the installed version for exact flags before scripting it.
7. Reduce geometry where the inspect report says geometry dominates. `simplify` reduces vertex count using meshoptimizer under the hood; `quantize` reduces precision and memory. Check `gltf-transform help simplify` on the installed version for the exact ratio and error option names, then simplify in small steps and view the result each time. Silhouettes, thin parts, and skinned joints break first, so protect them with lower ratios or exclude those meshes.
8. Apply geometry compression last, after simplification and quantization have settled. Pick one scheme per file. Draco (extension KHR_draco_mesh_compression, command `gltf-transform draco`) gives strong compression and wide viewer support. Meshopt (extension EXT_meshopt_compression, command `gltf-transform meshopt`) usually decodes faster and also compresses animation. Either way, the file becomes dependent on a decoder at load time: the target viewer must be configured with the matching decoder or the model will not load. Confirm decoder support in the actual viewer before shipping, and keep an uncompressed fallback when the viewer is unknown.
9. Consider gltfpack as an alternative single-tool pass. gltfpack is the meshoptimizer project tool that quantizes, reorders, merges, and optionally simplifies and compresses in one run. Verified flag shapes from its manual: `gltfpack -i input.glb -o output.glb -cc` for meshopt compression (single `-c` is the lighter setting), add `-tc` to convert textures to KTX2 with Basis, add `-si 0.5` to simplify toward half the original triangle count, and add `-kn` when outside code needs to find named nodes after packing. gltfpack changes data aggressively by default, so diff its output visually against the source before adopting it in a pipeline.
10. Build LOD variants when one file must serve both close and distant views. Produce separate files from the cleaned source at descending simplify ratios, for example a full detail file, a half triangle file, and a quarter triangle file, named with a clear suffix such as `_lod0`, `_lod1`, `_lod2`. Switching between them by distance happens in application code. An in-file LOD extension exists in some engines, but support is uneven; confirm the target viewer supports it before relying on it, otherwise ship separate files.
11. For a fast first pass on a well-behaved file, the one-shot command from the glTF Transform docs is `gltf-transform optimize input.glb output.glb --compress draco --texture-compress webp`. Swap the values to match steps 6 and 8 (for example `--compress meshopt` and `--texture-compress ktx2` are the expected shapes; confirm the accepted values with `gltf-transform optimize -h` on the installed version). Defaults in `optimize` may not fit every scene, so treat its output as a draft and still run steps 12 and 13.
12. Validate the output again with the same validator used in step 4, and re-run `budget_check.py` and `gltf-transform inspect` on the output. Compare counts side by side with the source.
13. View the output in the actual target viewer, on a real phone class device when phones are in scope. Check silhouette, normal map detail, transparent materials, skinning, and animation playback. A file that passes validation can still look wrong, and a file that looks right in a desktop viewer can fail in the production viewer when a decoder is missing.

## Output Contract

Report the result in this shape, with real numbers from the tools, never estimates presented as measurements:

- Source file name and size, output file name and size, and the size change as a percentage.
- Triangle count, texture count, and max texture dimension, before and after.
- The budget from step 1 and a pass or breach line for each budget item, taken from the budget checker output.
- Each pass applied, in order, with the tool and the settings that matter (compression scheme, texture mode, simplify target).
- Extensions the output requires, copied from `extensionsRequired` in the budget checker output, plus a line confirming the target viewer has the decoders for them or stating that this was not confirmed.
- Validator result for the output: tool used, error count, and any warnings carried forward.
- Visual check result: where the output was viewed, and what was checked. If no visual check happened, say so plainly.
- LOD variants produced, if any, with file names and triangle counts.

## Operating Rules

1. Never overwrite the source file. Every pass writes a new file, and the source stays available for re-runs and visual comparison.
2. Lossless cleanup before lossy steps, geometry compression last. Reordering these steps wastes quality for no size gain.
3. One geometry compression scheme per file. Do not stack Draco on top of meshopt output.
4. A smaller file that the target viewer cannot decode is a failure, not a win. Decoder support gets checked in the real viewer, not assumed from a tools list.
5. Budgets come from the target device and the page around the model, not from habit. State the budget in the report even when the project supplied it.
6. Simplify in steps and look at the result. A single aggressive ratio applied blind is how thin parts and faces get destroyed.
7. Texture mode follows the map type: UASTC for normal maps and hero color, ETC1S where loss is acceptable. Never put a normal map through a lossy mode chosen only for size without a visual check.
8. Keep names that outside code depends on. Simplification, joining, and packing tools can rename or merge nodes and materials; use keep-name options such as gltfpack `-kn` when the application looks nodes up by name, and test those lookups after optimizing.
9. Animations and skinning get their own check after any geometry pass. Static appearance passing says nothing about playback.
10. Verification basis for the commands in this skill, checked on 2026-10-12: `@gltf-transform/cli` version 4.5.1 from `npm view`; the command list, the `optimize` example, and the install command from the official docs at gltf-transform.dev, which list version 4.5.0; gltfpack flags from the gltfpack manual page covering `-i`, `-o`, `-c`, `-cc`, `-tc`, `-si`, and `-kn`; validator usage and its report file naming from the KhronosGroup glTF-Validator repository readme; meshoptimizer npm package at version 1.3.0 and the `gltf-validator` npm package at 2.0.0-dev.3.10 from the npm registry. Where a flag was not confirmed this way, the workflow says to check the installed tool help, and that check is part of the job, not optional.
11. When a tool is missing, install the documented way (`npm install --global @gltf-transform/cli` for glTF Transform; gltfpack and `gltf_validator` ship as standalone binaries from their project releases) or state plainly that the step was skipped. Do not substitute a remembered command for a missing tool.
