8.4 Tools & Pipeline (Python, editor scripting)

Phase 8 · Technical Art · Study time: 30–50 h

Automating art workflows — Python for DCC tools (Maya, Blender), editor scripts, and asset-validation pipelines that keep a big project consistent.

Earlier chapters taught you to write gameplay code: scripts that run inside a shipped game and react to a player pressing a button. This chapter is about a different kind of code — code that never ships inside the game at all, but instead runs inside the software artists use to build the game's content, and inside the engine's editor, to make the people building the game faster and the final assets more consistent. This job is usually called tools programming or technical art, and it leans heavily on one language: Python. By the end of this chapter you will have written a small batch tool, a full asset validation script, and a Unity editor tool in C#, and you will understand why studios with thousands of assets cannot get by without them.

1. What a Tools Programmer Actually Does

A technical artist or tools programmer's job is usually not to make art, and usually not to write gameplay systems either. The job is to watch where artists repeat the same manual, error-prone steps — renaming forty files by hand, clicking through the same export dialog for the hundredth prop, re-checking the same texture size limit every single time — and write a script that does it instead, the same way, every time, without getting tired or distracted on the two-hundredth repetition.

To see where these scripts fit, look at the path one asset takes from an artist's imagination to a running game. This is the art pipeline (the sequence of stages a game asset passes through, from first sketch to appearing on screen in the finished game):

concept art --> 3D model --> UVs + textures --> rig --> animate | v export from the DCC --> import into the game engine --> build --> game running Tools can plug into every arrow above: - a script that exports many assets the same way, every time - a script that checks an asset before it is allowed through - an editor tool that fixes import settings automatically - a build pipeline that only accepts assets that already passed validation

Every arrow in that diagram is a place where a human currently does something by hand, and every one of those places is a candidate for a tool. A small indie project with twenty props can survive on artists just being careful. A HoYoverse-sized project with tens of thousands of assets across characters, weapons, environments, and UI cannot — not because the artists are less careful, but because "be careful" does not scale past a certain number of repetitions, a fact Section 9 comes back to in detail.

Tip A good sign that a manual step deserves a tool: you have explained the same set of steps to a second person. If two different humans need the same instructions, a computer can follow those same instructions faster and without forgetting step four.

2. Python as the Glue Language of DCC Tools

DCC software (Digital Content Creation software — programs like Maya, Blender, Houdini, and Substance Painter that artists use to build models, textures, rigs, and animations) is not one closed black box. Every major DCC tool embeds a Python interpreter inside it, and exposes almost everything the program can do as Python functions. Maya calls its interface module maya.cmds; Blender calls its module bpy; Houdini calls its module hou. The names differ, but the idea is identical: click a button in the UI, and underneath, the DCC is really just calling a Python function on your behalf.

Artist clicks a menu item in Maya or Blender | v The DCC's C/C++ core runs the matching Python command | v The Script Editor panel shows the exact Python that just ran, for example: cmds.rename('pCube1', 'SM_Prop_barrel1') Anything you can click, you can also call directly from a Python script -- that is the whole trick behind automation.

This is why Python is called a glue language here (a language whose job is mainly to connect and drive other systems together, rather than to be the fastest possible language on its own). Python itself is slower than C++ at raw number crunching, but that almost never matters for a tool that runs for a fraction of a second, or a few seconds, over a batch of assets — what matters is that a tools programmer can write it quickly, an artist can read it, and it can reach into every DCC's own API the same way a human reaches into its menus.

Tip Maya's Script Editor has an "Echo All Commands" option that prints the exact Python line for every single thing you click in the UI. Turning it on and clicking around is one of the fastest ways to learn a DCC's API — you do the action once by hand, then read exactly which function call it turned into.

The rest of this chapter builds tools the same way a real pipeline tool is built: as plain Python functions that operate on scene data. Since this lesson does not have Maya or Blender installed, Section 3 builds a small, self-contained stand-in for a DCC scene, written entirely in ordinary Python, so every example below actually runs and prints real output. The exact same logic applies directly to maya.cmds or bpy calls in a real DCC — only the one or two lines that touch the DCC's own API would change.

3. A Mini Scene Toolkit in Python

3.1 A Tiny Stand-in for a DCC Scene

A DCC's "outliner" panel is really just a list of objects that exist in the current file. We model that here as a plain Python list of small objects, so the rest of this section has something real to operate on:


class SceneObject:
    def __init__(self, name, obj_type):
        self.name = name
        self.type = obj_type          # "mesh", "light", "camera"
        self.material = None

    def __repr__(self):
        return f"<{self.type} '{self.name}' material={self.material}>"


# A "scene" is just a list of objects, the same way Maya's or Blender's
# outliner panel lists everything currently in the file.
scene = [
    SceneObject("Prop_barrel1", "mesh"),
    SceneObject("Prop_barrel2", "mesh"),
    SceneObject("Prop_crate",   "mesh"),
    SceneObject("Sun",          "light"),
]

for obj in scene:
    print(obj)

Expected output:


<mesh 'Prop_barrel1' material=None>
<mesh 'Prop_barrel2' material=None>
<mesh 'Prop_crate' material=None>
<light 'Sun' material=None>

Four objects, none of them named consistently, none of them with a material yet. This is a normal, everyday scene an artist might hand off — and exactly the kind of scene a pipeline tool needs to clean up before it moves any further down the pipeline.

3.2 Batch-Rename

Studios use naming conventions (fixed, agreed-upon rules for how files and objects must be named, usually including a prefix that says what kind of asset it is) so that any tool, and any human, can tell what something is just by reading its name. SM_ for a static mesh, T_ for a texture, M_ for a material are common examples. Fixing names by hand across dozens of objects is exactly the repetitive, error-prone task from Section 1:


def batch_rename(objects, old, new):
    """Rename every object whose name contains 'old' to use 'new' instead.
    This is exactly the kind of chore an artist would otherwise repeat by
    hand, one click at a time, in the DCC's outliner."""
    renamed = []
    for obj in objects:
        if old in obj.name:
            obj.name = obj.name.replace(old, new)
            renamed.append(obj.name)
    return renamed


changed = batch_rename(scene, "Prop_", "SM_Prop_")
print("Renamed:", changed)

Expected output:


Renamed: ['SM_Prop_barrel1', 'SM_Prop_barrel2', 'SM_Prop_crate']

Three renames happened in one function call, and the same call would work identically whether the scene held three objects or three thousand. Sun was left untouched because its name never contained "Prop_" — the function only touches what matches the rule.

3.3 Setting Up a Material

Assigning a default material to every mesh in a scene is another one-click-per-object chore in a DCC. As a function, it is just a loop with a condition:


def assign_material(objects, obj_type, material_name):
    """Assign one material to every object of a given type."""
    count = 0
    for obj in objects:
        if obj.type == obj_type:
            obj.material = material_name
            count += 1
    return count


n = assign_material(scene, "mesh", "M_Default")
print(f"Assigned M_Default to {n} mesh objects")
for obj in scene:
    print(" ", obj)

Expected output:


Assigned M_Default to 3 mesh objects
  <mesh 'SM_Prop_barrel1' material=M_Default>
  <mesh 'SM_Prop_barrel2' material=M_Default>
  <mesh 'SM_Prop_crate' material=M_Default>
  <light 'Sun' material=None>

Every mesh now has the same placeholder material, and the light was skipped automatically because its type did not match "mesh". An artist doing this by hand on three hundred meshes would need to select each one, open the material assignment panel, and click assign three hundred times.

3.4 Export Selected

The last step in a DCC session is usually exporting some subset of the scene for the engine to import. A real DCC would write an .fbx or .obj file; here we write JSON instead, so the example needs nothing beyond Python's standard library to run:


import json

def export_selected(objects, names, out_path):
    """Simulate File > Export Selected. A real DCC would write an FBX
    file here; JSON keeps this example runnable with no extra tools."""
    selected = [o for o in objects if o.name in names]
    data = [{"name": o.name, "type": o.type, "material": o.material} for o in selected]
    with open(out_path, "w") as f:
        json.dump(data, f, indent=2)
    return len(data)


exported = export_selected(scene, ["SM_Prop_barrel1", "SM_Prop_crate"], "export.json")
print(f"Exported {exported} objects to export.json")

Expected output:


Exported 2 objects to export.json

And export.json now contains:


[
  {
    "name": "SM_Prop_barrel1",
    "type": "mesh",
    "material": "M_Default"
  },
  {
    "name": "SM_Prop_crate",
    "type": "mesh",
    "material": "M_Default"
  }
]

In a real Maya session, these same three functions would call the DCC's own API instead of touching a plain Python object — cmds.rename(old, new) instead of obj.name = new_name, cmds.select(names) plus cmds.sets(...) to assign a material, and cmds.file(exportSelected=True, type="FBX export") instead of writing JSON. The loops, the naming rules, and the reason the tool exists are identical — only the one or two lines that actually touch the DCC change.

Common mistake Writing a batch tool that only works on the exact objects you tested it on — hardcoding "Prop_barrel1" instead of a pattern like "Prop_". A tool that only handles the three objects you had open when you wrote it is not a pipeline tool, it is a one-off script. Always write the rule ("anything starting with this prefix," "every mesh," "everything currently selected"), never the specific name.

4. Running Tools in Batch (Headless) Mode

So far these functions ran inside a normal Python session, the same way you would run them from a DCC's built-in Script Editor with the program's window open in front of you. Pipeline tools frequently need to run without any window open at all — this is called batch mode or headless mode (running software with no graphical interface, driven entirely by a script or command line).

Maya ships a separate program called mayapy — a standalone Python interpreter with maya.cmds already available, but with no 3D viewport and no window ever drawn. Blender supports the same idea through command-line flags on its normal executable:


mayapy tools/validate_assets.py
blender --background --python tools/export_all.py
Normal use: an artist opens the DCC's window and clicks around Pipeline/batch use: mayapy tools/validate_assets.py blender --background --python tools/export_all.py | v no window ever opens -- a build server or a scheduled job can run this at 3 AM, unattended, across every file in the project

This is what turns a personal script into a real pipeline tool: it can run on a build machine overnight, over every asset file in the whole project, with no human present to click anything. A validation pass (Section 5) over ten thousand files that would take a human a week to check by hand can run headless in minutes.

Tip Headless jobs should open a fresh copy of a file rather than relying on whatever state happens to already be loaded. A script that behaves differently depending on what someone last had open in the DCC will produce different results on different machines — the same problem the warning in Section 8 covers for renaming.

5. Writing an Asset Validation Script

Asset validation means automatically checking every asset against the team's rules before it is allowed to move further down the pipeline, instead of hoping every artist remembers every rule on every file. Four checks show up in almost every real pipeline: a naming convention, a poly count budget (the number of triangles or polygons a mesh is made of — too many, and the game runs slower than the target hardware allows), whether the mesh has UVs (a 2D coordinate on the mesh's surface that tells the engine how to wrap a flat texture image around a 3D shape — missing UVs usually shows up as a stretched or solid-colored mesh in-game), and texture size (textures are almost always required to be a power of two — 256, 512, 1024, 2048 — because that is what GPU hardware and compression formats are built to handle efficiently).


ASSET_RULES = {
    "max_poly_count": 5000,
    "valid_texture_sizes": {256, 512, 1024, 2048},
    "required_prefix": "SM_",
}


def validate_asset(asset):
    """Check one asset dict against the pipeline's rules.
    Returns a list of problem strings; an empty list means it passed."""
    problems = []

    if not asset["name"].startswith(ASSET_RULES["required_prefix"]):
        problems.append(f"name must start with '{ASSET_RULES['required_prefix']}'")

    if asset["poly_count"] > ASSET_RULES["max_poly_count"]:
        problems.append(
            f"poly count {asset['poly_count']} exceeds budget "
            f"{ASSET_RULES['max_poly_count']}"
        )

    if not asset["has_uvs"]:
        problems.append("missing UVs")

    for size in asset["texture_sizes"]:
        if size not in ASSET_RULES["valid_texture_sizes"]:
            problems.append(f"texture size {size} is not a valid power-of-two size")

    return problems


def validate_all(assets):
    total_problems = 0
    for asset in assets:
        problems = validate_asset(asset)
        if problems:
            total_problems += len(problems)
            print(f"FAIL {asset['name']}:")
            for p in problems:
                print("   -", p)
        else:
            print(f"PASS {asset['name']}")
    return total_problems


assets = [
    {"name": "SM_barrel",  "poly_count": 1200, "has_uvs": True,  "texture_sizes": [512, 512]},
    {"name": "barrel_old", "poly_count": 8000, "has_uvs": False, "texture_sizes": [500, 1024]},
    {"name": "SM_crate",   "poly_count": 300,  "has_uvs": True,  "texture_sizes": [1024]},
]

failures = validate_all(assets)
print(f"\n{failures} problem(s) found across {len(assets)} asset(s)")

Expected output:


PASS SM_barrel
FAIL barrel_old:
   - name must start with 'SM_'
   - poly count 8000 exceeds budget 5000
   - missing UVs
   - texture size 500 is not a valid power-of-two size
PASS SM_crate

4 problem(s) found across 3 asset(s)

barrel_old fails every single rule at once, and validate_asset reports all four problems in one pass instead of stopping at the first one — an artist fixing the asset gets the complete list immediately, instead of fixing one problem, re-running the tool, and discovering the next problem five minutes later.

Common mistake Burying rule numbers like 5000 and 2048 deep inside conditional logic, scattered across the script. Real pipelines keep rules like ASSET_RULES in one place — often loaded from a separate JSON or YAML config file — specifically so an art lead can raise a poly budget for one project without a programmer editing and re-testing the validation code itself.

6. Hooking Validation Into the Pipeline

A validation script an artist has to remember to run by hand is barely better than a checklist — it still depends on a human remembering. A hook (a piece of code the pipeline calls automatically at a specific moment, without anyone having to remember to run it) removes that dependency entirely.

artist tries to submit new assets to version control | v pre-submit HOOK runs automatically | +-------+-------+ | | validation PASSES validation FAILS | | v v submit is allowed submit is blocked, artist sees the exact problem list

Version control systems (Git, Perforce, Plastic SCM) all support this idea under names like a pre-commit hook or a pre-submit trigger: a script the system runs automatically right before it accepts new files, with the power to reject the submission entirely if the script reports a failure.


#!/bin/sh
# .git/hooks/pre-commit  (the same idea works as a Perforce or
# Plastic SCM pre-submit trigger, just configured differently)

python3 tools/validate_assets.py --changed-files
if [ $? -ne 0 ]; then
    echo "Asset validation failed - commit blocked"
    exit 1
fi

The script itself is almost exactly validate_all from Section 5, just wired to exit with a non-zero status when problems are found — the shell hook checks that status and refuses the submit if it is nonzero. Nobody needs to remember to run this. It runs every time, on every submission, whether the artist remembers the rules or not.

Tip Catching a bad asset in a hook costs seconds — the artist sees the exact failure and fixes it before it ever leaves their machine. Catching the same bad asset two weeks later, because it made the game crash on one specific device, can cost a QA engineer and a programmer an entire afternoon of tracing the crash back to one missing UV set.

7. A Unity Editor Tool in C#: Fixing Import Settings on Many Assets

Pipeline tools are not only Python running outside the engine. Every major engine also exposes its own scripting API for building tools that live inside its editor, and for Unity that is editor scripting in C# — the same language you already use for gameplay, but running in the UnityEditor namespace, which only exists in the editor and is stripped out of the final built game.

A common tools task: an artist imported forty textures over several months, each with slightly different import settings, because Unity's defaults changed or different people set them by hand. A [MenuItem] (an attribute that adds a custom command to Unity's menu bar) can fix all of them in one click:


using UnityEditor;
using UnityEngine;

public class FixTextureImportSettings
{
    [MenuItem("Tools/Pipeline/Fix Texture Import Settings")]
    static void FixSelectedTextures()
    {
        int fixedCount = 0;

        foreach (Object obj in Selection.objects)
        {
            string path = AssetDatabase.GetAssetPath(obj);
            TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter;

            if (importer == null) continue; // not a texture, skip it

            importer.maxTextureSize = 2048;
            importer.mipmapEnabled = true;
            importer.textureCompression = TextureImporterCompression.Compressed;

            importer.SaveAndReimport();
            fixedCount++;
        }

        Debug.Log("Fixed import settings on " + fixedCount + " textures");
    }
}

Expected result: select forty texture assets in the Project window, choose Tools > Pipeline > Fix Texture Import Settings from the menu, and the Console prints Fixed import settings on 40 textures. Every one of those forty textures now has the identical max size, mip map setting, and compression — instead of whatever each artist happened to leave it at.

Selection.objects reads whatever is currently selected in the editor, AssetDatabase.GetAssetPath turns that selection into a file path, and AssetImporter.GetAtPath gets the importer object that controls how Unity reads that file from disk. SaveAndReimport() writes the changed settings back and makes Unity re-process the file with them — without that call, the changes only exist in memory and are thrown away.

Common mistake Forgetting importer.SaveAndReimport(). The script still runs, the loop still executes, and Debug.Log still prints a success message — but nothing on disk actually changes, because TextureImporter is a description of the import settings that you are editing in memory, not the texture itself. Without saving it back, every change is silently discarded the moment the function returns.

This particular tool fixes assets that already exist. A different kind of Unity tool prevents the problem from happening again on every asset imported from now on, using an AssetPostprocessor — the same idea as the pre-submit hook from Section 6, just triggered by the engine's own import step instead of version control:


using UnityEditor;

public class TextureImportHook : AssetPostprocessor
{
    // Unity calls this automatically for every texture, during import,
    // before the artist ever opens a menu.
    void OnPreprocessTexture()
    {
        TextureImporter importer = (TextureImporter)assetImporter;
        importer.maxTextureSize = 2048;
        importer.mipmapEnabled = true;
    }
}
a .png file is dropped into Unity's Assets folder | v Unity starts importing the texture | v OnPreprocessTexture() runs automatically [HOOK] (fixes max size and mip maps before import finishes) | v import finishes with correct settings already applied, no menu click required, no artist decision needed

The menu command from earlier fixes what is already broken; the AssetPostprocessor hook stops new imports from ever being broken in the first place. A mature pipeline usually has both: the hook so most assets never need fixing, and the menu command as a cleanup tool for anything imported before the hook existed.

8. Batch Processing Patterns: Dry Runs, Logging, and Idempotency

Running a batch tool on three objects in a test scene is low-risk. Running the same tool on five thousand real production assets is not — a bug in the tool now touches five thousand files instead of three. Three habits keep batch tools safe at that scale.

First, a dry run (a mode where the tool reports exactly what it would change, without actually changing anything) lets you preview the damage before committing to it:


def batch_rename(objects, old, new, dry_run=False):
    renamed = []
    for obj in objects:
        if old in obj.name:
            new_name = obj.name.replace(old, new)
            if dry_run:
                print(f"[DRY RUN] would rename {obj.name} -> {new_name}")
            else:
                obj.name = new_name
                renamed.append(new_name)
    return renamed


batch_rename(scene, "SM_Prop_", "SM_", dry_run=True)

Expected output:


[DRY RUN] would rename SM_Prop_barrel1 -> SM_barrel1
[DRY RUN] would rename SM_Prop_barrel2 -> SM_barrel2
[DRY RUN] would rename SM_Prop_crate -> SM_crate

Nothing in scene actually changed — the function only printed what it would have done. A lead can read that list, confirm it looks right, and then run the exact same call with dry_run=False to apply it for real.

Second, real pipeline tools log instead of only printing. A batch job running unattended overnight on a build server needs to leave a record a human can read the next morning, not a stream of text that vanished with the closed terminal window:


import logging

logging.basicConfig(filename="pipeline.log", level=logging.INFO,
                     format="%(asctime)s %(levelname)s %(message)s")

logging.info("Validation run started")
logging.warning("barrel_old failed 4 checks")
logging.info("Validation run finished: 4 problems in 3 assets")

Python's built-in logging module writes each line to pipeline.log with a timestamp and a severity level, at almost no extra cost over a plain print() — worth reaching for the moment a script stops being something you only run by hand while watching it.

Third, a batch tool should be idempotent (running it more than once on the same input produces the same result as running it once, instead of piling up extra changes each time).

Common mistake The batch_rename function from Section 3.2 checks old in obj.name — a substring check, not "starts with this exact prefix." Running batch_rename(scene, "Prop_", "SM_Prop_") once correctly turns Prop_barrel1 into SM_Prop_barrel1. Running that exact same call a second time finds "Prop_" still sitting inside SM_Prop_barrel1 and renames it again, producing SM_SM_Prop_barrel1. That is not idempotent, and an accidentally-run-twice job is exactly the kind of thing that happens on an unattended pipeline. The fix is to check the end state before acting: if not obj.name.startswith("SM_Prop_") before renaming, so a second run finds nothing left to do.

validate_all from Section 5, by contrast, is naturally idempotent — it only reads asset data and reports problems, so running it ten times in a row on unchanged files prints the exact same result every time. Prefer tools shaped like that (read and report) wherever you can, and be deliberately careful with tools that mutate data in place.

9. Why Consistency at Scale Needs Tools, Not Discipline

Say every artist on a team makes a naming or settings mistake on roughly one out of every two hundred assets they touch — a genuinely good rate for a careful human doing repetitive manual work. On a project with five thousand assets, that is about twenty-five assets slipping through with a problem, every single pass through the pipeline. Multiply that by however many times assets get re-exported and re-imported over the course of a production — often dozens — and "an occasional small mistake" becomes a constant background hum of expensive bugs: a texture stretched because of a missing UV, a prop invisible in-game because of one mistyped material name, a build that is four hundred megabytes larger than it needs to be because nobody happened to notice an 8192-pixel texture that should have been 1024.

A validation script (Section 5) that runs automatically through a hook (Section 6) catches every one of those twenty-five assets in seconds, every single time, with exactly zero variation in how "carefully" it happens to be paying attention that day. That is the entire argument for tools over discipline: discipline degrades under deadline pressure, under fatigue, and whenever a new team member joins who has never read the style guide. A script does not have bad days.

Common mistake Responding to a broken asset with "we'll just be more careful next time." A useful rule of thumb: the first time a rule gets broken, remind people. The second time the exact same rule gets broken, write a script that enforces it, because two independent people already proved a reminder is not enough.

This is not an argument that artists are careless — it is an argument about arithmetic. A person checking one asset against four rules is extremely reliable. The same person checking the ten-thousandth asset against the same four rules, at the end of a long day, near a deadline, is not — not because they got worse at their job, but because "stay perfectly consistent across ten thousand repetitions" is a task no human is built for, and a script is.

10. Putting It Together: Tools Plugged Into the Pipeline

Every piece from this chapter fits into one continuous path from an artist's first click to a finished build:

Artist works in Maya/Blender | (Python tool: batch rename, assign material, export selected) v Exported asset files (FBX, PNG, ...) | (pre-submit hook runs automatically) v validate_assets.py checks naming, poly count, UVs, texture size | PASS v Asset lands in the Unity project | (AssetPostprocessor hook runs automatically, on import) v Import settings are already correct, no manual fixing needed | (Tools > Fix Import Settings, for older assets from before the hook) v Build pipeline picks up clean, consistent assets v Game build

Notice that nothing in this chain depends on an artist remembering a rule. The Python tool in the DCC (Section 3) removes the repetitive manual steps. The validation script (Section 5) defines the rules once, in code, instead of in a document nobody rereads. The hooks (Sections 6 and 7) make sure the validation and the fixes actually run, on every asset, without anyone choosing to run them. The dry-run and logging habits (Section 8) make the tools themselves safe to trust at scale. None of this replaces artists — it removes the parts of their job that were never really about art in the first place, so the hours they do spend are spent on the model, the texture, the animation, not on remembering whether this week's texture size limit is 1024 or 2048.

11. Glossary

12. Exercises

Exercise 1 — Add a Validation Rule Extend validate_asset from Section 5 with a new rule: an asset's name must not contain a space character (spaces in filenames break some build systems and command-line tools). Add the check to ASSET_RULES-driven validate_asset, then run it against this asset list and show the output:

assets = [
    {"name": "SM_barrel", "poly_count": 1200, "has_uvs": True, "texture_sizes": [512]},
    {"name": "SM_old crate", "poly_count": 900, "has_uvs": True, "texture_sizes": [512]},
]
Show answer

def validate_asset(asset):
    problems = []

    if not asset["name"].startswith(ASSET_RULES["required_prefix"]):
        problems.append(f"name must start with '{ASSET_RULES['required_prefix']}'")

    if " " in asset["name"]:
        problems.append("name must not contain spaces")

    if asset["poly_count"] > ASSET_RULES["max_poly_count"]:
        problems.append(
            f"poly count {asset['poly_count']} exceeds budget "
            f"{ASSET_RULES['max_poly_count']}"
        )

    if not asset["has_uvs"]:
        problems.append("missing UVs")

    for size in asset["texture_sizes"]:
        if size not in ASSET_RULES["valid_texture_sizes"]:
            problems.append(f"texture size {size} is not a valid power-of-two size")

    return problems


assets = [
    {"name": "SM_barrel", "poly_count": 1200, "has_uvs": True, "texture_sizes": [512]},
    {"name": "SM_old crate", "poly_count": 900, "has_uvs": True, "texture_sizes": [512]},
]

validate_all(assets)

PASS SM_barrel
FAIL SM_old crate:
   - name must not contain spaces

Only one new if block was needed, following the exact same shape as every other check: test one condition, append one message if it fails. SM_old crate already had the right prefix and valid poly count, UVs, and texture size, so the space check is the only thing that fails it.

Exercise 2 — Add a Dry Run to assign_material The assign_material function from Section 3.3 changes objects immediately, with no way to preview the change first. Following the same pattern as the dry_run parameter added to batch_rename in Section 8, add a dry_run parameter to assign_material so a lead can see which objects would be affected before committing to the change.
Show answer

def assign_material(objects, obj_type, material_name, dry_run=False):
    count = 0
    for obj in objects:
        if obj.type == obj_type:
            if dry_run:
                print(f"[DRY RUN] would assign {material_name} to {obj.name}")
            else:
                obj.material = material_name
            count += 1
    return count


assign_material(scene, "mesh", "M_Rock", dry_run=True)

[DRY RUN] would assign M_Rock to SM_Prop_barrel1
[DRY RUN] would assign M_Rock to SM_Prop_barrel2
[DRY RUN] would assign M_Rock to SM_Prop_crate

The condition that decides which objects are affected (obj.type == obj_type) stays exactly the same — only the action taken once an object matches changes, based on dry_run. count is still incremented either way, so the caller always knows how many objects were (or would be) affected, whether or not anything was actually changed.

Exercise 3 — Find the Missing Line A junior tools programmer wrote this Unity editor script. It runs with no errors, the Console prints Done fixing textures, but an artist reports that after running it, the selected textures still look exactly the same as before — nothing actually changed. Read the code, explain what is missing and why the settings change has no visible effect without it, then write the corrected version.

using UnityEditor;
using UnityEngine;

public class FixTextureImportSettings
{
    [MenuItem("Tools/Pipeline/Fix Texture Import Settings")]
    static void FixSelectedTextures()
    {
        foreach (Object obj in Selection.objects)
        {
            string path = AssetDatabase.GetAssetPath(obj);
            TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter;

            if (importer == null) continue;

            importer.maxTextureSize = 2048;
            importer.mipmapEnabled = true;
        }

        Debug.Log("Done fixing textures");
    }
}
Show answer

The script is missing a call to importer.SaveAndReimport(). TextureImporter is a description of a texture's import settings that Unity loads into memory so you can edit it — setting importer.maxTextureSize only changes that in-memory copy. Nothing writes those changes back to the actual .meta file on disk, and nothing tells Unity to re-import the texture with the new settings, so the texture asset itself never changes. The loop runs, the fields get set, and Debug.Log still executes and prints its message regardless — which is exactly why the bug is easy to miss: there is no error, just silently discarded work.


using UnityEditor;
using UnityEngine;

public class FixTextureImportSettings
{
    [MenuItem("Tools/Pipeline/Fix Texture Import Settings")]
    static void FixSelectedTextures()
    {
        int fixedCount = 0;

        foreach (Object obj in Selection.objects)
        {
            string path = AssetDatabase.GetAssetPath(obj);
            TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter;

            if (importer == null) continue;

            importer.maxTextureSize = 2048;
            importer.mipmapEnabled = true;

            importer.SaveAndReimport(); // writes the settings back and re-imports
            fixedCount++;
        }

        Debug.Log("Fixed import settings on " + fixedCount + " textures");
    }
}

Adding SaveAndReimport() (and, as a small bonus fix, an actual fixedCount so the log message reflects real work done) makes the tool write its changes back to disk and re-import each texture immediately, so the artist sees the new settings take effect right away instead of nothing happening at all.

← Back to all chapters