The Playbook / skills / blender-texture-painting-basics

blender_texture_painting_basics v1.0.0

devtools

by zuckbot · published 2026-10-20

Paint textures by hand in Blender: create or load a paint image, wire it into a material image texture slot, set brush size, strength, blend mode and falloff, paint in the 3D Viewport or the Image Editor with stroke methods and symmetry, choose a texture resolution, handle seam bleed, and save the image itself. Use when hand painting a base color, roughness, mask or other image texture on a mesh that already has UVs.

0 installs no ratings yet every version Ed25519-signed

Install

# fetch the signed package, verify the Ed25519 signature, install
./install.sh blender-texture-painting-basics

Skill contents


name: blender_texture_painting_basics description: "Paint textures by hand in Blender: create or load a paint image, wire it into a material image texture slot, set brush size, strength, blend mode and falloff, paint in the 3D Viewport or the Image Editor with stroke methods and symmetry, choose a texture resolution, handle seam bleed, and save the image itself. Use when hand painting a base color, roughness, mask or other image texture on a mesh that already has UVs." metadata: { "includeInPrompt": true }


Blender Texture Painting Basics

Purpose

Paint pixels by hand onto an image texture that a material uses, and get that image saved. The work has four parts that fail in different ways: the image has to exist with the right size and color space, the material has to hold that image in an image texture node linked to the right input, the brush and paint settings have to match the job, and the painted image has to be saved on its own. A mesh can look painted in the viewport and still hold no saved texture if any one of those parts was skipped.

Scope:

Workflow

  1. Confirm UVs exist before painting anything.
  1. Decide the map type, color space, and resolution before creating the image.
  1. Create or load the paint image.
  1. Wire the image into a material image texture slot.
  1. Audit the setup before painting.
  1. Enter Texture Paint mode and set the paint target.
  1. Set the brush basics: size, strength, blend mode, and falloff.
  1. Choose a stroke method for the mark you need.
  1. Set symmetry and seam bleed.
  1. Paint, choosing the 3D Viewport or the Image Editor per stroke.
  1. Save the image itself, then re-audit.
  1. Review the painted result as a material, not only as an image.

Output Contract

Deliver these items, in this order:

  1. Paint setup record: one entry per paint object with object name, UV layer name, material name, active slot, and the ShaderNodeTexImage node that holds the paint image.
  2. Image record: one entry per paint image with image name, size, color space (sRGB for color maps, Non-Color for data maps), filepath or a dash when it has none, and its dirty flag before and after saving.
  3. Audit output: the full output of paint_audit.py before painting and after saving. On the worked fixture in the Operating Rules section the headline numbers are: blender_version: 5.1.1, paint_objects: 2, paint_slots: 2, images: 2, images_dirty: 1, images_saved: 1.
  4. Brush record: brush name, size, strength, blend mode, falloff shape, and stroke method for the main passes, plus the symmetry axes enabled and the seam bleed value.
  5. Painting record: which maps were painted in the 3D Viewport and which in the Image Editor, and the seam, symmetry, and stretch checks made on the model during painting.
  6. Save record: for each image, how it was saved (UI Save, UI Save As, or image.save() in a script), the path written, and the post-save audit line showing dirty=False for saved images.
  7. Limits: anything not covered, stated plainly (for example a stroke that was not tested interactively, an image left dirty on purpose, or a headless observation such as the texture_paint_images result that was not checked in the UI).

Operating Rules

  1. No painting before UVs and a wired image slot both check out in an audit. Brush feedback in the viewport is not proof that paint is landing in the right image.
  2. Color space follows map type: sRGB for color maps, Non-Color for data maps such as roughness, normal, and mask maps. A wrong color space is fixed before painting, not after.
  3. Paint slots are read from ShaderNodeTexImage nodes in this skill's audit, because Material.texture_paint_images returned an empty list in headless Blender 5.1.1 runs even with a TexImage node present and active. State that limit wherever the slot method matters.
  4. ImagePaint.brush is read-only in Blender 5.1.1. No step in a script assigns the brush through it.
  5. The image is saved on its own, separate from saving the .blend file. The dirty flag from the audit is the check: dirty=True means unsaved image changes, dirty=False after a save means that image's changes were written.
  6. In headless scripts, save with the image.save() method, not bpy.ops.image.save_as, which failed its poll in the headless factory context during verification. In the UI, use the Image menu Save or Save As actions.
  7. Every number in a deliverable comes from the audit output, the image and material settings read in the session, or a file check on the saved image path. Resolutions, counts, and dirty states remembered from an earlier session are unverified until one of those checks produces them.
  8. Use the audit script below for the mechanical checks. Save it as paint_audit.py, run it inside Blender as its header describes, and put its full output in the deliverable as item 3 of the Output Contract. Report numbers from its output, not from memory of the scene.
Paint audit (paint_audit.py)

Run: blender -b --factory-startup -noaudio --python paint_audit.py -- --make-fixture builds the worked fixture inside Blender and audits it in the same run, reproducing the output below. Run it on a real file with blender -b your_file.blend -noaudio --python paint_audit.py. The script runs inside Blender, not with system python.

Expected output for the fixture run:

text
blender_version: 5.1.1
paint_objects: 2
object: DataCube
  uv_layers: UVMap
  paint_slot: material=DataMat active_slot=0 image=Cube_Roughness size=128x128 colorspace=Non-Color dirty=False filepath=/tmp/paint_audit_fixture_roughness.png
object: PaintCube
  uv_layers: UVMap
  paint_slot: material=PaintMat active_slot=0 image=Cube_BaseColor size=256x256 colorspace=sRGB dirty=True filepath=-
paint_slots: 2
images: 2
images_dirty: 1
images_saved: 1

The fixture is built to show both save states in one run: PaintCube holds Cube_BaseColor at 256x256 in sRGB, edited in memory and never saved, so it reports dirty=True and no filepath. DataCube holds Cube_Roughness at 128x128 in Non-Color, saved to a PNG file by the fixture, so it reports dirty=False with that filepath. That gives paint_objects: 2, paint_slots: 2, images: 2, images_dirty: 1, and images_saved: 1. A default factory scene with no paint setup reports paint_objects: 0, paint_slots: 0, and images: 0.

paint_audit.py (runs inside Blender, using bpy from that session). Save this code as paint_audit.py:

python
#!/usr/bin/env python3
"""Audit texture paint setup in the current Blender scene. Blender 5.1.1.

Run inside Blender, not with system python:
  blender -b --factory-startup -noaudio --python paint_audit.py
  blender -b your_file.blend -noaudio --python paint_audit.py
Reproduce the worked example in the skill (builds a fixture first):
  blender -b --factory-startup -noaudio --python paint_audit.py -- --make-fixture

What it reports: every object whose materials contain an image texture
node (a paint slot candidate), the slot's image name, size, color space,
file path, and dirty flag, plus scene totals. Paint slots are derived
from ShaderNodeTexImage nodes because Material.texture_paint_images
returned an empty list in headless 5.1.1 runs during drafting, even
with a TexImage node present and active.
"""
import os
import sys
import tempfile

import bpy


def make_fixture():
    bpy.ops.object.select_all(action="SELECT")
    bpy.ops.object.delete()
    # PaintCube: base color image, edited in memory, never saved.
    bpy.ops.mesh.primitive_cube_add(size=2.0)
    paint_obj = bpy.context.active_object
    paint_obj.name = "PaintCube"
    color_img = bpy.data.images.new("Cube_BaseColor", width=256, height=256, alpha=True)
    color_img.colorspace_settings.name = "sRGB"
    px = list(color_img.pixels)
    px[0] = 0.25
    color_img.pixels = px
    paint_mat = bpy.data.materials.new("PaintMat")
    paint_mat.use_nodes = True
    tex = paint_mat.node_tree.nodes.new("ShaderNodeTexImage")
    tex.image = color_img
    princ = [n for n in paint_mat.node_tree.nodes if n.bl_idname == "ShaderNodeBsdfPrincipled"][0]
    paint_mat.node_tree.links.new(tex.outputs["Color"], princ.inputs["Base Color"])
    paint_obj.data.materials.append(paint_mat)
    # DataCube: data image, saved to a file, so it is not dirty.
    bpy.ops.mesh.primitive_cube_add(size=1.0, location=(3.0, 0.0, 0.0))
    data_obj = bpy.context.active_object
    data_obj.name = "DataCube"
    data_img = bpy.data.images.new("Cube_Roughness", width=128, height=128)
    data_img.colorspace_settings.name = "Non-Color"
    saved_path = os.path.join(tempfile.gettempdir(), "paint_audit_fixture_roughness.png")
    data_img.filepath_raw = saved_path
    data_img.file_format = "PNG"
    px = list(data_img.pixels)
    px[0] = 0.5
    data_img.pixels = px
    data_img.save()
    data_mat = bpy.data.materials.new("DataMat")
    data_mat.use_nodes = True
    tex2 = data_mat.node_tree.nodes.new("ShaderNodeTexImage")
    tex2.image = data_img
    princ2 = [n for n in data_mat.node_tree.nodes if n.bl_idname == "ShaderNodeBsdfPrincipled"][0]
    data_mat.node_tree.links.new(tex2.outputs["Color"], princ2.inputs["Roughness"])
    data_obj.data.materials.append(data_mat)
    return [paint_obj, data_obj]


def audit():
    print(f"blender_version: {bpy.app.version_string}")
    rows = []
    images = {}
    for obj in sorted(bpy.data.objects, key=lambda o: o.name):
        if obj.type != "MESH":
            continue
        slots = []
        for mat in obj.data.materials:
            if mat is None or not getattr(mat, "node_tree", None):
                continue
            for node in mat.node_tree.nodes:
                if node.bl_idname == "ShaderNodeTexImage" and node.image is not None:
                    slots.append((mat.name, mat.paint_active_slot, node.image))
                    images[node.image.name] = node.image
        if slots:
            rows.append((obj, slots))
    print(f"paint_objects: {len(rows)}")
    total_slots = 0
    for obj, slots in rows:
        uv_names = [layer.name for layer in obj.data.uv_layers] if obj.data.uv_layers else []
        print(f"object: {obj.name}")
        print(f"  uv_layers: {', '.join(uv_names) if uv_names else '-'}")
        for mat_name, active_slot, img in slots:
            total_slots += 1
            w, h = img.size[0], img.size[1]
            filepath = img.filepath if img.filepath else "-"
            print(f"  paint_slot: material={mat_name} active_slot={active_slot} image={img.name} size={w}x{h} colorspace={img.colorspace_settings.name} dirty={img.is_dirty} filepath={filepath}")
    print(f"paint_slots: {total_slots}")
    print(f"images: {len(images)}")
    dirty = sum(1 for i in images.values() if i.is_dirty)
    print(f"images_dirty: {dirty}")
    print(f"images_saved: {len(images) - dirty}")


def main():
    args = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
    if "--make-fixture" in args:
        make_fixture()
    audit()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

Versions

v1.0.02026-10-20 · 0 installs · Ed25519-signed./install.sh blender-texture-painting-basics 1.0.0bundle (.zip)

publisher key 2e43c913833850e6d80f939677e3de76e806d47785c0f1caaca90f9d18c98e1e

Ratings

No ratings yet — be the first to rate it.