Cage-bake high-poly surface detail onto a low-poly target as a tangent-space normal map with bpy.ops.object.bake (Cycles, selected-to-active, active Image Texture node, UV layer, save_render). Use when the user wants a normal map from a dense mesh onto a game-resolution or decimated LOD, mentions cage_extrusion or use_selected_to_active, or gets a bake that returns CANCELLED, comes out black or flat, or fails with 'No active UV layer found'. Targets 5.2 LTS with 4.5 LTS fallback.
Installs into .claude/skills of the current project.
Are you the author of Bake High To Low?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/tmhsdigital-bake-high-to-low)
---
name: bake-high-to-low
description: "Cage-bake high-poly surface detail onto a low-poly target as a tangent-space normal map with bpy.ops.object.bake (Cycles, selected-to-active, active Image Texture node, UV layer, save_render). Use when the user wants a normal map from a dense mesh onto a game-resolution or decimated LOD, mentions cage_extrusion or use_selected_to_active, or gets a bake that returns CANCELLED, comes out black or flat, or fails with 'No active UV layer found'. Targets 5.2 LTS with 4.5 LTS fallback."
standards-version: 1.10.0
---
# Bake High to Low
## Trigger
Use this skill when the user:
- Wants a tangent-space normal map from a dense source onto a game-resolution mesh
- Mentions cage bake, `use_selected_to_active`, `cage_extrusion`, or `bpy.ops.object.bake`
- Has an LOD from `DECIMATE COLLAPSE` (or a retopo) and needs the missing surface detail in a map
- Is about to call bake from EEVEE, on GPU in CI, or with `bake_type=`
This skill is the bake step. It composes `ai-mesh-cleanup` (identity scale, applied transforms, a UV layer that already exists) and the LOD contract in [`examples/lod-decimate-chain/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/lod-decimate-chain) / [`snippets/decimate_to_budget.py`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/snippets/decimate_to_budget.py) (collapse onto a budget; UVs survive well enough to bake). It does not unwrap, transfer UVs, or pack an atlas — those are a later phase. It does not generate meshes.
## The core misunderstanding
Baking is not a render. EEVEE has no bake path. The operator writes into an **Image Texture node** in the **active** object's material (the active one, `nodes.active`, when the material has several), not into a file and not into "the selected image datablock." The failure modes are not equally loud. Measured on 4.5.11 LTS and 5.2.1 LTS:
| Setup mistake | What `bpy.ops.object.bake` does |
| --- | --- |
| Target mesh has no UV layer | Raises `RuntimeError: No active UV layer found in the object "Low"` |
| Target material has no Image Texture node | Returns `{'CANCELLED'}`, **raises nothing**, writes nothing |
| The only Image Texture node is not `nodes.active` | Bakes into it anyway (`{'FINISHED'}`) |
So a headless job must check the returned set: `{'CANCELLED'}` is the silent one. With several Image Texture nodes, set `nodes.active` to the one you mean.
The pass type RNA is `type`, not `bake_type`. `bake_type` is not on `bpy.ops.object.bake`. Passing it is a TypeError.
Cycles bake is stochastic. Pixel buffers are not byte-identical across 4.5 / 5.1 / 5.2, even at one sample on CPU. Assert with tolerances and a flat-source control, not a hash.
## RNA (verified)
Live dump of `bpy.ops.object.bake.get_rna_type()` on 4.5.11 LTS, 5.1.2, and 5.2.1 LTS: **22 properties, identical identifiers and enums.** No version shim. The kwargs below are the contract.
Docs:
- 5.2: https://docs.blender.org/api/current/bpy.ops.object.html#bpy.ops.object.bake
- 5.1: https://docs.blender.org/api/5.1/bpy.ops.object.html#bpy.ops.object.bake
- 4.5: https://docs.blender.org/api/4.5/bpy.ops.object.html#bpy.ops.object.bake
`cage_object` is a **string name**, never an Object pointer.
## The canonical pattern
Every step is a silent-failure point. Do them in this order.
### 1. Engine and device
```python
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.device = "CPU"
scene.cycles.samples = 1
scene.cycles.use_denoising = False
```
Do not rely on the default device. Headless CI has no GPU. `samples = 1` is enough for a statistical check; raise it for production maps, not for smoke.
### 2. Target: UV layer, image, active Image Texture node
The low-poly object needs a UV layer (`mesh.uv_layers`). Confirm it; do not unwrap here.
```python
img = bpy.data.images.new("BakeNrm", 128, 128, alpha=True, float_buffer=False)
img.colorspace_settings.name = "Non-Color"
mat = bpy.data.materials.new("BakeTarget")
if bpy.app.version < (5, 0, 0):
mat.use_nodes = True # 4.5 LTS only; the tree exists and use_nodes is deprecated on 5.x
nodes = mat.node_tree.nodes
tex = nodes.new("ShaderNodeTexImage")
tex.image = img
nodes.active = tex
tex.select = True
if obj.data.materials:
obj.data.materials[0] = mat
else:
obj.data.materials.append(mat)
```
The Image Texture does **not** need to be linked into Principled for the bake to land. It must be `nodes.active`. After the bake, wire `ShaderNodeNormalMap` for display or export; do not plug Image Texture Color into Principled Normal.
Snippet: [`snippets/setup_bake_target_image.py`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/snippets/setup_bake_target_image.py).
### 3. Selection: high selected, low active
```python
high.select_set(True)
low.select_set(True)
bpy.context.view_layer.objects.active = low
```
`use_selected_to_active=True` bakes **selected** sources onto the **active** target. Reverse that and the map is empty or self-bakes the low.
### 4. Cage normal bake
```python
result = bpy.ops.object.bake(
type="NORMAL",
use_selected_to_active=True,
cage_extrusion=0.20,
use_cage=False,
normal_space="TANGENT",
margin=4,
margin_type="ADJACENT_FACES",
use_clear=True,
target="IMAGE_TEXTURES",
)
if result != {'FINISHED'}:
# {'CANCELLED'} raises nothing: e.g. no Image Texture node in the material.
raise RuntimeError(f"bake did not finish: {result}")
```
`cage_extrusion` inflates the active object along its normals so rays hit the high mesh. It must clear the high-frequency amplitude (ribs at 0.10 need extrusion > 0.10; 0.20 is the worked value). If extrusion alone skims past concavities, set `use_cage=True` and `cage_object="CageName"` (the object's `.name`). Do not pass the Object.
`margin_type` is `ADJACENT_FACES` or `EXTEND`. Prefer `ADJACENT_FACES` at UV seams.
Snippet: [`snippets/bake_normal_high_to_low.py`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/snippets/bake_normal_high_to_low.py).
### 5. Save the datablock
The bake writes the image **in memory**. `Image.save()` on a `GENERATED` image flips `source` to `'FILE'` and drops the buffer — the trap [`examples/image-pixels-testcard/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/image-pixels-testcard) witnesses. Use `save_render()`.
`save_render()` writes in the format of the **scene's** `render.image_settings`, not `img.file_format`: setting `img.file_format = "OPEN_EXR"` and calling `save_render` still writes a PNG on a default scene. Set the scene's format for the call and restore it:
```python
settings = scene.render.image_settings
previous = settings.file_format
settings.file_format = "OPEN_EXR" # or "PNG"
try:
img.save_render(filepath, scene=scene)
finally:
settings.file_format = previous
```
Snippet: [`snippets/save_baked_image.py`](https://github.com/TMHSDigital/Blender-Developer-Tools/blob/main/snippets/save_baked_image.py). Folded save is the wrong size: the trap is its own contract.
## Common mistakes
| Wrong | Right |
| --- | --- |
| `bake_type='NORMAL'` | `type='NORMAL'` |
| `scene.render.engine = 'BLENDER_EEVEE'` then bake | `CYCLES` only |
| GPU / default device in CI | `scene.cycles.device = 'CPU'` |
| Low selected, high active | High selected, low **active** |
| Image Texture present but not `nodes.active` | Set `nodes.active = tex` |
| No UV layer | Confirm `mesh.uv_layers` before bake |
| `Image.save()` after bake | `Image.save_render(path)` |
| Hash / byte-compare maps across versions | Fraction / MAD vs `(0.5, 0.5, 1.0)` plus a flat-source control |
## Version notes
Bake operator RNA is identical on 4.5 LTS, 5.1, and 5.2 LTS. Branch on `bpy.app.version` only for unrelated neighbors (EEVEE engine id for a gallery still, NodesModifier inputs). Do not invent a bake shim.
Witness: [`examples/bake-normal-high-to-low/`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/bake-normal-high-to-low). `--flat-source` feeds an undisplaced high into the same detail assertion and must exit non-zero.
<!-- examples:begin (generated by scripts/build_examples_index.py from examples/skills.json; do not edit) -->
## Runnable examples
Each example runs headless, asserts the contract, and exits non-zero when it breaks. Run one with `blender --background --python <script> --`; pass a falsifier flag to watch the check fail.
- [`bake-normal-high-to-low`](https://github.com/TMHSDigital/Blender-Developer-Tools/tree/main/examples/bake-normal-high-to-low): A collapse-decimated hatch plate receiving a Cycles cage-baked tangent normal map from a ribbed high-poly source Falsify: `--flat-source` (exit 5).
<!-- examples:end -->