blender-skill
v0.31.0
Published
Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local 3D asset pipeline: inspect, convert, optimize, render, bake and validate glb/gltf/fbx/obj/stl/usd/usdz/ply/abc/.blend files via headless Blender. No API keys, no cloud, no pip depen
Maintainers
Readme
blender-skill
Give your coding agent a 3D asset pipeline. Local Blender, headless, no cloud, no API keys.
Install
npx blender-skill # this repo's own installer (Node)
npx skills add kajisho5/blender-skill # skills.sh registry
gh skill install kajisho5/blender-skill blender-skill # GitHub CLI 2.90+Any of the three installs SKILL.md and scripts/ where your agent looks for skills. Requires
Blender 4.2+ on the machine; the scripts find it themselves
(--blender flag, $BLENDER, PATH, or the OS's usual install location) and tell you how to
install it if they can't.
Demo
Real output from examples/make_demo.sh against tests/fixtures/fox.glb -- nothing staged.
A 360° turntable (render.py --turntable -o turntable.mp4) is at
docs/demo/turntable.mp4. The same run's info.py/check.py output,
unedited:
$ python3 scripts/info.py tests/fixtures/fox.glb
tests/fixtures/fox.glb: 3 object(s), 2 mesh(es), 656 triangles, 1770 vertices
materials: 1 textures: 1 animations: 3
warning: fox: 1150 non-manifold edge(s) (hole/gap in the surface, after welding split normals/UV seams) -- try: optimize.py <file> -o <out> --fill-holes
warning: fox: flipped-normal check skipped (unreliable while non-manifold edges are present -- fix those first, then re-run info.py)
$ python3 scripts/check.py tests/fixtures/fox.glb --target three.js
tests/fixtures/fox.glb vs three.js: PASS
PASS format: gltf is a recommended format for three.js
PASS triangle budget: 656 <= 300000
PASS texture size: all textures <= 4096px
PASS textures present: no missing texture files
PASS transmission size: 0.2 MB <= 15 MBRun bash examples/make_demo.sh yourself to regenerate all of it (info, optimize, convert
--verify, render, look, bake, check) end to end -- needs Blender, see Requirements below.
What this is / isn't
Inspects, converts, optimizes, renders, bakes and validates 3D assets from natural-language
requests -- for a coding agent working with .glb/.gltf/.fbx/.obj/.stl/.usd/.usdz/
.ply/.abc/.blend files, not for someone modeling interactively in Blender's UI. It runs
Blender exclusively headless (blender -b) and never opens a window. That makes it a complement
to, not a replacement for, Blender's own MCP add-on
or ahujasid/blender-mcp -- use this to check and fix a
file's numbers deterministically (triangle count, texture size, manifoldness, delivery-target
budgets), and a live Blender connection for anything that needs a human's eye on the actual
3D viewport.
Features
info.py-- object/mesh/material/texture/animation/armature counts, unit scale, bounding box, non-manifold-edge and duplicate-vertex detection (computed on a welded scratch copy, so a perfectly normal hard-edged/UV-seamed mesh isn't misreported as broken), approximate self-intersection detection, flipped-normal detection (only reported when trustworthy -- see below), unapplied-scale detection (a common cm/m unit-mismatch symptom), origin offset from each object's own bounding-box center/bottom-center (data, not a defect -- an off-center origin is often deliberate), UV out-of-[0,1]-range/zero-area/overlap detection (out-of-bounds and overlap are data, not defects -- tiling textures and mirrored UV islands both use them deliberately; zero-area is a real "never actually unwrapped" defect), vertex color layers and other genuinely custom mesh attributes (Blender's own non-internal built-ins --position,material_index, bevel weight, crease -- are excluded), per-animation-clip bone count and root-motion detection (whether the armature's own root bone'slocationchannel actually moves, vs. an in-place cycle meant to be driven by a character controller), bone hierarchy (name/parent per bone) and unweighted-vertex detection on skinned meshes (a vertex with no bone weight at all won't move with the rig -- skipped, not falsely flagged, on meshes with no armature modifier at all), a default LOD1/2/3 triangle-ratio suggestion (50%/25%/10% of the file's current total, with theoptimize.py --decimate-ratiocommand for each -- a starting-point estimate, not a guarantee), a draw-call estimate (one per distinct material a mesh object's faces actually use, not the cruder "material count x object count" that overestimates as soon as objects share a material), misconfigured-transparency detection (a hard binary-alpha mask, e.g. foliage, using real alpha blending instead of the cheaper, sorting-artifact-free dithered mode), misconfigured- texture-colorspace detection (Base Color/Emission not taggedsRGB, or Metallic/Roughness/ Alpha/a normal map's own texture not taggedNon-Color-- the latter is the most damaging in practice: a mistagged normal map silently warps the decoded normal vectors, no error thrown), (glb/gltf only) the file's ownextensionsUsed/extensionsRequiredlist read straight from its JSON -- with a short description per knownKHR_*/EXT_*extension -- since Blender's importer translates those into its own representation and doesn't expose which ones the source file declared, and duplicate-mesh/instancing detection (objects already sharing one mesh datablock, reported as data; objects on separate datablocks with identical geometry, flagged as a real "could share one datablock" memory-saving opportunity), and (.objonly) the referenced.mtl's per- material Phong parameters (Ka/Kd/Ks/Ns/d/illum) alongside the same Ns-to-roughness heuristic Blender's own OBJ importer applies -- confirmed to match its real output exactly -- since OBJ/MTL predates PBR and has no metallic/roughness channels of its own (metallic is never guessed: no reliable signal separates a metal from a shiny dielectric in Phong parameters alone), ray-cast wall-thickness analysis (a real, testable "3D-Print Toolbox"-style thickness check -- that add-on itself is a Blender Extension as of 4.2+, not bundled, so not something this skill can assume is installed), and point-cloud detection (a PLY vertices-only mesh -- photogrammetry/LiDAR scan data -- readsis_point_cloud: trueand skips the usual no-UV-map warning, which is meaningless for one), each defect naming its fix command where a safe one exists.convert.py-- glb/gltf, fbx, obj, stl, usd, usdz, ply, abc, .blend, any direction;--verifyre-imports the output and diffs it against the input;--up-axis/--forward-axisset the output's coordinate-system convention (Blender's X/Y/Z/-X/-Y/-Z vocabulary) -- glTF only accepts up-axis Y or Z and has no forward-axis control at all (its spec is fixed Y-up); ABC and .blend have no axis-orientation control in Blender at all.split.py-- split one multi-object file into one output file per independent object hierarchy (a root object with no parent, plus every descendant), so a skinned mesh stays with its armature while an unrelated standalone object becomes its own file.lod.py-- batch-generate LOD0 (unmodified original) through LOD3 in one call, one output file per level, at either the default ratios matchinginfo.py's own LOD1/2/3 suggestions (50%/25%/10%) or an absolute--target-trianglesfor LOD1 (LOD2/LOD3 auto-scale to keep the same halving relationship). Strips Blender's own synthesized bone-shape display widget after import first (seereferences/pitfalls.md) -- otherwise a skinned character's reported triangle counts are inflated by the widget's own triangles, and every level needlessly decimates it too.anim.py----extractstrips every non-armature object, keeping just the skeleton and its actions (an animation-only file);--combinemerges several files' actions (matched by bone name) onto one base character into a single output with every action as its own exportable clip -- the Mixamo workflow of downloading a rigged character once and several separate animation clips for it. Both discard Blender's own synthesized bone-shape display widget rather than treating it as real content to preserve (confirmed not to exist in the source file's own data -- seereferences/pitfalls.md).optimize.py-- decimate, weld duplicate vertices, recalculate normals, triangulate, clean up a mesh (--clean-mesh: orphan/loose vertices touching no face at all, plus degenerate zero-area faces -- a no-op on a point cloud, since every one of its vertices touches zero faces by definition; runs first so a decimate ratio/triangle budget is derived from a count that excludes dead geometry), report vertex-cache efficiency (--vertex-cache-report: each mesh object's Average Cache Miss Ratio -- a FIFO 32-entry vertex-cache simulation over the current triangle order, 3.0 the real worst case (no cache reuse at all), 0.5 a common target for a large closed mesh but not a universal floor (an unusual small/non-manifold mesh can score below it, confirmed directly) -- before and after this run's own topology changes, a zero-triangle mesh reportingnullin--jsonoutput andn/ain text output; reports only, doesn't reorder itself, since--meshoptalready delegates to gltf-transform's own cache-awarereordercommand), fill boundary-edge holes (--fill-holes), bake unapplied scale into the mesh (--fix-scale), recenter an object's origin without moving its geometry (--origin center|bottom), cap texture resolution, purge orphan data, thin a point cloud by voxel-grid downsampling (--point-thin-voxel SIZE-- a no-op on any mesh that has faces, since that's what--decimate-ratiois for), convert every texture to WebP (--webp, glb/gltf output only, addsEXT_texture_webp) -- reports the file's own real before/after texture bytes rather than assuming a reduction, because WebP at Blender's own default quality can end up larger than a well-compressed PNG for some textures (confirmed, not hypothetical -- seereferences/pitfalls.md;--webp-qualitygives a lever to actually shrink when that happens);--texture-auto-resolutioncomputes a per-texture cap instead of one flat--texture-max, from how much of the whole file's combined bounding box the largest object using that texture spans -- a real, data-driven screen-occupancy proxy this skill has no way to get from an actual camera/FOV, times--viewport-width(default 1920), rounded to the next power of two;--target-web/--target-mobile/--target-ar/--target-sketchfab/--target-vrchat/--target-roblox/--target-gltf-viewer/--target-quicklookpresets -- each grounded in that platform's own published numbers where one exists (VRChat's official PC "Good" Performance Rank triangle threshold, Roblox's official per-mesh triangle/texture caps, Apple's own AR Quick Look guidance), cited inPRESETS' own comment inscripts/bpy/optimize.py, not invented; a preset with a real absolute platform triangle cap (vrchat/roblox/quicklook) converts it into an actual decimate ratio at run time, so the output is really at or under that cap regardless of how big the input was to start -- not a fixed fraction that couldn't guarantee compliance for an arbitrary input size, a no-op when already within budget.vrchat/quicklookcompute one shared ratio from the whole file's combined triangle count, matching how those platforms actually evaluate a budget (one avatar/one scene as a whole);robloxinstead clamps each mesh object independently against the same absolute number, since Roblox's own real limit is explicitly per individual mesh, not a scene total -- an aggregate ratio there would needlessly decimate two already-individually-compliant meshes just because their combined total crossed the cap, a case Roblox's real limit never actually restricts (caught by review; seereferences/pitfalls.md);--draco/--meshopt/--ktx2delegate to gltf-transform (and, for--ktx2, the KTX-SoftwarektxCLI) when installed, and say so and skip just that step when they aren't.--generate-mipmaps DIRpre-generates the full mip chain (level 0 = this run's own final texture size -- after any--texture-max/--texture-auto-resolutionresize -- each level independently halved per dimension down to 1x1, the standard GPU mip-chain definition) as separate PNG files underDIR, for every texture a mesh object's material actually references, using Blender's ownImage.scale()(a real box/bilinear filter, confirmed directly against a checkerboard pattern -- not a naive nearest-neighbor resize); runs on a throwaway copy, never touching the texture still referenced by this run's own export. For a pipeline/engine that needs mips pre-baked as loose files rather than generated at runtime or embedded in a container format ----ktx2already embeds its own generated mip chain in the compressed KTX2 texture, so use--generate-mipmapsinstead only when you need the mips as standalone files.--recalc-normalswarns instead of silently trusting its own output on a still-non-manifold mesh -- seereferences/pitfalls.md, this is a real failure mode, not a hypothetical one.--fix-colorspacecorrects a texture's colorspace tag based on which material socket it feeds (Base Color/Emission needsRGB; Metallic/ Roughness/Alpha/a normal map's own texture needNon-Color-- info.py flags the mismatch, this fixes it);--texture-colorspace NAMEis the blunt override, forcing every texture to one named colorspace (e.g.ACEScg/ACES2065-1for an ACES-aware pipeline -- Blender's own bundled OCIO config has no ACES view transform for rendering at all, only these texture- tagging colorspaces, confirmed via its real enum).--flip-normal-map-greeninverts the green (Y) channel of every texture genuinely wired as a tangent-space normal map (an Image Texture feeding a Normal Map node feeding a material's Normal input) -- the complete, sole pixel operation to convert between OpenGL (+Y) and DirectX (-Y) normal map convention; R/X and B/Z are shared between both conventions and left untouched. Deliberately does not attempt to auto-detect which convention a normal map is currently in -- no reliable general-case, pixel-data-only method for that exists (confirmed via research: even Adobe Substance 3D Painter can't do it without an explicit tag, and Unity holds a patent, US12102923B2, for a statistical reconstruction-error heuristic with no published reliability figures). glTF's own spec mandates OpenGL convention fornormalTexture; Unity and Godot also expect OpenGL; Unreal expects DirectX -- each engine's own texture importer exposes only a manual "flip green channel" toggle, never auto-detection, which is exactly what this mirrors: a deliberate conversion you run when you know the source and target disagree.--remove-unused-bonesprunes every bone with zero skin-weight influence and no animation, from the leaves inward (never a bone a still-used descendant needs);--max-bones Ncaps an armature at N bones -- a real mobile-engine-style bone budget, not just a report -- removing the lowest-influence unanimated leaf bones first and reassigning their skin weight to the parent bone, never removing an animated bone even if the target can't otherwise be reached (warns instead).--keyframe- decimate TOLERANCEthins every action's keyframes with Ramer-Douglas-Peucker curve simplification, exact only for LINEAR/CONSTANT-interpolated segments (the two modes where the real post-removal value is cheaply computable -- a straight line or a flat step): a keyframe is dropped only if that real value would deviate by at most TOLERANCE (in that fcurve's own units) from its actual value; a BEZIER-governed keyframe (Blender's own default for a hand-keyed action) is never touched, since approximating its real curve risks silently exceeding TOLERANCE (confirmed on a CONSTANT counterexample -- seereferences/pitfalls.md). A glTF import is exclusively LINEAR, so this isn't a hobbled feature for that common case. The first/last keyframe of every fcurve always survives.--remove-unused-shape-keysremoves every non-Basis shape key whose max per-vertex displacement from whatever it's actually defined relative to (usually Basis, but a shape key can chain off another shape key instead) is below a tiny fixed epsilon -- geometrically a no-op no matter its value slider, mute state, or any driver pointed at it. A multi-level dead chain resolves correctly on its own: Blender's ownobj.shape_key_remove()automatically re-points every shape key that referenced the removed one onto its relative_key (verified directly against real Blender), so removing a chain of dead keys one at a time still lands every surviving key on the right base.--instance-duplicate-meshesmerges every group of mesh objects on separate datablocks that are fully identical within a tight floating-point tolerance -- vertex positions, face topology plus per-face material_index/use_smooth, sharp-face/sharp-edge marks, every UV layer's name/active-render flag/coordinates, every color attribute's name/values and the mesh's render-fallback selection, and the exact same Material datablocks (not just similarly shaped; stricter thaninfo.py's own looseduplicate_mesh_candidatessuggestion) -- onto one shared datablock, freeing the now-orphaned duplicates. A material's node tree can look up a UV layer or color attribute by name, so two meshes with identical values under different layer names or a different render fallback are correctly left unmerged, not just ones with different raw values (seereferences/pitfalls.md). Never merges a mesh with custom split normals (exact comparison is out of scope) or touches a mesh with shape keys or an Armature modifier -- or any datablock shared with such a mesh, even one of its other users would individually have qualified: shared mesh data means shared shape-key/vertex-weight state in Blender's own data model, not just shared shape, so merging those could silently change animated behavior.--merge-materialsmerges every group of separate Material datablocks that are fully identical onto one canonical datablock, redirecting every real reference to a duplicate (Blender's ownID.user_remap-- every mesh/curve/other data-block's material slot and every per-object slot override in one call, not a hand-enumerated walk that could miss a user type) before freeing it: every non-identity RNA property on the Material itself, plus -- whenuse_nodesis on -- true node-tree structural equality (the same node types/settings/socket values, and the exact same links between them, matched by node name, not just a similar node count). AnImage/NodeTree/ other ID reference inside the graph (an Image Texture node's.image, a Group node's.node_tree, ...) is compared by datablock identity, not by name or pixel content -- two texture nodes pointing at separately-created but pixel-identical images are correctly left unmerged, since a real edit to one wouldn't reach the other. A node property Blender marks read-only can still need comparing: a Color Ramp/Mapping/ Image-User-style nested settings struct is read-only only in the sense that the pointer can't be reassigned, not that its own fields are fixed -- naively skipping every read-only property would treat two Color Ramp nodes with completely different ramps as identical (seereferences/pitfalls.md).render.py-- a thumbnail, a 360° turntable (PNG sequence or an FFmpeg-encoded video), or a 4-view sheet. Eevee by default,--cyclesto switch.look.py-- the agent's eyes: a wireframe render, a grid of every texture in the file, a before/after comparison (with a pure-numpy PSNR/SSIM similarity score when the two images share pixel dimensions), or the UV layout (as SVG -- headless Blender's PNG UV export needs a GPU offscreen context-bmode doesn't have).check.py-- PASS/WARN/FAIL against a delivery target's budget (three.js, Unity, Unreal, Godot, iOS/Android AR, WebXR, 3D printing, Sketchfab), every row naming its fix command; for.usdzinputs, also validates the package itself against Apple's real USDZ requirements (every entry uncompressed and 64-byte-aligned -- read from the file's own zip structure, independent of Blender).bake.py-- Cycles-bake a material to a texture (AO/normal/roughness/diffuse/combined);--atlasrepacks UVs across several objects into one shared image first.scene.py-- assemble a declarativescene.json(asset placement, lights, camera, background) into a render and/or an exported 3D file.batch.py-- chain this skill's own scripts as a recipe over every file in a folder, with a content-hash cache so a re-run only reprocesses what changed.verify.py-- the whole toolchain (info → convert --verify → check) over one or more real files, PASS/FAIL per file.- MCP server (
mcp/server.py) -- every script above as an MCP tool over stdio.
Usage
"check this glb for a Unity import" -> check.py model.glb --target unity
"cut this to 50k triangles" -> info.py, then optimize.py --decimate-ratio ...
"convert this fbx to usdz and confirm it worked" -> convert.py model.fbx -o model.usdz --verify
"make a turntable of this model" -> render.py model.glb --turntable -o turntable.mp4
"bake this material to a single texture" -> bake.py model.glb --pass combined -o baked.pngScripts
| Script | Purpose |
|---|---|
| info.py | Inspect: counts, scale, bounding box, manifold/UV checks |
| convert.py | Cross-format conversion, with round-trip --verify |
| split.py | Split a multi-object file into one file per independent object hierarchy |
| anim.py | Extract animation-only data, or combine several files' actions (Mixamo workflow) |
| lod.py | Batch-generate LOD0-3 files at decreasing triangle counts |
| optimize.py | Decimate, weld, triangulate, cap texture size, purge unused, Draco/Meshopt/KTX2 (delegated) |
| render.py | Thumbnail, turntable, 4-view sheet |
| look.py | Wireframe, texture grid, before/after compare, UV layout |
| check.py | Delivery-target PASS/WARN/FAIL with fix commands |
| bake.py | Material-to-texture baking, with UV-atlas repacking |
| scene.py | Declarative multi-asset scene assembly |
| batch.py | Recipe chains over a folder, with a content-hash cache |
| verify.py | The whole toolchain over real files |
See references/pitfalls.md for the real gotchas found building this (GPU-offscreen limits
under -b, an Eevee engine-name rename between Blender versions, format round-trip quirks) and
references/blender-versions.md for which Blender versions have actually been tested.
MCP
{
"mcpServers": {
"blender-skill": {
"command": "python3",
"args": ["/path/to/blender-skill/mcp/server.py"]
}
}
}Each tool takes one argument, argv: the exact CLI arguments you'd pass to that script.
Requirements
- Blender 4.2 or newer (LTS releases tested: 4.2, 4.5;
latest tested: 5.2 -- see
references/blender-versions.md) - Python 3.9+ for the host-side scripts (standard library only, no
pip install) - Node.js 16+ only for the
npx blender-skillinstaller itself
Development
git clone https://github.com/kajisho5/blender-skill
cd blender-skill
python3 tests/test_all.pySee CONTRIBUTING.md and ROADMAP.md for what's planned next.
License
MIT -- see LICENSE.
