บทที่ 7.8 แนะนำ frame budget ไปแล้ว: 16.6 ms ในการสร้างหนึ่งเฟรมที่ 60 fps แบ่งเป็นงานฝั่ง CPU กับงานฝั่ง GPU บทที่ 8.5 ชี้ตัวเลขสองตัวที่มักทำให้ budget นั้นแตกในฉากจริง ๆ — draw call กับ triangle — แล้วให้ LOD, batching, GPU instancing, กับ occlusion culling เป็นเช็คลิสต์ไว้แก้ปัญหา บทนี้จะเปิดแต่ละเทคนิคเหล่านั้นออกมาดูให้ละเอียดขึ้น: draw call มีต้นทุนกับ CPU เท่าไหร่และเพราะอะไรกันแน่ วิธีเรียง renderer ให้ material ไม่ทำให้ batch พัง โค้ดจริง ๆ สำหรับ static batching, dynamic batching, กับ SRP Batcher คณิตศาสตร์เบื้องหลังการเลือกระยะ LOD แทนที่จะเดาสุ่ม และพาไล่ดู frustum culling, occlusion culling, กับ distance culling แยกตาม layer แบบเต็ม ๆ พอจบบทนี้เราจะเปิด Frame Debugger กับฉากจริง แล้วอธิบายได้ทีละ draw call ว่าทำไมมันถึงอยู่ตรงนั้น และมันจำเป็นต้องอยู่ไหม
draw call คือคำสั่งหนึ่งที่ CPU ส่งไปให้ GPU ว่า "วาด mesh นี้ ด้วย material นี้ ที่ transform นี้ เดี๋ยวนี้เลย" ฝั่ง GPU ของคำสั่งนี้แทบจะเร็วเสมอ — GPU ถูกสร้างมาให้ประมวลผลสามเหลี่ยมเป็นล้าน ๆ ได้สบาย ๆ ต้นทุนจริง ๆ ซ่อนอยู่ฝั่ง CPU ต่างหาก และมันไม่เกี่ยวอะไรเลยกับว่า mesh มีกี่สามเหลี่ยม ก่อนที่ GPU จะเริ่มทำงานได้ CPU ต้อง:
แต่ละขั้นตอนพวกนี้เสีย CPU time เล็ก ๆ ที่ค่อนข้างคงที่ ไม่ว่า mesh ข้างหลังจะเป็น cube 12 สามเหลี่ยม หรือตัวละคร 50,000 สามเหลี่ยม วาด object เดียวที่มี 50,000 สามเหลี่ยม GPU ก็ทำงานหนัก แต่วาด 5,000 object ที่มี 10 สามเหลี่ยมต่อชิ้น — จำนวนสามเหลี่ยมรวมเท่ากัน — CPU ต้องทำงาน setup มากกว่าถึง 5,000 เท่า สำหรับปริมาณงาน GPU ที่เท่ากัน นี่คือข้อเท็จจริงหลักที่บทนี้ทั้งบทสร้างขึ้นมาจากมัน: เกินจุดที่จำนวนสามเหลี่ยมค่อนข้างต่ำแล้ว จำนวน draw call สำคัญกว่าจำนวนสามเหลี่ยม
ตัวเลขจะทำให้เรื่องนี้จับต้องได้ชัดขึ้น ลองนึกภาพ cube เล็ก ๆ 1000 ก้อนเรียงกันเป็น grid แต่ละก้อนเป็น GameObject ของตัวเอง มี MeshRenderer ของตัวเอง ทุกก้อนใช้ material เดียวกัน นี่คือวิธีสร้างฉากนั้นแบบตรงไปตรงมาที่สุด:
using UnityEngine;
public class SpawnManyCubes : MonoBehaviour
{
public Mesh cubeMesh;
public Material cubeMaterial;
public int count = 1000;
void Start()
{
for (int i = 0; i < count; i++)
{
GameObject go = new GameObject("Cube_" + i);
MeshFilter mf = go.AddComponent<MeshFilter>();
MeshRenderer mr = go.AddComponent<MeshRenderer>();
mf.mesh = cubeMesh;
mr.sharedMaterial = cubeMaterial; // shared, not a private copy
go.transform.position = new Vector3(i % 32, 0f, i / 32) * 1.2f;
}
}
}
cube ทั้ง 1000 ก้อนแต่ละก้อนจบลงเป็น draw call ของตัวเอง เพราะแต่ละก้อนเป็น MeshRenderer แยกกัน และ Unity ไม่มีทางรู้ล่วงหน้าว่ามันจะไม่ขยับตำแหน่งสัมพัทธ์กันเลย ทีนี้ลองสร้างผลลัพธ์ทางภาพเดียวกันด้วยวิธีที่ต่างไปโดยสิ้นเชิง: รวม mesh ของ cube ทั้ง 1000 ก้อนเข้าเป็น mesh ก้อนใหญ่ก้อนเดียว ครั้งเดียวตอน load
using UnityEngine;
using UnityEngine.Rendering;
public class CombineCubes : MonoBehaviour
{
public Mesh cubeMesh;
public Material cubeMaterial;
public int count = 1000;
void Start()
{
CombineInstance[] combine = new CombineInstance[count];
for (int i = 0; i < count; i++)
{
Vector3 pos = new Vector3(i % 32, 0f, i / 32) * 1.2f;
combine[i].mesh = cubeMesh;
combine[i].transform = Matrix4x4.Translate(pos);
}
Mesh combinedMesh = new Mesh();
combinedMesh.indexFormat = IndexFormat.UInt32; // 1000 cubes = way over 65535 verts
combinedMesh.CombineMeshes(combine);
GameObject go = new GameObject("CombinedCubes");
go.AddComponent<MeshFilter>().mesh = combinedMesh;
go.AddComponent<MeshRenderer>().sharedMaterial = cubeMaterial;
}
}
Mesh ปกติใช้ index format แบบ 16-bit ซึ่ง address vertex ได้สูงสุด 65,535 ตัว cube หนึ่งก้อนมี 24 vertex (4 ต่อหน้า เพื่อให้มุมคมชัดตอน shade) ดังนั้น cube 1000 ก้อนต้องใช้ 24,000 vertex — ยังอยู่ใต้ limit ในตอนนี้ แต่ถ้าดันจำนวนขึ้นไปอีก (หรือใช้ source mesh ที่หนักกว่านี้) CombineMeshes จะสร้าง mesh ที่พังออกมาแบบเงียบ ๆ เว้นแต่เราจะตั้ง indexFormat = IndexFormat.UInt32 ไว้ก่อน ซึ่งจะยกขีดจำกัดขึ้นไปเกิน 4 พันล้านทั้งสองเวอร์ชันวาดสามเหลี่ยมชุดเดียวกันเป๊ะ ๆ ลงบนจอ สิ่งเดียวที่เปลี่ยนไปคือจำนวนครั้งที่ CPU ต้องพูดว่า "GPU เอ้ย วาดอันนี้เดี๋ยวนี้":
ลองใส่ตัวเลขจริงลงไปแทน "(fixed)" ดู แล้วเรื่องนี้จะไม่เป็นเรื่องนามธรรมอีกต่อไป overhead ฝั่ง CPU ของ draw call หนึ่งครั้งมักอยู่ราว ๆ 0.01 ถึง 0.05 ms เมื่อรวม state validation, การอัปโหลด matrix, และงานบัญชีของ driver เข้าไปด้วย — ตัวเลขที่แน่นอนขึ้นอยู่กับแพลตฟอร์ม, driver, และ state เปลี่ยนไปมากแค่ไหน ลองใช้ค่ากลาง ๆ 0.02 ms ต่อครั้ง:
นี่คือตัวอย่างประกอบการอธิบาย ไม่ใช่ค่าที่วัดได้แน่นอน — ตัวเลขจริงของเราจะขึ้นอยู่กับ target hardware สิ่งที่ไม่เปลี่ยนไม่ว่าจะแพลตฟอร์มไหนคือรูปแบบนี้: ต้นทุน draw call ฝั่ง CPU ขยายตาม จำนวนครั้งที่เราเรียกมัน ไม่ใช่ตามปริมาณ geometry ที่อยู่หลัง call แต่ละครั้ง นี่คือเหตุผลทั้งหมดที่ batching, instancing, และ SRP Batcher มีอยู่ และเป็นเหตุผลที่บทที่เหลือจะมอง "ลด draw call" เป็นเป้าหมายแยกจาก "ลดสามเหลี่ยม"
ไม่ใช่ draw call ทุกครั้งจะมีต้นทุนเท่ากัน แม้จะยังไม่พูดถึง batching เลยก็ตาม ก่อนหน้า draw call บางครั้ง GPU ต้องการ SetPass call — การเปลี่ยน pipeline state: bind shader program คนละตัว, bind texture คนละชุด, หรือเปลี่ยน render state อย่าง blend mode หรือ depth testing SetPass call แพงกว่า draw call ที่ตามมาค่อนข้างมาก เพราะมันกำลังตั้งค่า pipeline ของ GPU ทั้งชุดใหม่ ไม่ใช่แค่ส่ง object อีกชิ้นให้วาด
ตัวกระตุ้นของ SetPass call คือ การเปลี่ยน material draw call สองครั้งติดกันที่ใช้ material เดียวกันเป๊ะ ไม่ต้องมี SetPass call คั่นกลางเลยสักครั้ง — pipeline ของ GPU ถูกตั้งค่าไว้ถูกต้องอยู่แล้ว draw call สองครั้งติดกันที่ใช้ material ต่างกันต้องมี SetPass call คั่นกลาง แม้ mesh จะเล็กแค่ไหนก็ตาม นี่คือเหตุผลที่ ลำดับ การส่ง draw call มีผล ไม่ใช่แค่จำนวน object ทั้งหมด
renderer ของ Unity เองก็เรียงลำดับ opaque object เพื่อลดการเปลี่ยน state อยู่แล้วในจุดที่ทำได้อย่างปลอดภัย (เรียงจากใกล้ไปไกลคร่าว ๆ สำหรับ opaque object เพื่อช่วย early depth testing และเรียงตาม render queue กับ shader สำหรับการเปลี่ยน state) แต่มันไม่สามารถหาลำดับที่ดีที่สุดได้เองเสมอไป โดยเฉพาะเมื่อมี custom render queue หรือ material ที่ไม่ซ้ำกันเยอะ ๆ เข้ามาเกี่ยวข้อง สิ่งที่เราควบคุมได้โดยตรงคือมี material ที่ ต่างกันจริง ๆ กี่ตัวตั้งแต่แรก — material ไม่ซ้ำกันน้อยลง หมายถึงโอกาสเปลี่ยน SetPass น้อยลงไม่ว่าลำดับสุดท้ายจะเป็นยังไง วิธีเร็ว ๆ ในการดูตัวเลขนี้สำหรับ renderer ชุดหนึ่ง:
using System.Linq;
using UnityEngine;
public static class BatchAudit
{
public static void ReportUniqueMaterials(MeshRenderer[] renderers)
{
int uniqueMaterials = renderers
.Select(r => r.sharedMaterial)
.Distinct()
.Count();
Debug.Log("Renderers: " + renderers.Length);
Debug.Log("Unique materials: " + uniqueMaterials);
Debug.Log("Best case SetPass calls (if perfectly sorted): " + uniqueMaterials);
}
}
ลองรัน BatchAudit.ReportUniqueMaterials กับ renderer สัก 500 ตัวที่จริง ๆ ใช้ material ต่างกันแค่ 12 ตัว console จะโชว์:
Renderers: 500
Unique materials: 12
Best case SetPass calls (if perfectly sorted): 12
ตัวเลข "12" นั้นคือเพดานว่าฉากนี้จะดีที่สุดได้แค่ไหนถ้าใช้แค่การเรียงลำดับอย่างเดียว — มันลดลงกว่านี้ไม่ได้อีกแล้ว นอกจากจะรวม renderer ให้เหลือ draw call น้อยลง (Section 4 ถึง 6) หรือลดจำนวน material ที่ต่างกันตั้งแต่ต้น (Section 8)
renderer.material (ไม่ใช่ sharedMaterial) ที่ไหนก็ตาม แม้จะแค่แอบดู color ก็ตาม การแตะ .material ครั้งแรกจะสร้างสำเนา material ส่วนตัวให้ renderer ตัวนั้นแบบเงียบ ๆ ทันที นับจากจุดนั้น Distinct() ในโค้ดข้างบนจะนับมันแยกออกมา เพราะมันเป็น material คนละตัวจริง ๆ แล้ว — และระบบ batching ก็นับแบบเดียวกัน นี่คือเหตุผลที่ renderer ตัวนั้นหยุด batch กับอะไรก็ตามไปเลยStatic batching รวม mesh ของ object ที่ถูก mark ว่าไม่ขยับเลย เข้าเป็น vertex buffer ก้อนใหญ่ก้อนเดียว ครั้งเดียว ไม่ว่าจะตอนโหลดฉากหรือตอน build จากนั้น object กลุ่มไหนที่ใช้ material เดียวกันก็จะวาดเป็น batch เดียวแทนที่จะเป็น draw call ต่อ object มันคือเครื่องมือที่เหมาะกับ level geometry: ตึก, prop ของภูมิประเทศ, ก้อนหิน, รั้ว — อะไรก็ตามที่วางครั้งเดียวแล้วไม่ถูกย้ายตำแหน่ง หมุน หรือ scale อีกเลยตอน runtime
การ mark object เป็น "Static" ผ่าน checkbox Static ใน Inspector คือวิธีปกติที่จะเปิดใช้งาน แต่สิ่งเดียวกันนี้ก็เรียกจากโค้ดได้เหมือนกัน มีประโยชน์เวลา object ถูกสร้างแบบ procedural ตอน load แทนที่จะวางด้วยมือใน Editor:
using UnityEngine;
public class BuildStaticBatch : MonoBehaviour
{
public GameObject[] neverMovingRocks;
void Start()
{
// Combines every renderer in neverMovingRocks that shares a
// material into as few draw calls as possible, and parents
// the result under this GameObject's transform.
StaticBatchingUtility.Combine(neverMovingRocks, gameObject);
}
}
call นี้ไม่มี console output ออกมา — ผลลัพธ์จะไปโผล่ใน Frame Debugger เป็น step เดียวชื่อ "Static Batch" ที่ครอบคลุมหินทุกก้อนที่ใช้ material เดียวกัน แทนที่จะเป็นหนึ่ง step ต่อหินหนึ่งก้อน ต้นทุนที่เสียไปคือ memory ไม่ใช่ CPU: static batching ก็อปปี้ vertex data ของแต่ละ object เข้าไปใน shared buffer โดย transform ไปอยู่ตำแหน่งสุดท้ายไว้ล่วงหน้าเรียบร้อยแล้ว รวมหิน 2,000 vertex ก้อนเดียวกัน 500 ก้อนเข้าด้วยกัน เราจะเก็บข้อมูลราว ๆ 500 x 2,000 vertex แม้ว่าทางภาพมันจะยังเป็น "หินก้อนเดียวกัน" 500 ก้อนอยู่ดี เทียบกับ GPU instancing ใน Section 6 ที่เก็บ mesh ไว้แค่สำเนาเดียวใน memory ไม่ว่าจะมี instance ปรากฏบนจอกี่ตัวก็ตาม — ข้อแลกเปลี่ยนระหว่างสองเทคนิคนี้เกือบทั้งหมดคือเรื่องต้นทุน memory นี้ เทียบกับความสามารถในการขยับ object ตอน runtime
Dynamic batching เป็นด้านตรงข้ามของ static batching ในจุดหนึ่งที่เฉพาะเจาะจง: มันทำงานกับ object ที่ขยับได้จริง โดยสร้าง combined vertex buffer ใหม่ทุกเฟรมแทนที่จะสร้างครั้งเดียว built-in render pipeline ของ Unity ทำแบบนี้ให้อัตโนมัติได้กับ renderer ที่ตรงตามเงื่อนไขที่ค่อนข้างเข้มงวดชุดหนึ่ง:
เพราะมันเสีย CPU time ทุกเฟรมในการสร้าง shared buffer ใหม่ dynamic batching จะคุ้มค่าจริง ๆ ก็ต่อเมื่อมี object เล็ก ๆ เรียบง่ายมาก ๆ จำนวนมาก — นึกถึงเศษซากที่กระจายอยู่, ปลอกกระสุน, หรือ foliage card ง่าย ๆ — และตอนนี้มันสำคัญน้อยลงกว่าเมื่อก่อนมาก เพราะมี GPU instancing (Section 6) กับ SRP Batcher (Section 7) เกิดขึ้นมาแล้ว แต่ก็ยังคุ้มที่จะเข้าใจข้อจำกัดของมันไว้ อย่างน้อยก็เพื่อรู้ทันว่าเมื่อไหร่ mesh หนึ่งกำลังตัดสิทธิ์ตัวเองแบบเงียบ ๆ:
using UnityEngine;
public class DynamicBatchEligibility : MonoBehaviour
{
const int dynamicBatchVertexLimit = 300;
void Start()
{
Mesh mesh = GetComponent<MeshFilter>().sharedMesh;
if (mesh.vertexCount > dynamicBatchVertexLimit)
{
Debug.LogWarning(name + " has " + mesh.vertexCount +
" vertices -- too heavy for dynamic batching (limit ~" +
dynamicBatchVertexLimit + ").");
}
else
{
Debug.Log(name + " has " + mesh.vertexCount +
" vertices -- eligible for dynamic batching.");
}
}
}
ลอง attach โค้ดนี้เข้ากับเศษซากธรรมดาที่มี 180 vertex กับ prop ละเอียดที่มี 4,500 vertex console จะโชว์ผลลัพธ์ทั้งสองแบบเทียบกัน:
Debris_Chunk has 180 vertices -- eligible for dynamic batching.
DetailedCrate has 4500 vertices -- too heavy for dynamic batching (limit ~300).
GPU instancing ใช้วิธีที่ต่างออกไปโดยสิ้นเชิง: แทนที่จะรวม mesh เข้า buffer เดียว (static batching) หรือสร้าง buffer ใหม่ทุกเฟรม (dynamic batching) มันส่ง mesh ไปให้ GPU แค่ครั้งเดียว แล้วส่งรายการข้อมูลต่อ instance ตามมา — ที่พบบ่อยที่สุดคือ transform matrix ต่อชิ้น — แล้วปล่อยให้ GPU ปั๊ม mesh ก้อนเดียวนั้นออกมาในทุกตำแหน่งภายใน draw call เดียว มันเหมาะกับ mesh เดียวกันเป๊ะ ๆ ที่ต้องมีหลายชุด และยังต้องขยับ, spawn, หรือ despawn แยกจากกันได้ด้วย: หญ้า, ต้นไม้, ก้อนหิน, ลูกธนู, ปลอกกระสุน
การวาดแบบ instanced ที่เรียบง่ายที่สุด กระจายกอ foliage ไปทั่วทุ่ง:
using UnityEngine;
public class InstancedFoliage : MonoBehaviour
{
public Mesh clumpMesh;
public Material clumpMaterial; // must have "Enable GPU Instancing" checked
public int clumpCount = 6000;
Matrix4x4[][] batches; // pre-split into chunks of at most 1023
void Start()
{
const int maxPerBatch = 1023; // hard limit of Graphics.DrawMeshInstanced
int batchCount = Mathf.CeilToInt(clumpCount / (float)maxPerBatch);
batches = new Matrix4x4[batchCount][];
int placed = 0;
for (int b = 0; b < batchCount; b++)
{
int sizeThisBatch = Mathf.Min(maxPerBatch, clumpCount - placed);
batches[b] = new Matrix4x4[sizeThisBatch];
for (int i = 0; i < sizeThisBatch; i++)
{
Vector3 pos = new Vector3(Random.Range(-60f, 60f), 0f, Random.Range(-60f, 60f));
Quaternion rot = Quaternion.Euler(0f, Random.Range(0f, 360f), 0f);
float scale = Random.Range(0.8f, 1.3f);
batches[b][i] = Matrix4x4.TRS(pos, rot, Vector3.one * scale);
placed++;
}
}
}
void Update()
{
// Pre-split once in Start, so Update just submits the chunks --
// no per-frame array allocation.
for (int b = 0; b < batches.Length; b++)
{
Graphics.DrawMeshInstanced(clumpMesh, 0, clumpMaterial, batches[b]);
}
}
}
ด้วย clumpCount = 6000 นั่นคือ 6 chunk ที่มีอย่างมาก 1023 ตัวต่อ chunk ดังนั้น 6 draw call ก็วาง foliage 6000 กอบนจอได้ทุกเฟรม — แทนที่จะเป็น 6000 draw call แยกกัน การแบ่ง chunk ครั้งเดียวใน Start() แทนที่จะทำทุกเฟรมใน Update() มีผลมาก: การ allocate array ใหม่ 6 ครั้งต่อเฟรม 60 ครั้งต่อวินาที คือตัวการแรงกดดัน garbage collector แบบซ่อนเร้นพอดี ที่จะโผล่มาเป็นอาการกระตุกทีหลัง แม้ว่าจำนวน draw call เองจะดูโอเคก็ตาม
MaterialPropertyBlock — ถุงค่า override เล็ก ๆ ต่อ draw call ที่ไม่สร้าง material ใหม่และไม่ทำให้ batch พัง shader ที่อ่าน _Color ต่อ instance กับการเรียกแบบ Graphics.DrawMeshInstanced(clumpMesh, 0, clumpMaterial, batches[b], batches[b].Length, block) ทำให้แต่ละ chunk มี color array ของตัวเองได้โดยไม่ต้องแตะ sharedMaterial เลยGraphics.DrawMeshInstanced จะ fallback กลับไปยิง draw call ปกติทีละ instance แบบเงียบ ๆ — โค้ดรันได้โดยไม่มี error แต่ Frame Debugger จะโชว์จำนวน draw call ที่เรากำลังพยายามหลีกเลี่ยงอยู่พอดีถ้า project ของเราใช้ Scriptable Render Pipeline (URP หรือ HDRP) จะมีเทคนิคที่สี่ที่ทำงานต่างจากสามอย่างแรกโดยสิ้นเชิง SRP Batcher ไม่ได้รวม object หลายตัวให้เหลือ draw call น้อยลงเลย — ฉากที่มี object แยกกัน 500 ตัวที่ใช้ shader ที่ compatible กับ SRP Batcher ก็ยังยิง draw call 500 ครั้งอยู่ดี สิ่งที่มันทำแทนคือทำให้แต่ละ draw call เตรียมตัวบน CPU ถูกลงอย่างมาก โดย cache ข้อมูล shader property ของแต่ละ material ไว้บน GPU ข้ามเฟรม ดังนั้นการสลับจาก object หนึ่งไปอีก object — แม้จะเป็น mesh คนละอันเลย แม้จะเป็น material คนละตัว ตราบใดที่ทั้งคู่ใช้ shader ที่ compatible กับ SRP Batcher — ส่วนใหญ่ก็แค่ชี้ไปที่ข้อมูลที่อัปโหลดไว้แล้ว แทนที่จะอัปโหลดใหม่
เรื่องนี้สำคัญเพราะมันตัดเหตุผลปกติที่ทำให้ material หลากหลายแล้วแพงออกไป: ปกติแล้วการสลับ material เสียทั้ง SetPass call (Section 3) บวกกับการอัปโหลด property ใหม่ พอ SRP Batcher ทำงานอยู่ ต้นทุนนั้นจะหดลงมากจนฉากที่มี material ไม่ซ้ำกันเยอะ ๆ — เคสเดียวกับที่ batching และ atlasing ถูกสร้างมาเพื่อหลีกเลี่ยง — ไม่เจ็บปวดเหมือนเดิมอีกต่อไป
ข้อกำหนดอยู่ฝั่ง shader: property ต่อ material ของมันต้องอยู่ใน constant buffer block ที่ Unity รู้จัก โดยใช้ชื่อ UnityPerMaterial shader built-in ของ URP และ HDRP ทุกตัว รวมถึง Shader Graph output แบบ default ทุกอันทำตาม convention นี้อยู่แล้ว shader ที่เขียนเองต้อง opt in แบบชัดเจน:
// Simplified excerpt from a URP-style shader's HLSL code.
// Properties declared inside this exact CBUFFER are what makes
// the shader SRP-Batcher compatible.
CBUFFER_START(UnityPerMaterial)
float4 _BaseColor;
float _Metallic;
float _Smoothness;
CBUFFER_END
ไม่มีโค้ดฝั่ง C# ต้องเขียนเพื่อเปิดใช้งานเรื่องนี้ — มันอัตโนมัติทันทีที่ shader ทุกตัวที่ใช้งานผ่านเกณฑ์ สิ่งที่เราเช็คได้คือมันเกิดขึ้นจริงไหม: Frame Debugger (Section 13) จะติด label draw call ที่ผ่านเกณฑ์ว่าเป็น "SRP Batch" แทนที่จะเป็น draw call ธรรมดา และ panel สถิติของ Frame Debugger เองก็รายงานจำนวน "SRP Batches" ของเฟรมนั้นด้วย shader ตัวเดียวในฉากที่ไม่ผ่านเกณฑ์ — ส่วนใหญ่มักเป็น shader ของ Built-in Render Pipeline รุ่นเก่าที่ถูกดึงเข้ามาใน project แบบ URP — ก็เพียงพอที่จะดีด object ทุกตัวที่ใช้ shader นั้นกลับไปเป็น draw call ธรรมดาที่แพงกว่าเดิม ในขณะที่ตัวอื่น ๆ ยัง batch กันได้ตามปกติ
ทุกเทคนิคที่ผ่านมามีข้อกำหนดที่ไม่ได้พูดออกมาตรง ๆ เหมือนกันหมด: object ที่เกี่ยวข้องต้องใช้ material เดียวกันอยู่แล้ว static batching, dynamic batching, และ GPU instancing ทั้งหมดหยุดทำงานทันทีที่ object สองตัวต้องการ texture ต่างกัน เพราะ texture ต่างกันปกติแล้วหมายถึง material ต่างกัน บทเรื่อง technical art พูดถึง texture atlas (texture เล็ก ๆ หลายตัวถูกอัดเข้าไปในแผ่นใหญ่แผ่นเดียว) ไปแล้วว่าเป็นวิธีประหยัด texture memory และเลี่ยงไฟล์ซ้ำ เทคนิคเดียวกันนี้ก็เป็นสิ่งที่เปลี่ยนกอง object ที่หน้าตาต่างกันให้กลายเป็น object ที่ batch รวมกันได้ เพราะหลัง atlas แล้วทุกตัวชี้ไปที่ material เดียวกัน แค่ sample คนละบริเวณของ texture เดียวกันเท่านั้น
วิธีของบท technical art เลื่อนและ scale หน้าต่าง sample texture ของ material (mainTextureOffset กับ mainTextureScale) เพื่อเลือก tile หนึ่งจาก grid อีกวิธีที่พบบ่อย — มีประโยชน์เวลา mesh ต่างกันมี UV layout ที่วางแบบไม่เป็นระเบียบเลย ไม่ใช่ grid tile เรียบร้อย — คือ remap UV coordinate ของ mesh เองครั้งเดียว ให้ทุก vertex ชี้ไปที่บริเวณที่ถูกต้องของ atlas ที่แชร์กันอยู่แล้ว โดย material เองไม่ต้องเปลี่ยนต่อ object เลยแม้แต่น้อย:
using UnityEngine;
public static class AtlasUVRemap
{
// Remaps UVs that are currently in the 0-1 range of a standalone
// texture into one cell of a gridSize x gridSize atlas, e.g.
// cellX = 2, cellY = 1 inside a 4x4 atlas.
public static Vector2[] RemapToAtlasCell(Vector2[] originalUVs, int cellX, int cellY, int gridSize)
{
Vector2[] remapped = new Vector2[originalUVs.Length];
float cellSize = 1f / gridSize;
for (int i = 0; i < originalUVs.Length; i++)
{
float u = (cellX + originalUVs[i].x) * cellSize;
float v = (cellY + originalUVs[i].y) * cellSize;
remapped[i] = new Vector2(u, v);
}
return remapped;
}
}
ลองไล่ตัวอย่างจริง: UV coordinate เดิม (0.5, 0.5) — จุดกึ่งกลางเป๊ะของ texture ของตัวเอง — remap เข้าไปที่ cell (2, 1) ของ atlas 4x4:
ผลตอบแทนโผล่มาให้เห็นตรง ๆ ในจำนวน material จาก BatchAudit ของ Section 3 ลองเอากลุ่ม background prop 40 ชิ้นที่แต่ก่อนต้องใช้ material แบบ single-texture แยกกัน 40 ตัว มา repack texture เข้า atlas ที่แชร์กันตัวเดียว, remap UV แล้วรัน audit เดิมซ้ำ:
Before atlasing:
Renderers: 40
Unique materials: 40
Best case SetPass calls (if perfectly sorted): 40
After atlasing:
Renderers: 40
Unique materials: 1
Best case SetPass calls (if perfectly sorted): 1
การลดจาก material ไม่ซ้ำกัน 40 ตัวเหลือ 1 ตัว ไม่ได้แค่ช่วยเรื่องการเรียงลำดับ — มันคือสิ่งที่ทำให้ static batching, dynamic batching, และการรวม mesh เป็นไปได้ สำหรับกลุ่มนี้ตั้งแต่แรก เพราะทั้งสามอย่างต้องการ material ที่แชร์กันเป็นเงื่อนไขเบื้องต้น การทำ atlas มักเป็นขั้นตอนที่ต้องเกิดขึ้นก่อนที่ Section 4 ถึง 6 อันไหนก็ตามจะทำอะไรได้เลยด้วยซ้ำ
บทเรื่อง technical art สร้าง LODGroup โดยใช้ threshold ของ screen-relative-height ที่ตายตัว — 50%, 15%, 2% — ซึ่งเลือกมาเป็นค่า default ที่สมเหตุสมผล section นี้จะพูดถึงว่าตัวเลขแบบนั้นมาจากไหนกันแน่ เพื่อให้เราเลือกของตัวเองได้แทนที่จะเดา
LODGroup สลับ mesh โดยอิงจาก screen-relative height: สัดส่วนความสูงแนวตั้งของหน้าจอที่ bounding box ของ object ครอบคลุมอยู่ตอนนั้น ตั้งแต่ 0 (มองไม่เห็น) ถึง 1 (เต็มจอตั้งแต่บนถึงล่าง) การใช้สัดส่วนหน้าจอแทนระยะทางแบบ world-space ตรง ๆ เป็นเรื่องที่ตั้งใจ — มันคำนึงถึงทั้งขนาดของ object เองและ field of view ของกล้องโดยอัตโนมัติ ซึ่งเป็นสองอย่างที่กฎแบบตายตัว "สลับที่ 20 เมตร" จะผิดพลาดทันทีที่เราเปลี่ยนอย่างใดอย่างหนึ่ง
ความสัมพันธ์ระหว่าง screen-relative height, ขนาด object, ระยะทาง, และ field of view มาจากตรีโกณมิติพื้นฐาน: object ที่มีความสูงแบบ world-space เท่ากับ h อยู่ที่ระยะ d จากกล้อง มองผ่าน vertical field of view fov จะครอบคลุมความสูงหน้าจอราว ๆ สัดส่วนนี้:
screenHeight =~ h / (2 * d * tan(fov / 2))
จัดสูตรใหม่เพื่อแก้หาระยะทาง เมื่อมี target screen-relative height threshold แล้ว มันจะบอกเราได้เป๊ะ ๆ ว่า LOD จะสลับที่กี่เมตร แทนที่จะเลือก threshold ด้วยสายตาแล้วหวังว่ามันจะโอเค:
using UnityEngine;
public static class LODDistanceCalculator
{
// Returns the camera distance, in meters, at which an object of
// the given world-space height will cross the given screen-relative
// height threshold (the same 0-1 number an LODGroup's LOD uses).
public static float DistanceForScreenHeight(float objectWorldHeight, float screenRelativeHeight, float verticalFovDegrees)
{
float fovRad = verticalFovDegrees * Mathf.Deg2Rad;
return objectWorldHeight / (2f * screenRelativeHeight * Mathf.Tan(fovRad / 2f));
}
}
สำหรับตัวละครสูง 2 เมตร กล้องที่มี vertical FOV 60 องศา และ threshold 50% / 15% / 2% จากบท technical art:
DistanceForScreenHeight(2f, 0.50f, 60f) =~ 3.46 m (switches to LOD1)
DistanceForScreenHeight(2f, 0.15f, 60f) =~ 11.55 m (switches to LOD2)
DistanceForScreenHeight(2f, 0.02f, 60f) =~ 86.60 m (culled)
ตัวเลขพวกนี้บอกว่า threshold 50%/15%/2% ชุดเดียวกันที่รู้สึกโอเคสำหรับตัวละคร 2 เมตร จะทำให้การสลับ LOD0 ไป LOD1 เกิดขึ้นในระยะไม่ถึง 3.5 เมตร — ใกล้เกินจนไม่สบายตา มีโอกาสสูงที่จะเห็นเป็น pop ชัด ๆ ระหว่างเล่นปกติ ในขณะที่ threshold ชุดเดียวกันบน object ที่ใหญ่กว่ามาก เช่นตึกสูง 20 เมตร จะดันการสลับแบบเดียวกันออกไปถึง 34.6 เมตร นี่คือเหตุผลที่ชุดเปอร์เซ็นต์ "ดี ๆ" ชุดเดียวไม่สามารถย้ายไปใช้กับ asset ที่ขนาดต่างกันมาก ๆ ได้: เปอร์เซ็นต์ต้องถูกเลือกโดยคำนึงถึงขนาด object จริงและ FOV ของกล้อง โดยใช้สูตรข้างบน ไม่ใช่ก็อปมาจาก asset อื่นที่แค่ดูโอเคเฉย ๆ
using UnityEngine;
public class SetupLODGroup : MonoBehaviour
{
public Mesh highDetail;
public Mesh mediumDetail;
public Mesh lowDetail;
public Material sharedMaterial;
void Start()
{
LODGroup group = gameObject.AddComponent<LODGroup>();
LOD[] lods = new LOD[3];
lods[0] = BuildLOD(highDetail, 0.35f); // switch below 35% of screen height
lods[1] = BuildLOD(mediumDetail, 0.12f); // switch below 12%
lods[2] = BuildLOD(lowDetail, 0.03f); // switch below 3%, culled after that
group.SetLODs(lods);
group.RecalculateBounds();
}
LOD BuildLOD(Mesh mesh, float screenRelativeHeight)
{
GameObject child = new GameObject(mesh.name);
child.transform.SetParent(transform, false);
child.AddComponent<MeshFilter>().mesh = mesh;
Renderer renderer = child.AddComponent<MeshRenderer>();
renderer.sharedMaterial = sharedMaterial;
return new LOD(screenRelativeHeight, new Renderer[] { renderer });
}
}
กล้องไม่จำเป็นต้องพิจารณาทั้งฉากทุกเฟรม — แค่บริเวณพื้นที่ที่มันมองเห็นได้จริงเท่านั้น บริเวณนั้นเรียกว่า view frustum (ปิรามิดสี่เหลี่ยมที่ถูกตัดยอด: near plane ใกล้กล้อง, far plane ที่ระยะวาดสูงสุด, และระนาบด้านข้างอีกสี่ระนาบเชื่อมกัน เอียงออกตาม field of view) frustum culling คือการเทส bounds ของทุก object กับหกระนาบนั้น แล้วข้าม object ที่อยู่นอกทุกระนาบไปเลย — ไม่มี draw call, ไม่มี vertex processing, ไม่มี pixel shading เพราะ GPU ไม่เคยถูกบอกเรื่อง object นั้นตั้งแต่แรก
Unity ทำเรื่องนี้ให้อัตโนมัติสำหรับทุก Camera ทุกเฟรม โดยใช้ bounding box ของแต่ละ renderer ดังนั้น gameplay code ทั่วไปไม่ต้องคิดเรื่องนี้เลย มันจะคุ้มค่าที่จะเขียนเองในสองสถานการณ์: ระบบ custom ที่ตัดสินใจ visibility เองก่อนที่ renderer path ปกติของ Unity จะรันด้วยซ้ำ (เช่น ระบบ instancing แบบ custom ที่ตัดสินใจว่า instance ไหนจะรวมอยู่ใน batch ของเฟรมนี้) หรือแค่เพื่อเข้าใจว่าเบื้องหลังมันเกิดอะไรขึ้น:
using UnityEngine;
public class ManualFrustumCheck : MonoBehaviour
{
public Camera cam;
public Renderer target;
void Update()
{
Plane[] frustumPlanes = GeometryUtility.CalculateFrustumPlanes(cam);
bool visible = GeometryUtility.TestPlanesAABB(frustumPlanes, target.bounds);
Debug.Log(target.name + " inside frustum: " + visible);
}
}
ตอนที่หินเป้าหมายอยู่ตรงหน้ากล้อง console จะแสดง:
Rock_12 inside frustum: True
เดินกล้องผ่านหินไปจนตอนนี้มันอยู่ข้างหลังกล้องแล้ว โค้ดบรรทัดเดิมเป๊ะ ๆ จะ print ผลลัพธ์ตรงข้ามในเฟรมถัดไป:
Rock_12 inside frustum: False
frustum culling แทบจะได้มาฟรี ๆ — ไม่ต้อง bake, ไม่ต้อง setup, ไม่ต้อง opt in ต่อ object เลย — แต่มันช่วยได้แค่กับ object ที่อยู่นอก view cone ไปเลยเท่านั้น object หนึ่งอาจอยู่ใน frustum เป๊ะ ๆ แต่ผู้เล่นก็ยังมองไม่เห็นมันอยู่ดี ถ้ามีอะไรบางอย่างยืนขวางอยู่ข้างหน้าพอดี ช่องว่างตรงนี้คือสิ่งที่ Section 11 มาปิดให้
occlusion culling ข้าม object ที่ผ่านเทส frustum แล้ว — อยู่ใน view cone จริง — แต่ยังถูกบังจนมิดโดยสิ่งที่อยู่ใกล้กล้องกว่า: ลังไม้ที่อยู่หลังกำแพงทึบพอดี, ห้องที่เต็มไปด้วย prop หลังประตูที่ปิดอยู่ ต่างจาก frustum culling ตรงที่เรื่องนี้คำนวณสด ๆ แบบถูก ๆ สำหรับฉากซับซ้อนไม่ได้ (การเทสทุก object กับ occluder ที่เป็นไปได้ทุกตัว ทุกเฟรม มันช้าเกินไปอยู่แล้วในตัวมันเอง) ดังนั้นระบบ built-in ของ Unity จึงเป็นระบบแบบ bake: ข้อมูล visibility ถูกคำนวณไว้ล่วงหน้าครั้งเดียวใน Editor แล้วกล้องอ่านข้อมูลที่คำนวณไว้แล้วนั้นตอน runtime แทนที่จะเทส geometry ดิบ ๆ
การตั้งค่าเรื่องนี้คือเปิด Window > Rendering > Occlusion Culling, mark object ทึบขนาดใหญ่เป็น Occluder Static (สิ่งที่บัง object อื่นได้) mark object ที่ควรถูกพิจารณา cull เป็น Occludee Static แล้วรัน bake ฉากใหญ่ ๆ อาจมี occluder ที่เป็นไปได้เป็นร้อย ๆ ตัว และไม่ใช่ทุกตัวที่คุ้มเวลา bake: เสารั้วบาง ๆ ทางเทคนิคก็บังมุมมองได้นิดหน่อย แต่แทบไม่เคยบังอะไรที่มีความหมายเลย ในขณะที่กำแพงตึกหรือสันเขาบังได้เยอะมาก การกรองตามขนาดเป็นวิธีที่ใช้งานได้จริงในการ flag เฉพาะ object ที่น่าจะคุ้มค่า แทนที่จะ mark ทุกอย่างที่ทึบในฉากด้วยมือ:
using UnityEditor;
using UnityEngine;
public class AutoMarkOccluders
{
const float minOccluderSize = 3f; // meters, along the largest bounds axis
[MenuItem("Tools/Auto-Mark Large Objects As Occluders")]
static void MarkLargeObjects()
{
int marked = 0;
foreach (Renderer renderer in Object.FindObjectsOfType<Renderer>())
{
Vector3 size = renderer.bounds.size;
float largestAxis = Mathf.Max(size.x, size.y, size.z);
if (largestAxis >= minOccluderSize)
{
GameObject go = renderer.gameObject;
StaticEditorFlags flags = GameObjectUtility.GetStaticEditorFlags(go);
flags |= StaticEditorFlags.OccluderStatic;
flags |= StaticEditorFlags.OccludeeStatic;
GameObjectUtility.SetStaticEditorFlags(go, flags);
marked++;
}
}
Debug.Log("Marked " + marked + " objects as occluders (largest axis >= " + minOccluderSize + " m).");
}
}
รันโค้ดนี้กับฉากถนนที่มี renderer รวม 340 ตัว ซึ่งมีแค่ตึกกับกำแพงเขตแดนเท่านั้นที่ใหญ่พอจะมีความหมาย จะ print ออกมาประมาณนี้:
Marked 27 objects as occluders (largest axis >= 3 m).
หลังจาก mark เสร็จแล้วเท่านั้นถึงจะ bake จริง ๆ ได้ จากแท็บ Bake ของ window Occlusion Culling ตัวเดียวกัน พอ bake เสร็จ การเดินกล้องผ่านฉากแล้วดู Frame Debugger จะเห็น draw call หายไปทั้งหมดสำหรับ object รอบมุมตึกหรือหลังกำแพงที่กล้องมองผ่านไม่ได้ — object พวกนั้นไม่มีทางไปถึง GPU เลยในเฟรมนั้น
LOD ลดรายละเอียดของ object ตามระยะที่ไกลขึ้น ส่วน frustum กับ occlusion culling เอา object ที่กล้องมองไม่เห็นเลยออกไป มีเครื่องมือที่สามที่ตรงไปตรงมากว่ามากอยู่ข้าง ๆ ทั้งสองอย่างนี้: แค่ปฏิเสธที่จะวาด object ทั้งหมวดหมู่เมื่อเลยระยะที่กำหนดไว้ ไม่สนใจขนาดบนจอหรือมองเห็นได้ไหม layerCullDistances ของกล้องตั้งค่าแบบนั้นได้เป๊ะ ๆ — การตัดระยะวาดแยกต่างหากต่อ layer (ระบบ layer เดียวกับที่ใช้ใน physics collision matrix กับ raycasting) ดังนั้น prop เล็ก ๆ หายไปที่ 30 เมตรได้ ในขณะที่ต้นไม้ยังมองเห็นได้ถึง 100 เมตร โดยไม่ต้องแตะ setting ของ LOD ทั้งสองอย่างเลย
using UnityEngine;
public class SetupLayerCullDistances : MonoBehaviour
{
public Camera cam;
void Start()
{
float[] distances = new float[32]; // one slot per Unity layer, 0-31
distances[LayerMask.NameToLayer("SmallProps")] = 30f;
distances[LayerMask.NameToLayer("Trees")] = 100f;
// any layer left at 0 falls back to cam.farClipPlane instead
cam.layerCullDistances = distances;
cam.layerCullSpherical = true; // distance from camera position, not view-plane depth
}
}
ความต่างระหว่าง layerCullSpherical = true กับค่า default false มีผลใกล้ ๆ ขอบจอ: ค่า default วัดระยะทางตามแกนหน้ากล้องอย่างเดียว (view-plane depth) ดังนั้น object ที่อยู่ไกลไปทางด้านข้างแต่ใกล้ในความหมายของแกนหน้ากล้อง อาจถูกนับว่า "ใกล้" ทั้งที่ระยะจริงจากกล้องไกลกว่านั้นมาก spherical culling วัดระยะทางเส้นตรงจริง ๆ แทน ซึ่งแม่นยำกว่าแต่เสีย CPU ต่อ object เพิ่มขึ้นนิดหน่อยในการคำนวณ — คุ้มค่าสำหรับการตัดระยะที่ต้องดูสม่ำเสมอทั่วทั้งจอ ไม่ใช่แค่ตรงหน้าอย่างเดียว
เครื่องมือนี้ตั้งใจให้หยาบกว่า LOD — ไม่มี fade, ไม่มี mesh ที่ simplify แล้ว มีแค่ปรากฏหรือไม่ปรากฏ — ซึ่งเป็นเหตุผลพอดีที่มันเหมาะกับหมวดหมู่ที่การตัดแบบเด็ดขาดยอมรับได้หรือถึงขั้นต้องการด้วยซ้ำ: ของกระจุกกระจิกเล็ก ๆ ที่ผู้เล่นไม่มีทางสนใจอยู่แล้ว, ต้นไม้พื้นหลังไกล ๆ ที่ fog effect จะบังไว้อยู่ดี หรือ gizmo สำหรับ debug ที่ไม่ควรโผล่ใน build เลยแม้แต่นิดเดียว
ทุกเทคนิคในบทนี้หยุดทำงานแบบเงียบ ๆ ได้ทันทีที่รายละเอียดเล็ก ๆ อย่างหนึ่งเปลี่ยนไป — script แตะ .material แทน .sharedMaterial, prop ตัวหนึ่งได้ baked lighting ที่ต่างจากเพื่อนบ้าน, ใครบางคนทำ scale ของมันติดลบบนแกนหนึ่ง ฉากยังคง render ถูกต้องอยู่ ดังนั้นไม่มีอะไรดูผิดปกติ — มันแค่เสีย draw call มากกว่าที่ควรจะเป็นแบบเงียบ ๆ Frame Debugger (Window > Analysis > Frame Debugger) คือเครื่องมือสำหรับจับเรื่องนี้
workflow ที่ใช้งานได้จริงสำหรับตามหา batch ที่พัง:
คำที่ panel ด้านขวาใช้ ตรงกับ section ต่าง ๆ ในบทนี้โดยตรง:
ลองไล่ตัวอย่างจริงของขั้นตอนนี้ดู: ไล่ดูฉากถนน draw call ที่ 12 ถึง 27 ทั้งหมดโชว์ "Static Batch (16 renderers)" — ดี กลุ่มก้อนหินทั้งหมดรวมกันตามที่คาดไว้ draw call ที่ 28 เป็น draw call เดี่ยว ๆ ที่ไม่ถูก batch สำหรับหินก้อนเดียว และ panel บอกว่า "Renderers have different materials" เลือกหินก้อนนั้นใน Hierarchy แล้วเช็ค Inspector จะเห็นว่า Material slot ของมันชี้ไปที่ "Rock_Mat (Instance)" แทนที่จะเป็น "Rock_Mat" ตัวที่หินก้อนอื่นทุกก้อนใช้ร่วมกัน — มี script เรียก renderer.material.color = tint; กับมันตอนไหนสักตอน แยกสำเนาส่วนตัวออกมาแบบเงียบ ๆ ตรงตามที่คำเตือนใน Section 3 อธิบายไว้เป๊ะ เปลี่ยนบรรทัดนั้นเป็นการเรียก MaterialPropertyBlock ตามที่โชว์ใน Section 6 จะแก้ปัญหาได้โดยไม่เสียสีเฉพาะตัวของหินไป
สาเหตุหลักที่พบบ่อยที่สุดของ batch ที่พัง ในโปรเจกต์จริง คือตัวเดียวกับที่ Section 3 กับ 6 เตือนไว้แยกกัน: มีบางที่ บางจุด อ่าน .material แทน .sharedMaterial คุ้มค่าที่จะค้นหา codebase หา .material (ไม่รวมที่ match กับ .sharedMaterial) เป็นการเคลื่อนไหวแรกทุกครั้งที่จำนวน draw call ของฉากดูสูงกว่าที่ควรจะเป็น
LODGroup ใช้ตัดสินใจสลับCamera.layerCullDistances แยกอิสระจาก LOD หรือ far clip plane โดยรวมของกล้องMeshRenderer ของตัวเอง ทำให้ Frame Debugger โชว์ 500 draw call เทคนิคไหนเหมาะกว่ากันในกรณีนี้ static batching หรือ GPU instancing? เขียน C# ที่ set up เทคนิคที่เราเลือก แล้วอธิบายหนึ่งประโยคว่าทำไมถึงเลือกอันนั้นแทนอีกอันGPU instancing เหมาะกว่า Static batching ก็ลด draw call ลงมาเหลือประมาณ 1 ได้เหมือนกัน แต่มันก็อปปี้ vertex data เต็ม ๆ ของหินเข้าไปใน combined buffer หนึ่งครั้งต่อ instance — 500 สำเนาของ mesh เดียวกันเป๊ะ อย่างสิ้นเปลือง เพราะ static batching ไม่มีทางรู้ว่าทั้ง 500 สำเนาเป็น mesh เดียวกันเป๊ะ ๆ GPU instancing เก็บ mesh ไว้แค่สำเนาเดียวใน memory และเก็บแค่ transform matrix เล็ก ๆ ต่อหินหนึ่งก้อน ซึ่งทั้งเบากว่ามากในแง่ memory และไม่ต้องบังคับให้หินแข็งค้างอยู่กับที่ถาวรแบบที่ static batching ทำอยู่จริง ๆ
using UnityEngine;
public class InstancedRockField : MonoBehaviour
{
public Mesh rockMesh;
public Material rockMaterial; // "Enable GPU Instancing" checked
public int rockCount = 500;
Matrix4x4[] matrices;
void Start()
{
matrices = new Matrix4x4[rockCount]; // 500 fits under the 1023 limit, one call is enough
for (int i = 0; i < rockCount; i++)
{
Vector3 pos = new Vector3(Random.Range(-40f, 40f), 0f, Random.Range(-40f, 40f));
Quaternion rot = Quaternion.Euler(0f, Random.Range(0f, 360f), 0f);
matrices[i] = Matrix4x4.TRS(pos, rot, Vector3.one);
}
}
void Update()
{
Graphics.DrawMeshInstanced(rockMesh, 0, rockMaterial, matrices);
}
}
using UnityEngine;
public class TintedCrate : MonoBehaviour
{
void Start()
{
Renderer r = GetComponent<Renderer>();
r.material.color = new Color(Random.value, Random.value, Random.value);
}
}
อธิบายให้ชัดว่าทำไมสิ่งนี้ถึงทำให้ batching พัง แล้วเขียนใหม่ให้ยังคงสีสุ่มต่อลังไว้ได้ พร้อมกับยังมีสิทธิ์ batch อยู่บั๊กอยู่ที่ r.material ครั้งแรกที่โค้ดไหนก็ตามอ่าน .material (ไม่ใช่ .sharedMaterial) Unity จะสร้างสำเนา material ส่วนตัวของ renderer นั้นขึ้นมาให้มันโดยเฉพาะ เพื่อให้แก้ไขได้โดยไม่กระทบใคร เรื่องนี้เกิดกับลังทั้ง 200 ใบ ดังนั้นทั้ง 200 ใบจบลงด้วย material instance แยกกัน 200 ตัว — นี่คือเหตุผลพอดีที่ Frame Debugger รายงาน "different materials" ถูกต้องสำหรับทุกใบ หลังจากบรรทัดนี้รันแล้ว พวกมันเป็น material คนละตัวจริง ๆ
วิธีแก้คือ MaterialPropertyBlock: มันให้เรา override ค่าต่อ draw call อย่างเช่นสี ได้โดยไม่ต้องสร้าง material ใหม่หรือแตะ sharedMaterial เลย
using UnityEngine;
public class TintedCrate : MonoBehaviour
{
static MaterialPropertyBlock block;
void Start()
{
if (block == null) block = new MaterialPropertyBlock();
Renderer r = GetComponent<Renderer>();
Color randomTint = new Color(Random.value, Random.value, Random.value);
block.SetColor("_Color", randomTint);
r.SetPropertyBlock(block);
// r.sharedMaterial is never touched, so all 200 crates still
// point at the exact same Material asset and stay batchable.
}
}
รายละเอียดเพิ่มเติมที่น่าสังเกต: การใช้ MaterialPropertyBlock instance แบบ static ตัวเดียวซ้ำข้าม Start() ทั้ง 200 ครั้งปลอดภัย เพราะ SetPropertyBlock ก็อปปี้เนื้อหาของมันเข้า renderer ทันที — object block ตัวเดียวกันเติมใหม่แล้วใช้ซ้ำได้ 200 ครั้งติดกันโดยที่ลังแต่ละใบไม่รบกวนกัน
DistanceForScreenHeight จาก Section 9 คำนวณระยะทั้งสองเป็นเมตร แล้วอธิบายวิธีหนึ่งที่เราจะเช็คซ้ำว่าตัวเลขพวกนี้โอเคจริงไหมตอนเกมรันจริง ๆfovRad = 50 * (pi / 180) =~ 0.8727 rad
tan(fovRad / 2) = tan(0.4363) =~ 0.4663
distance = objectHeight / (2 * screenRelativeHeight * tan(fov/2))
LOD1 distance = 8 / (2 * 0.25 * 0.4663) = 8 / 0.23315 =~ 34.31 m
culled distance = 8 / (2 * 0.03 * 0.4663) = 8 / 0.027978 =~ 285.90 m
ดังนั้นต้นไม้ต้นนี้ควรสลับไปใช้ mesh รายละเอียดต่ำที่ระยะประมาณ 34 เมตร และหายไปเลยที่ระยะประมาณ 286 เมตร ด้วยความสูงกับ FOV แบบนี้
วิธีเช็คซ้ำ: เข้า Play mode เดินหรือบินกล้องออกห่างจากต้นไม้ต้นเดียวตรง ๆ แล้วดู colored LOD strip ของ Scene view ที่มุมขวาบน พร้อมแอบดูระยะทางแบบ world-space ไปด้วย (ดูได้จาก debug overlay หรือแค่เช็ค Vector3.Distance ใน script ชั่วคราว) ถ้า mesh ของ LOD1 ดูเรียบง่ายกว่า mesh เต็มอย่างเห็นได้ชัด และการสลับเกิดขึ้นใกล้ ๆ กับ 34 เมตรที่คำนวณไว้ แสดงว่าคณิตศาสตร์กับ asset ตรงกัน ถ้า pop ยังดูชัดอยู่ดีแม้ที่ระยะที่ถูกต้อง นั่นไม่ใช่ปัญหาคณิตศาสตร์ — มันหมายความว่า LOD1 เองต้องการรายละเอียดเพิ่มอีกรอบ หรือ transition ต้องเปิด option LOD cross-fade ของ Unity เพื่อ blend แทนที่จะสลับทันที