# Blender Lod Pipeline

> Generate LOD (level-of-detail) mesh variants of a Blender model via progressive Decimate/Collapse, fix broken or missing texture links, and export each LOD to FBX (embedded or external textures) plus a single set of separate, unpacked 8-bit PNG PBR texture maps (base color/roughness/metallic/AO/normal each as their own file, splitting any pre-packed source maps apart, shared across all LOD tiers rather than duplicated per LOD) — with control over naming conventions, optional channel packing (e.g. ORM) as an explicit add-on, and baking an AO map when none exists. Use whenever the user asks to create LODs, make a low-poly version, decimate a model, reduce poly count for a game asset, relink or fix a broken texture, export to FBX for a game engine, export or split textures, or bake an AO map — even without the word "LOD" (e.g. "cut the triangle count down", "textures look pink/missing", "give me separate roughness and metallic maps"). Applies in a Blender MCP context.

- Skill: `jmhobbs/blender-lod-pipeline` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jmhobbs/blender-lod-pipeline`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jmhobbs/blender-lod-pipeline/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: jmhobbs (https://skillmd.com/u/jmhobbs)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/jmhobbs/blender-lod-pipeline

---


# Blender LOD Pipeline

Workflow for taking a high-poly textured mesh already open in a user's local
Blender (connected via the Blender MCP server) and producing a set of
lower-poly LOD variants, each correctly textured and exportable to FBX with
its texture maps.

Everything below runs as Python snippets passed to
`Blender:execute_blender_code`. That tool executes on the **user's own
machine**, not the assistant's sandbox — its filesystem, running Blender
session, and any local file paths are entirely separate from
`bash_tool`/`create_file`. Keep this distinction in mind throughout; it drives
several of the "gotchas" below, especially around image transfer and where
exported files land.

## Step 1: Inspect the source mesh and materials

Before doing anything, get the facts via `execute_blender_code` — object
names and poly counts (needed for decimate ratios later), UV layers,
materials, and existing modifiers:

```python
import bpy
result = {}
objs = []
for obj in bpy.data.objects:
    if obj.type == 'MESH':
        me = obj.data
        objs.append({
            "name": obj.name,
            "verts": len(me.vertices),
            "polys": len(me.polygons),
            "uv_layers": [uv.name for uv in me.uv_layers],
            "materials": [m.name if m else None for m in obj.data.materials],
            "modifiers": [m.name + ":" + m.type for m in obj.modifiers],
        })
result["objects"] = objs
result["materials"] = [m.name for m in bpy.data.materials]
```

Confirm with the user which object is the LOD target if there's more than one
mesh in the scene, and how many LOD levels / what reduction factor they want
(default assumption if unstated: halve the triangle count at each step —
50%, 25%, 12.5%, ... extend further for more aggressive tiers).

Note: for triangle-equivalent counts on an all-quads mesh, `tris ≈ polys * 2`;
if the mesh isn't obviously quad-based, triangulate a duplicate to be sure
rather than assuming.

## Step 2: Fix broken texture links (if needed)

A common starting problem: a material's Image Texture node points to a
filepath that doesn't exist on the Blender host (moved/renamed folder, or the
project was passed between machines). Detect it like this:

```python
import bpy, os
for mat_name in (...):
    mat = bpy.data.materials.get(mat_name)
    tex_node = mat.node_tree.nodes.get("Image Texture")
    img = tex_node.image
    print(img.filepath, os.path.exists(bpy.path.abspath(img.filepath)), img.size)
```

If `size` is `[0, 0]` and the path doesn't exist, the link is broken.

**Always ask the user for the correct local path first** rather than
improvising a workaround — if they can give you a real path on the Blender
host machine, relinking is a single, cheap call:

```python
img = bpy.data.images.load(correct_path, check_existing=True)
img.reload()
tex_node.image = img
```

If the user has no local copy and the only source image lives in a chat
upload, do not attempt to transfer it into Blender yourself — see "Cross-
machine image transfer" near the end. Tell them plainly that they need to
save the image to the Blender host's local filesystem themselves and give
you that path.

## Step 3: Generate LOD variants via Decimate (Collapse)

For each LOD tier, duplicate the source mesh (data too, not linked), add a
**Decimate modifier in `COLLAPSE` mode**, apply it, and file the result into
its own collection. Collapse mode merges vertices by contracting edges at
low-detail areas first, which naturally smooths out small bumps/noise before
eating large flat regions — the "smooth out at a distance" look you want —
and it's UV/edge-collapse aware, preserving UVs and smooth shading far better
than `'PLANAR'` decimate on dense, non-coplanar meshes. If `'PLANAR'` ever
wildly overshoots a target ratio (e.g. asking for 50% but landing near 10%),
that's the signal the mesh is dense/non-coplanar — switch to `'COLLAPSE'`.

```python
import bpy

source_names = [...]  # e.g. ["Body_LD", "Lid_LD"]
lod_ratios = {
    "LOD1": 0.5,
    "LOD2": 0.25,
    "LOD3": 0.125,
    # continue halving for more aggressive tiers if asked, e.g.
    # "LOD4": 0.0625, "LOD5": 0.03125, "LOD6": 0.015625, "LOD7": 0.0078125
}

created = []
for lod_name, ratio in lod_ratios.items():
    col = bpy.data.collections.new(lod_name)
    bpy.context.scene.collection.children.link(col)
    col.hide_render = True
    col.hide_viewport = True  # LODs are off by default until previewed/exported

    for src_name in source_names:
        src_obj = bpy.data.objects[src_name]  # always duplicate from the LOD0 source
        new_mesh = src_obj.data.copy()
        new_obj = src_obj.copy()
        new_obj.data = new_mesh
        new_obj.name = f"{src_name}_{lod_name}"
        col.objects.link(new_obj)

        dec = new_obj.modifiers.new(name="Decimate", type='DECIMATE')
        dec.decimate_type = 'COLLAPSE'
        dec.ratio = ratio  # relative to the ORIGINAL (LOD0) poly count, not the previous LOD
        bpy.context.view_layer.objects.active = new_obj
        bpy.ops.object.modifier_apply(modifier=dec.name)

        created.append({"name": new_obj.name, "verts": len(new_mesh.vertices),
                         "polys": len(new_mesh.polygons)})
result = {"lods": created}
```

**Important:** `ratio` is always computed relative to the original LOD0 mesh,
not chained relative to the previous LOD — duplicate from the LOD0 source
each time, never from the last LOD's already-decimated mesh (that compounds
error and drifts the ratios).

Verify after generating: check triangle-equivalent counts land where
expected, confirm UVs survived (`len(mesh.uv_layers) > 0`), and confirm
smooth shading carried over (`all(p.use_smooth for p in mesh.polygons)`).
Report a vert/poly table (LOD name → count → % of base) so the user can
confirm the reduction landed correctly.

**Flag, don't silently fix, UV-stretch risk:** Decimate collapse doesn't
treat UV seams as hard boundaries, so texture mapping can distort in
heavily-collapsed areas. Mention this as worth an eyeball check in the UV
editor, especially at the most aggressive LOD.

## Step 4: Export each LOD to FBX

Ask where exported files should go if the .blend file has no path yet
(`bpy.data.filepath == ""`) — there's no "next to the file" default to fall
back on. Use `ask_user_input_v0` with a couple of sensible folder
suggestions (Desktop, Documents, or an existing project folder near the
source textures) plus a free-text option.

Export each LOD's objects individually by selecting only that LOD's meshes.
Make sure the LOD's collection is visible in the viewport before selecting
(hidden objects can't be selected), and restore visibility state afterward:

```python
import bpy, os

out_dir = "..."  # from the user, or a sensible default near the source files
os.makedirs(out_dir, exist_ok=True)

lod_object_names = {
    "LOD0": [...],
    "LOD1": [...],
    # ...
}

for lod, obj_names in lod_object_names.items():
    col = bpy.data.collections.get(lod)
    orig_hide_viewport = col.hide_viewport
    col.hide_viewport = False  # must be visible in viewport to select its objects

    bpy.ops.object.select_all(action='DESELECT')
    for name in obj_names:
        obj = bpy.data.objects[name]
        obj.hide_set(False)
        obj.select_set(True)
    bpy.context.view_layer.objects.active = bpy.data.objects[obj_names[0]]

    bpy.ops.export_scene.fbx(
        filepath=os.path.join(out_dir, f"{lod}.fbx"),
        use_selection=True,
        object_types={'MESH'},
        use_mesh_modifiers=True,
        mesh_smooth_type='FACE',
        path_mode='AUTO',       # or 'COPY' — see embed/external note below
        embed_textures=False,   # see embed/external note below
    )

    col.hide_viewport = orig_hide_viewport  # restore, don't leave scene state altered
```

**Embedded vs. external textures — ask if unclear:**
- `embed_textures=True` with `path_mode='COPY'` bakes the full texture bytes
  into every single FBX. Simple, but each LOD's file size is then dominated
  by the (identical, full-resolution) texture data rather than its actual
  geometry — a 900-triangle LOD7 can still be tens of MB. Fine for a one-off
  handoff.
- `embed_textures=False` with `path_mode='AUTO'` (or `'COPY'` to also copy
  the texture files alongside the FBX) keeps the FBX referencing external
  texture files, so file size actually reflects geometry. This is almost
  always what's wanted for a real LOD chain destined for a game engine —
  default to asking, or lean toward this option if the user mentions LODs,
  file size, or multiple exports at all.

Always restore any `hide_viewport`/`hide_render` state you toggled purely to
make selection/export possible, so the scene doesn't end up in a different
state than the user left it just because of an export.

## Step 5: Export the material's PBR texture maps as separate files

### 6a. Identify what actually exists

Don't assume standard map names/slots exist — inspect the actual node graph
first, because texture setups vary (glTF-imported materials often split
metallic/roughness into separate pre-baked grayscale images via Separate
Color nodes rather than a single packed ORM texture):

```python
import bpy

mat = bpy.data.materials.get("MATERIAL_NAME")
nt = mat.node_tree
for node in nt.nodes:
    if node.type == 'TEX_IMAGE' and node.image:
        for out in node.outputs:
            for link in out.links:
                print(node.image.name, "->", link.to_node.type, link.to_socket.name)
```

Trace each `TEX_IMAGE` node to what it ultimately feeds (Base Color,
Metallic, Roughness, Normal Map). If a texture routes through a
`SEPARATE_COLOR` node before reaching the BSDF, note which output channel
(R/G/B) is used.

### 6b. Bake an AO map if one doesn't exist

Many materials (especially glTF imports) don't have a dedicated AO texture —
don't report success on a map that isn't there. If none exists, offer to
bake one rather than silently skipping it or fabricating a filename.
Baking requires **Cycles** (the bake operator doesn't run under Eevee), a
target image assigned to an active Image Texture node, and the object
selected:

```python
import bpy

obj = bpy.data.objects["BASE_OBJECT_NAME"]  # bake from LOD0 for the most accurate result
mat = obj.active_material

orig_engine = bpy.context.scene.render.engine
bpy.context.scene.render.engine = 'CYCLES'

ao_img = bpy.data.images.new("AO_bake", width=2048, height=2048)  # match source map resolution
bake_node = mat.node_tree.nodes.new('ShaderNodeTexImage')
bake_node.image = ao_img
mat.node_tree.nodes.active = bake_node  # bake target = active image node

bpy.context.view_layer.objects.active = obj
obj.select_set(True)
bpy.context.scene.cycles.samples = 32          # AO doesn't need many samples
bpy.ops.object.bake(type='AO', margin=16)      # margin prevents seam bleeding at UV edges

bpy.context.scene.render.engine = orig_engine  # restore — bake doesn't need Cycles left active
mat.node_tree.nodes.remove(bake_node)          # remove the temp node; ao_img datablock persists
```

Flag that a baked AO map reflects the geometry it was baked from — bake
from LOD0 (or the highest LOD you export from) rather than a decimated tier,
and re-bake if the base mesh changes later.

### 6c. Format and separate files

Export every map as 8-bit PNG — lossless, universal alpha support, no
external codecs, and the standard bit depth real-time engines expect.

**Keep every map as its own file — do not channel-pack by default.**
Export Base Color, Roughness, Metallic, AO, Normal, Emissive, and Height
(whichever exist) as separate, single-purpose image files, each carrying
one map's actual value in every RGB channel it uses (not one channel of a
combined texture). This applies even when the source material already has
maps packed together upstream (e.g. a glTF import with Roughness/Metallic
pre-packed into one image's G/B channels) — split those back out into
distinct images rather than passing the packed source through as-is:

```python
import bpy

packed_img = bpy.data.images.get("SOURCE_ORM_OR_RM_IMAGE")
w, h = packed_img.size
n = w * h
src_px = [0.0] * (n * 4)
packed_img.pixels.foreach_get(src_px)

# Example: source packs Roughness in G, Metallic in B (common glTF layout) —
# adjust channel indices to match whatever 6a's node-graph trace found.
def extract_channel(channel_index, out_name):
    out_px = [0.0] * (n * 4)
    for i in range(n):
        v = src_px[i*4 + channel_index]
        out_px[i*4 + 0] = out_px[i*4 + 1] = out_px[i*4 + 2] = v  # replicate to RGB
        out_px[i*4 + 3] = 1.0
    img = bpy.data.images.new(out_name, width=w, height=h)
    img.pixels.foreach_set(out_px)
    img.update()
    return img

roughness_img = extract_channel(1, "Roughness_split")
metallic_img = extract_channel(2, "Metallic_split")
```

Only produce a packed texture (e.g. ORM) if the user explicitly asks for
one — treat it as an additional, opt-in output alongside the separate maps,
never a replacement for them. This splitting loop (and any opt-in packing)
is per-pixel Python, so it's slow at high resolution (multiple seconds at
2048²) — prefer numpy if it's available in the Blender host's Python.

### 6d. Naming convention and export

Default naming pattern: `{MeshOrMaterialName}_{MapType}.{ext}`, with
`MapType` from a fixed vocabulary — `BaseColor`, `Normal`, `Roughness`,
`Metallic`, `AO`, `Emissive`, `Height` (add `ORM` or similar only for the
opt-in packed output from 6c, alongside — not instead of — the separate
files). Ask if the user's engine/pipeline expects a different convention
(e.g. Unreal's `_D`/`_N`/`_ORM` suffixes) before defaulting to the above.

**Export one texture set per model, not one per LOD.** Textures are shared
across all LOD tiers via the same UVs — export each map once, at its
highest available quality (traced from the source material, or the LOD0/base
mesh if baking AO), and reference that single set from every LOD's FBX.
Never suffix filenames with `_LOD0`, `_LOD1`, etc., and don't ask the user
about this — it's not a per-model decision to make.

Export each resolved image (images embedded in the .blend need to be saved
out, not just referenced; never mutate the original datablock's
filepath/format — always export a copy):

```python
import os

export_dir = "..."  # same out_dir as the FBX export, or as specified by the user
targets = {
    "SOURCE_IMAGE_NAME.jpg": "Crate_BaseColor.png",
    # ... one entry per resolved map: traced (6a), baked (6b), split back
    # out if the source was pre-packed (6c) — named per the convention
    # above. Add the packed output too only if requested.
}

for img_name, out_name in targets.items():
    img = bpy.data.images.get(img_name)
    if img is None:
        continue
    out_path = os.path.join(export_dir, out_name)
    img_copy = img.copy()          # don't mutate the original datablock's filepath/format
    img_copy.file_format = 'PNG'
    img_copy.filepath_raw = out_path
    img_copy.save()
    bpy.data.images.remove(img_copy)
```

## Step 6: Summarize for the user

Give a table of LOD name → vert/poly count → % of base, and a table of
exported files (FBX + PNG maps) with sizes. Call out explicitly: which
texture maps were found vs. missing (e.g. no AO), any UV-stretch risk at the
most decimated LOD, and whether textures were embedded or external in the
FBX exports.

## Cross-machine image transfer: explicitly disallowed

Do not attempt to move image bytes between the Blender host and the
assistant's sandbox in either direction — not via base64 chunking, not via
any other workaround. Tool results and the `view` tool both silently
truncate around ~17,000 characters with no error, so any naive attempt at
transferring a real texture will come back corrupted with no warning (it
still decodes to some bytes, just wrong ones, e.g. `image.size == [0, 0]`
after "successful" load). Chunking around that limit is technically
possible but prohibitively slow for anything beyond a tiny image, so it is
not an acceptable fallback here.

If the user asks you to deliver an image file into chat, or submits an
image and expects it loaded into their Blender scene, explain plainly that
this isn't something you can do: the Blender host and the assistant's
sandbox are separate machines with no shared filesystem, and there is no
fast, reliable way to move image bytes between them through the available
tools. The user needs to read or write the image themselves on the
Blender host's local filesystem — e.g. saving a chat-provided image to a
folder on their machine, then giving you that local path to load
(`bpy.data.images.load`), or opening a file they exported directly from
Blender rather than asking you to relay its bytes. Do not attempt the
chunked-transfer workaround even if explicitly asked to power through it.

## Quick reference: what to ask the user if unspecified

- How many LOD tiers, and at what ratios? (Halving is a reasonable default:
  50%, 25%, 12.5%, ... — extend further if they want more aggressive
  reduction.)
- Where should exported FBX/PNG files go? (Look for an existing project
  folder structure near the source textures as a hint; ask if the .blend
  has no filepath yet.)
- Embedded or external textures in the FBX export?
- Any house naming convention for the map files?
- Channel-pack any maps (e.g. ORM) in addition to the separate files? (Off
  by default — maps export unpacked unless explicitly requested.)
- Bake an AO map if one doesn't already exist?

