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:
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:
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:
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'.
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:
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=Truewithpath_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=Falsewithpath_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):
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:
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:
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):
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?