บทที่แล้ว ๆ สอนให้เขียน gameplay code — สคริปต์ที่รันอยู่ในเกมที่ปล่อยจริงและตอบสนองตอนผู้เล่นกดปุ่ม บทนี้พูดถึงโค้ดอีกแบบหนึ่ง ที่ไม่เคยไปอยู่ในตัวเกมเลย แต่รันอยู่ใน software ที่ artist ใช้สร้างเนื้อหาของเกม และรันอยู่ใน editor ของเอนจิน เพื่อให้คนที่สร้างเกมทำงานได้เร็วขึ้น และ asset สุดท้ายมีความสม่ำเสมอมากขึ้น งานแบบนี้มักเรียกว่า tools programming หรือ technical art และมันพึ่งพาภาษาเดียวเป็นหลัก คือ Python จบบทนี้แล้ว คุณจะเขียน batch tool เล็ก ๆ, สคริปต์ validate asset แบบเต็ม ๆ, และ Unity editor tool ด้วย C# มาแล้ว และจะเข้าใจว่าทำไมสตูดิโอที่มี asset เป็นพัน ๆ ถึงอยู่ไม่ได้ถ้าไม่มีเครื่องมือพวกนี้
งานของ technical artist หรือ tools programmer ปกติแล้วไม่ใช่การทำ art และปกติก็ไม่ใช่การเขียนระบบ gameplay ด้วย งานคือคอยสังเกตว่า artist ทำขั้นตอนซ้ำ ๆ แบบเดิม ที่ทำด้วยมือแล้วผิดพลาดง่ายตรงไหนบ้าง — rename ไฟล์สี่สิบไฟล์ด้วยมือ, คลิกผ่าน export dialog เดิม ๆ ซ้ำเป็นครั้งที่ร้อยสำหรับ prop, เช็ก texture size limit เดิมซ้ำทุกครั้ง — แล้วเขียนสคริปต์มาทำแทน ให้มันทำแบบเดิมทุกครั้ง โดยไม่เหนื่อยหรือหลุด focus ตอนทำครั้งที่สองร้อย
เพื่อดูว่าสคริปต์พวกนี้เข้าไปอยู่ตรงไหน ลองดูเส้นทางที่ asset หนึ่งชิ้นเดินทางจากจินตนาการของ artist ไปจนถึงเกมที่รันจริง นี่คือ art pipeline (ลำดับขั้นตอนที่ asset ของเกมผ่าน ตั้งแต่ sketch แรกจนไปโผล่บนหน้าจอในเกมที่เสร็จสมบูรณ์):
ทุกลูกศรใน diagram นี้คือจุดที่ตอนนี้มีคนทำด้วยมืออยู่ และทุกจุดคือตัวเลือกที่จะทำเป็นเครื่องมือได้ โปรเจกต์ indie เล็ก ๆ ที่มี prop ยี่สิบชิ้นอยู่ได้ด้วยการให้ artist ระวังเอาเอง แต่โปรเจกต์ขนาด HoYoverse ที่มี asset เป็นหมื่น ๆ ทั้งตัวละคร, อาวุธ, environment, UI อยู่แบบนั้นไม่ได้ — ไม่ใช่เพราะ artist ไม่ระวัง แต่เพราะ "ระวังให้ดี" มันขยายสเกลไปเกินจำนวนการทำซ้ำจุดหนึ่งไม่ได้ ซึ่ง Section 9 จะพูดถึงเรื่องนี้แบบละเอียดอีกที
DCC software (Digital Content Creation software — โปรแกรมอย่าง Maya, Blender, Houdini, Substance Painter ที่ artist ใช้สร้าง model, texture, rig, animation) ไม่ใช่กล่องดำปิดตายกล่องเดียว DCC หลัก ๆ ทุกตัวฝัง Python interpreter ไว้ข้างในตัวเอง และเปิดให้เกือบทุกอย่างที่โปรแกรมทำได้ ถูกเรียกผ่าน Python function ได้ Maya เรียก module ของตัวเองว่า maya.cmds, Blender เรียกว่า bpy, Houdini เรียกว่า hou ชื่อต่างกัน แต่ไอเดียเหมือนกันเป๊ะ: คลิกปุ่มใน UI แล้วข้างใต้นั้น DCC ก็แค่เรียก Python function แทนคุณอยู่ดี
นี่คือเหตุผลที่เราเรียก Python ว่าเป็น glue language (ภาษากาว — ภาษาที่หน้าที่หลักคือเชื่อมและขับเคลื่อนระบบอื่น ๆ เข้าด้วยกัน มากกว่าจะเป็นภาษาที่เร็วที่สุดในตัวเอง) Python เองช้ากว่า C++ เวลาต้องคำนวณตัวเลขดิบ ๆ แต่นั่นแทบไม่สำคัญเลยสำหรับเครื่องมือที่รันแค่เสี้ยววินาที หรือไม่กี่วินาที ผ่าน asset เป็น batch — สิ่งที่สำคัญคือ tools programmer เขียนมันได้เร็ว, artist อ่านมันเข้าใจ, และมันเข้าถึง API ของแต่ละ DCC ได้แบบเดียวกับที่มนุษย์เข้าถึงเมนูของมัน
ที่เหลือของบทนี้จะสร้างเครื่องมือแบบเดียวกับที่ pipeline tool จริงถูกสร้างขึ้นมา — เป็น Python function ธรรมดาที่ทำงานกับ scene data บทเรียนนี้ไม่มี Maya หรือ Blender ติดตั้งอยู่ Section 3 จึงสร้างตัวแทน scene ของ DCC ขนาดเล็ก ๆ ที่จบในตัวเอง เขียนด้วย Python ธรรมดาล้วน ๆ เพื่อให้ตัวอย่างข้างล่างนี้รันได้จริงและ print output จริงออกมา logic เดียวกันเป๊ะนำไปใช้กับการเรียก maya.cmds หรือ bpy ใน DCC จริงได้ตรง ๆ — มีแค่หนึ่งหรือสองบรรทัดที่แตะ API ของ DCC เองเท่านั้นที่จะเปลี่ยน
panel "outliner" ของ DCC จริง ๆ ก็แค่ list ของ object ที่มีอยู่ในไฟล์ปัจจุบัน เราจำลองมันตรงนี้เป็น Python list ของ object เล็ก ๆ เพื่อให้ที่เหลือของ section นี้มีของจริงให้ทำงานด้วย:
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)
Output ที่ควรได้:
<mesh 'Prop_barrel1' material=None>
<mesh 'Prop_barrel2' material=None>
<mesh 'Prop_crate' material=None>
<light 'Sun' material=None>
สี่ object ชื่อไม่สม่ำเสมอเลยสักตัว ยังไม่มี material เลย นี่คือ scene ปกติทั่วไปที่ artist อาจส่งต่อมาให้ — และเป็น scene แบบที่ pipeline tool ต้องมาเก็บกวาดก่อนที่มันจะไปต่อใน pipeline ได้
สตูดิโอใช้ naming convention (กฎการตั้งชื่อไฟล์และ object ที่ตกลงกันไว้แน่นอน ปกติจะมี prefix บอกว่า asset นี้เป็นประเภทไหน) เพื่อให้เครื่องมือไหน ๆ หรือคนไหน ๆ ก็รู้ว่าของชิ้นนั้นคืออะไร แค่อ่านชื่อมันก็พอ SM_ สำหรับ static mesh, T_ สำหรับ texture, M_ สำหรับ material คือตัวอย่างที่พบบ่อย การแก้ชื่อด้วยมือทีละหลายสิบ object คือ task ซ้ำ ๆ ที่ผิดพลาดง่ายแบบเดียวกับใน 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)
Output ที่ควรได้:
Renamed: ['SM_Prop_barrel1', 'SM_Prop_barrel2', 'SM_Prop_crate']
สาม rename เกิดขึ้นใน function call เดียว และ call เดียวกันนี้จะทำงานเหมือนกันเป๊ะไม่ว่า scene จะมี object สามชิ้นหรือสามพันชิ้น Sun ไม่ถูกแตะเลยเพราะชื่อมันไม่มี "Prop_" อยู่ — function แตะแค่สิ่งที่ตรงกับกฎเท่านั้น
การ assign material เริ่มต้นให้ mesh ทุกตัวใน scene ก็เป็นอีก task ที่ต้องคลิกทีละ object ใน DCC เขียนเป็น function ก็แค่ loop กับเงื่อนไข:
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)
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>
mesh ทุกตัวตอนนี้มี material placeholder เดียวกันแล้ว และ light ถูกข้ามไปอัตโนมัติเพราะ type ของมันไม่ตรงกับ "mesh" ถ้า artist ทำเองด้วยมือกับ mesh สามร้อยตัว ต้อง select ทีละตัว เปิด panel assign material แล้วคลิก assign สามร้อยครั้ง
ขั้นตอนสุดท้ายของ session ใน DCC ปกติคือ export บางส่วนของ scene ออกไปให้ engine import DCC จริงจะเขียนไฟล์ .fbx หรือ .obj ที่นี่เราเขียนเป็น JSON แทน เพื่อให้ตัวอย่างนี้รันได้โดยไม่ต้องใช้อะไรนอกเหนือจาก standard library ของ Python:
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")
Output ที่ควรได้:
Exported 2 objects to export.json
และ export.json ตอนนี้มีเนื้อหาว่า:
[
{
"name": "SM_Prop_barrel1",
"type": "mesh",
"material": "M_Default"
},
{
"name": "SM_Prop_crate",
"type": "mesh",
"material": "M_Default"
}
]
ใน session Maya จริง ๆ function สามตัวนี้เหมือนกันเป๊ะแต่จะเรียก API ของ DCC เองแทนการแตะ Python object ธรรมดา — cmds.rename(old, new) แทน obj.name = new_name, cmds.select(names) บวก cmds.sets(...) เพื่อ assign material, และ cmds.file(exportSelected=True, type="FBX export") แทนการเขียน JSON loop, กฎการตั้งชื่อ, และเหตุผลที่ tool มีอยู่เหมือนกันเป๊ะ — มีแค่หนึ่งหรือสองบรรทัดที่แตะ DCC จริง ๆ เท่านั้นที่เปลี่ยน
"Prop_barrel1" แทนที่จะใช้ pattern อย่าง "Prop_" tool ที่จัดการได้แค่สาม object ที่คุณเปิดอยู่ตอนเขียนโค้ด ไม่ใช่ pipeline tool มันคือ script แบบใช้ครั้งเดียว เขียนกฎเสมอ ("อะไรก็ตามที่ขึ้นต้นด้วย prefix นี้", "mesh ทุกตัว", "ทุกอย่างที่ select อยู่ตอนนี้") อย่าเขียนชื่อเฉพาะเจาะจงจนถึงตอนนี้ function พวกนี้รันอยู่ใน Python session ปกติ แบบเดียวกับที่คุณรันมันจาก Script Editor ที่ฝังอยู่ใน DCC โดยมีหน้าต่างโปรแกรมเปิดอยู่ตรงหน้า pipeline tool มักต้องรันโดยไม่มีหน้าต่างเปิดเลย เรียกว่า batch mode หรือ headless mode (รัน software โดยไม่มี graphical interface เลย ขับเคลื่อนด้วย script หรือ command line ล้วน ๆ)
Maya มีโปรแกรมแยกต่างหากชื่อ mayapy — Python interpreter แบบ standalone ที่มี maya.cmds พร้อมใช้อยู่แล้ว แต่ไม่มี 3D viewport และไม่มีหน้าต่างวาดขึ้นมาเลย Blender รองรับไอเดียเดียวกันผ่าน command-line flag บน executable ปกติของมัน:
mayapy tools/validate_assets.py
blender --background --python tools/export_all.py
นี่คือสิ่งที่เปลี่ยน script ส่วนตัวให้กลายเป็น pipeline tool จริง ๆ: มันรันบนเครื่อง build ได้ทั้งคืน ผ่าน asset file ทั้งโปรเจกต์ โดยไม่มีมนุษย์คนไหนต้องมาคลิกอะไรเลย validation pass (Section 5) ผ่านไฟล์หนึ่งหมื่นไฟล์ที่คนต้องใช้เวลาเช็กด้วยมือทั้งอาทิตย์ รันแบบ headless เสร็จได้ในไม่กี่นาที
Asset validation คือการเช็ก asset ทุกชิ้นกับกฎของทีมโดยอัตโนมัติ ก่อนปล่อยให้มันไปต่อใน pipeline แทนที่จะหวังว่า artist ทุกคนจะจำกฎทุกข้อได้กับทุกไฟล์ สี่การเช็กที่เจอในเกือบทุก pipeline จริง คือ naming convention, poly count budget (จำนวน triangle หรือ polygon ที่ mesh ประกอบขึ้นมา — เยอะเกินไป เกมก็รันช้ากว่าที่ target hardware รับไหว), mesh มี UV หรือเปล่า (พิกัด 2D บนผิวของ mesh ที่บอก engine ว่าจะห่อ texture ภาพแบนเข้ากับรูปทรง 3D ยังไง — UV หายมักโผล่เป็น mesh ที่ยืดเบี้ยวหรือเป็นสีทึบในเกม), และขนาด texture (texture เกือบทุกครั้งต้องเป็น power of two — 256, 512, 1024, 2048 — เพราะนี่คือขนาดที่ GPU hardware กับ compression format ถูกออกแบบมาให้จัดการได้มีประสิทธิภาพที่สุด)
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)")
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 พลาดทุกกฎพร้อมกันหมด และ validate_asset รายงานปัญหาทั้งสี่ในรอบเดียว แทนที่จะหยุดที่ปัญหาแรก artist ที่มาแก้ asset ตัวนี้ได้ list ปัญหาครบทันที แทนที่จะแก้ปัญหาหนึ่ง รัน tool ใหม่ แล้วเจอปัญหาถัดไปอีกห้านาทีให้หลัง
5000 กับ 2048 ลึกเข้าไปใน conditional logic กระจัดกระจายทั่วสคริปต์ pipeline จริงเก็บกฎอย่าง ASSET_RULES ไว้ที่เดียว — บ่อยครั้งโหลดมาจากไฟล์ config JSON หรือ YAML แยกต่างหาก — โดยเฉพาะเพื่อให้ art lead ปรับ poly budget ของโปรเจกต์หนึ่งได้เอง โดยไม่ต้องให้ programmer มาแก้และ test โค้ด validation ใหม่validation script ที่ artist ต้องจำเองว่าต้องรันด้วยมือ ก็แทบไม่ต่างจาก checklist — ยังพึ่งพาให้มนุษย์จำได้อยู่ดี hook (โค้ดชิ้นหนึ่งที่ pipeline เรียกอัตโนมัติในจังหวะที่กำหนดไว้ โดยไม่ต้องมีใครจำว่าต้องรันมัน) เอาการพึ่งพานั้นออกไปทั้งหมด
ระบบ version control (Git, Perforce, Plastic SCM) ทุกตัวรองรับไอเดียนี้ ภายใต้ชื่ออย่าง pre-commit hook หรือ pre-submit trigger: script ที่ระบบรันอัตโนมัติก่อนรับไฟล์ใหม่ โดยมีอำนาจปฏิเสธการ submit ทั้งหมดได้ ถ้า script รายงานว่าล้มเหลว
#!/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
ตัว script เองก็แทบจะเหมือน validate_all จาก Section 5 เป๊ะ แค่ต่อสายให้ exit ด้วย status ที่ไม่ใช่ศูนย์เมื่อเจอปัญหา — shell hook เช็ก status นั้นแล้วปฏิเสธการ submit ถ้ามันไม่ใช่ศูนย์ ไม่มีใครต้องจำว่าต้องรันอันนี้เลย มันรันทุกครั้ง ทุกการ submit ไม่ว่า artist จะจำกฎได้หรือไม่ก็ตาม
pipeline tool ไม่ได้มีแค่ Python ที่รันนอกเอนจินเท่านั้น เอนจินหลัก ๆ ทุกตัวก็เปิด scripting API ของตัวเองสำหรับสร้างเครื่องมือที่อยู่ใน editor ด้วยเหมือนกัน และสำหรับ Unity นั่นคือ editor scripting ด้วย C# — ภาษาเดียวกับที่คุณใช้เขียน gameplay อยู่แล้ว แค่รันอยู่ใน namespace UnityEditor ซึ่งมีอยู่แค่ใน editor เท่านั้น และถูกตัดออกจากตัวเกมที่ build จริง
งาน tools ที่เจอบ่อย: artist import texture สี่สิบไฟล์มาตลอดหลายเดือน แต่ละไฟล์มี import setting ต่างกันนิดหน่อย เพราะค่า default ของ Unity เปลี่ยนไป หรือคนละคนตั้งเองด้วยมือ [MenuItem] (attribute ที่เพิ่มคำสั่งกำหนดเองเข้าไปใน menu bar ของ Unity) แก้ทั้งสี่สิบไฟล์ได้ในคลิกเดียว:
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");
}
}
ผลลัพธ์ที่ควรได้: select texture asset สี่สิบชิ้นใน Project window เลือก Tools > Pipeline > Fix Texture Import Settings จากเมนู แล้ว Console จะพิมพ์ Fixed import settings on 40 textures ทุกตัวในสี่สิบ texture นี้ตอนนี้มี max size, mip map setting, และ compression เหมือนกันเป๊ะ — แทนที่จะเป็นอะไรก็ตามที่ artist แต่ละคนบังเอิญตั้งค้างไว้
Selection.objects อ่านสิ่งที่ select อยู่ใน editor ตอนนี้ AssetDatabase.GetAssetPath เปลี่ยน selection นั้นให้เป็น file path และ AssetImporter.GetAtPath ได้ importer object ที่คุมว่า Unity อ่านไฟล์นั้นจาก disk ยังไง SaveAndReimport() เขียน setting ที่เปลี่ยนกลับลงไปและสั่งให้ Unity process ไฟล์ใหม่ด้วย setting นั้น — ถ้าไม่มี call นี้ การเปลี่ยนแปลงจะอยู่แค่ใน memory แล้วถูกทิ้งไปเฉย ๆ
importer.SaveAndReimport() script ยังรันอยู่ loop ยังทำงานอยู่ และ Debug.Log ก็ยังพิมพ์ข้อความสำเร็จออกมา — แต่ไม่มีอะไรบน disk เปลี่ยนแปลงจริง เพราะ TextureImporter คือคำอธิบาย import setting ที่คุณกำลังแก้อยู่ใน memory ไม่ใช่ตัว texture เอง ถ้าไม่ save กลับไป การเปลี่ยนแปลงทุกอย่างจะถูกทิ้งไปเงียบ ๆ ทันทีที่ function จบtool นี้แก้ asset ที่มีอยู่แล้วเท่านั้น Unity tool อีกแบบป้องกันไม่ให้ปัญหานี้เกิดซ้ำกับทุก asset ที่ import เข้ามาตั้งแต่นี้ไป โดยใช้ AssetPostprocessor — ไอเดียเดียวกับ pre-submit hook จาก Section 6 เลย แค่ถูก trigger โดยขั้นตอน import ของ engine เองแทนที่จะเป็น 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;
}
}
menu command ก่อนหน้านี้แก้สิ่งที่พังไปแล้ว ส่วน hook AssetPostprocessor หยุดไม่ให้ import ใหม่พังตั้งแต่แรก pipeline ที่เติบโตแล้วมักมีทั้งสองอย่าง: hook เพื่อให้ asset ส่วนใหญ่ไม่ต้องมาแก้เลย และ menu command เป็นเครื่องมือ cleanup สำหรับอะไรก็ตามที่ import มาก่อนที่ hook จะมีอยู่
การรัน batch tool กับ object สามตัวใน test scene มีความเสี่ยงต่ำ การรัน tool เดียวกันกับ asset จริงห้าพันชิ้นไม่ใช่แบบนั้น — bug ใน tool ตอนนี้แตะไฟล์ห้าพันไฟล์แทนที่จะเป็นสาม นิสัยสามข้อนี้ทำให้ batch tool ปลอดภัยในสเกลนั้น
ข้อแรก dry run (โหมดที่ tool รายงานว่าจะเปลี่ยนอะไรบ้าง โดยไม่เปลี่ยนอะไรจริง ๆ) ให้คุณ preview ความเสียหายก่อนที่จะยืนยันทำจริง:
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)
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
ไม่มีอะไรใน scene เปลี่ยนจริง ๆ เลย — function แค่พิมพ์ว่ามันจะทำอะไร lead อ่าน list นั้น ยืนยันว่าดูถูกต้องแล้ว แล้วค่อยรัน call เดียวกันเป๊ะแต่ใส่ dry_run=False เพื่อทำจริง
ข้อสอง pipeline tool จริงจะ log แทนที่จะแค่ print batch job ที่รันไม่มีคนเฝ้าตอนกลางคืนบน build server ต้องทิ้งบันทึกไว้ที่มนุษย์อ่านได้ตอนเช้า ไม่ใช่ text ที่หายไปพร้อมกับหน้าต่าง terminal ที่ปิดไปแล้ว:
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")
module logging ที่มากับ Python เขียนแต่ละบรรทัดลง pipeline.log พร้อม timestamp และระดับความรุนแรง โดยแทบไม่ต้องเขียนโค้ดเพิ่มจาก print() ธรรมดาเลย คุ้มที่จะเปลี่ยนไปใช้ตั้งแต่ตอนที่ script หยุดเป็นสิ่งที่คุณรันด้วยมือแล้วเฝ้าดูมันเอง
ข้อสาม batch tool ควรจะเป็น idempotent (คุณสมบัติที่การรัน tool มากกว่าหนึ่งครั้งกับ input เดิม ให้ผลลัพธ์เหมือนกับรันแค่ครั้งเดียว แทนที่จะสะสมการเปลี่ยนแปลงเพิ่มขึ้นทุกครั้ง)
batch_rename จาก Section 3.2 เช็ก old in obj.name — เป็นการเช็ก substring ไม่ใช่ "ขึ้นต้นด้วย prefix นี้เป๊ะ" การรัน batch_rename(scene, "Prop_", "SM_Prop_") ครั้งแรก เปลี่ยน Prop_barrel1 เป็น SM_Prop_barrel1 ได้ถูกต้อง แต่การรัน call เดียวกันเป๊ะครั้งที่สอง เจอ "Prop_" ที่ยังอยู่ข้างใน SM_Prop_barrel1 แล้ว rename ซ้ำอีกที กลายเป็น SM_SM_Prop_barrel1 นั่นไม่ idempotent และ job ที่บังเอิญรันซ้ำสองครั้งก็เป็นสิ่งที่เกิดขึ้นได้จริงบน pipeline ที่ไม่มีคนเฝ้า วิธีแก้คือเช็ก end state ก่อนลงมือทำ: if not obj.name.startswith("SM_Prop_") ก่อน rename เพื่อให้การรันครั้งที่สองไม่เจออะไรต้องทำอีกvalidate_all จาก Section 5 ในทางกลับกัน idempotent โดยธรรมชาติ — มันแค่อ่าน asset data แล้วรายงานปัญหา ไม่ได้เปลี่ยนอะไรเลย รันสิบครั้งติดกันกับไฟล์ที่ไม่เปลี่ยนแปลงก็พิมพ์ผลลัพธ์เดิมเป๊ะทุกครั้ง เลือก tool ที่มีรูปแบบแบบนี้ (อ่านแล้วรายงาน) ให้มากที่สุดเท่าที่ทำได้ และระวังเป็นพิเศษกับ tool ที่แก้ data ตรง ๆ
สมมติว่า artist ทุกคนในทีมทำผิดเรื่องการตั้งชื่อหรือ setting ประมาณหนึ่งใน สอง ร้อย asset ที่ตัวเองแตะ — เป็นอัตราที่ดีมากสำหรับมนุษย์ที่ทำงานซ้ำ ๆ ด้วยมืออย่างระมัดระวัง บนโปรเจกต์ที่มี asset ห้าพันชิ้น นั่นคือประมาณยี่สิบห้า asset ที่หลุดผ่านไปพร้อมปัญหา ทุกครั้งที่ผ่าน pipeline หนึ่งรอบ คูณด้วยจำนวนครั้งที่ asset ถูก re-export และ re-import ตลอดการทำโปรดักชัน — บ่อยครั้งเป็นสิบ ๆ ครั้ง — "ความผิดพลาดเล็ก ๆ นาน ๆ ครั้ง" ก็กลายเป็นเสียงหึ่ง ๆ ของ bug ที่แพงตลอดเวลา: texture ยืดเบี้ยวเพราะ UV หาย, prop มองไม่เห็นในเกมเพราะพิมพ์ชื่อ material ผิดตัวเดียว, build ที่ใหญ่กว่าที่ควรเป็นสี่ร้อยเมกะไบต์ เพราะไม่มีใครสังเกตเห็น texture 8192 พิกเซลที่ควรเป็น 1024
validation script (Section 5) ที่รันอัตโนมัติผ่าน hook (Section 6) จับ asset ทั้งยี่สิบห้าตัวนั้นได้ในไม่กี่วินาที ทุกครั้งไป โดยไม่มีความแปรปรวนเลยว่ามัน "ระมัดระวัง" แค่ไหนวันนั้น นี่คือเหตุผลทั้งหมดว่าทำไมเครื่องมือดีกว่าความมีวินัย: ความมีวินัยเสื่อมลงได้เมื่อโดน deadline บีบ, เมื่อเหนื่อยล้า, และเมื่อมีคนใหม่เข้าทีมที่ไม่เคยอ่าน style guide เลย แต่ script ไม่มีวันแย่
นี่ไม่ใช่ข้อโต้แย้งว่า artist ไม่ระวัง — มันคือข้อโต้แย้งเรื่องเลขคณิต คนที่เช็ก asset หนึ่งชิ้นกับกฎสี่ข้อ เชื่อถือได้มาก คนคนเดียวกันที่เช็ก asset ชิ้นที่หนึ่งหมื่นกับกฎสี่ข้อเดิม ตอนปลายวันที่ยาวนาน ใกล้ deadline ไม่เชื่อถือได้เท่ากันแล้ว — ไม่ใช่เพราะเขาทำงานแย่ลง แต่เพราะ "รักษาความสม่ำเสมอให้สมบูรณ์แบบตลอดหนึ่งหมื่นครั้ง" ไม่ใช่ task ที่มนุษย์คนไหนถูกสร้างมาให้ทำได้ แต่ script ทำได้
ทุกชิ้นส่วนจากบทนี้ประกอบกันเป็นเส้นทางเดียวต่อเนื่อง จากคลิกแรกของ artist ไปจนถึง build ที่เสร็จสมบูรณ์:
สังเกตว่าไม่มีจุดไหนใน chain นี้ที่พึ่งพาให้ artist จำกฎเลย Python tool ใน DCC (Section 3) เอาขั้นตอนซ้ำ ๆ ที่ทำด้วยมือออกไป validation script (Section 5) นิยามกฎครั้งเดียวในโค้ด แทนที่จะอยู่ในเอกสารที่ไม่มีใครอ่านซ้ำ hook (Section 6 กับ 7) ทำให้ validation กับการแก้ไขรันจริง ๆ กับทุก asset โดยไม่มีใครต้องเลือกที่จะรันมัน นิสัย dry-run กับ logging (Section 8) ทำให้ตัว tool เองน่าเชื่อถือพอที่จะใช้ในสเกลใหญ่ ไม่มีอันไหนมาแทนที่ artist เลย — มันแค่เอาส่วนที่ไม่เคยเกี่ยวกับ art จริง ๆ ออกจากงานพวกเขา เพื่อให้ชั่วโมงที่พวกเขาใช้จริง ถูกใช้ไปกับ model, texture, animation ไม่ใช่กับการจำว่าขีดจำกัด texture size อาทิตย์นี้คือ 1024 หรือ 2048
validate_asset จาก Section 5 ด้วยกฎใหม่: name ของ asset ต้องไม่มีช่องว่าง (space ในชื่อไฟล์ทำให้ build system กับ command-line tool บางตัวพังได้) เพิ่มการเช็กเข้าไปใน validate_asset ที่ขับเคลื่อนด้วย ASSET_RULES แล้วรันกับ asset list นี้ พร้อมแสดง 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]},
]
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
ต้องเพิ่มแค่ block if ใหม่หนึ่งอัน ตามรูปแบบเดียวกันเป๊ะกับการเช็กอื่น ๆ ทุกตัว: test เงื่อนไขหนึ่งอย่าง เพิ่ม message หนึ่งข้อความถ้ามันไม่ผ่าน SM_old crate มี prefix ที่ถูกต้องอยู่แล้ว รวมถึง poly count, UV, และ texture size ที่ใช้ได้ ดังนั้นการเช็ก space คือสิ่งเดียวที่ทำให้มันไม่ผ่าน
assign_material จาก Section 3.3 เปลี่ยน object ทันที ไม่มีทาง preview การเปลี่ยนแปลงก่อนเลย ตามรูปแบบเดียวกับ parameter dry_run ที่เพิ่มเข้าไปใน batch_rename ที่ Section 8 เพิ่ม parameter dry_run ให้ assign_material เพื่อให้ lead เห็นว่า object ตัวไหนจะได้รับผลกระทบ ก่อนที่จะยืนยันทำจริง
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
เงื่อนไขที่ตัดสินว่า object ตัวไหน ได้รับผลกระทบ (obj.type == obj_type) เหมือนเดิมทุกประการ — มีแค่ action ที่ทำเมื่อ object ตรงเงื่อนไขเท่านั้นที่เปลี่ยน ขึ้นอยู่กับ dry_run count ยังถูกเพิ่มเหมือนเดิมไม่ว่าจะทางไหน ดังนั้นผู้เรียกจะรู้เสมอว่า object กี่ตัวได้รับผลกระทบ (หรือจะได้รับผลกระทบ) ไม่ว่าจะมีอะไรถูกเปลี่ยนจริงหรือไม่
Done fixing textures แต่ artist รายงานว่าหลังจากรันแล้ว texture ที่ select ไว้ยังดูเหมือนเดิมทุกประการ — ไม่มีอะไรเปลี่ยนจริง อ่านโค้ดนี้ อธิบายว่าอะไรหายไป และทำไมการเปลี่ยน setting ถึงไม่มีผลอะไรเลยถ้าไม่มีมัน แล้วเขียนเวอร์ชันที่แก้แล้ว
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");
}
}
script ขาดการเรียก importer.SaveAndReimport() ไป TextureImporter คือคำอธิบาย import setting ของ texture ที่ Unity โหลดเข้า memory ให้คุณแก้ได้ — การตั้ง importer.maxTextureSize แค่เปลี่ยน copy ใน memory นั้นเท่านั้น ไม่มีอะไรเขียนการเปลี่ยนแปลงกลับไปที่ไฟล์ .meta จริงบน disk เลย และไม่มีอะไรบอก Unity ให้ import texture ใหม่ด้วย setting ใหม่ ดังนั้นตัว texture asset เองไม่เคยเปลี่ยนเลย loop ยังรันอยู่ field ยังถูกตั้งค่าอยู่ และ Debug.Log ก็ยังรันแล้วพิมพ์ message ออกมาอยู่ดีไม่ว่าจะเกิดอะไรขึ้น — นี่คือเหตุผลที่ bug แบบนี้พลาดง่ายมาก: ไม่มี error เลย มีแค่งานที่ถูกทิ้งไปเงียบ ๆ
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");
}
}
การเพิ่ม SaveAndReimport() (และของแถมเล็ก ๆ อีกอย่างคือ fixedCount จริง ๆ ที่ทำให้ log message สะท้อนงานที่ทำจริง) ทำให้ tool เขียนการเปลี่ยนแปลงกลับไปที่ disk และ import texture แต่ละตัวใหม่ทันที artist จึงเห็น setting ใหม่มีผลทันที แทนที่จะไม่มีอะไรเกิดขึ้นเลย