เกมที่สร้างด้วย Unity หรือ Unreal มักจะรันบน PC ของนักพัฒนาเป็นที่แรก ซึ่งไม่ขาดอะไรเลย มี RAM เหลือเฟือ มีการ์ดจอที่มีหน่วยความจำเป็นของตัวเอง มีปลั๊กไฟไม่จำกัด และมีพัดลมช่วยระบายความร้อนออกไป พอเกมเดียวกันต้องไปรันบนมือถือ สมมติฐานเกือบทั้งหมดนั้นพังทันที มือถือไม่มีพัดลม มีแบตเตอรี่แทนปลั๊กไฟ และมีชิปตัวเดียวทำหน้าที่แทนชิ้นส่วนหลายชิ้นของ PC พร้อมกัน บทนี้จะพูดถึงข้อจำกัดที่มาจากความต่างนี้ — thermal throttling, การกินแบต, เพดานหน่วยความจำที่เข้มงวด, GPU คนละแบบ, และหน้าจอกับอินพุตที่หลากหลายกว่า PC มาก — พร้อมทั้งวิธีปฏิบัติทางวิศวกรรมที่เป็นรูปธรรม ส่วนใหญ่เป็น C# สำหรับ Unity ที่ทำให้เกม live-service บนมือถือเล่นได้ลื่นทั้งบนมือถือราคาประหยัดอายุสามปีและเรือธงรุ่นใหม่ล่าสุดในเวลาเดียวกัน
มันเป็นเรื่องง่ายที่จะคิดว่ามือถือก็แค่ PC ตัวเล็กที่แรงน้อยกว่า — แนวคิดเดียวกัน แค่ teraflops น้อยกว่า แต่โมเดลความคิดแบบนี้ทำให้เกิดบัคจริง ๆ เพราะมือถือไม่ได้แค่มีทุกอย่างน้อยกว่า PC เท่านั้น มันถูกสร้างขึ้นบนชุด trade-off ที่ต่างกันจริง ๆ
เกือบทุกชิ้นส่วนใน PC สำหรับเล่นเกมเป็นชิ้นส่วนแยกกันทางกายภาพ: CPU, การ์ดจอที่มีหน่วยความจำวิดีโอ (VRAM) ของตัวเอง, แหล่งจ่ายไฟที่เสียบเข้าปลั๊กไฟ, และพัดลมหรือ heat pipe ที่พาความร้อนออกจากเคส มือถือบีบอัด CPU, GPU, และตัวควบคุมหน่วยความจำเข้าไปในชิปตัวเดียวที่เรียกว่า SoC (system on a chip — ชิปที่รวมชิ้นส่วนหลักของคอมพิวเตอร์ทั้งหมดผลิตมาด้วยกันบนซิลิคอนแผ่นเดียว) รันด้วยแบตเตอรี่ ปิดผนึกอยู่ในเคสที่ไม่มีพัดลมเลย เพราะ CPU กับ GPU ใช้ชิปหน่วยความจำตัวเดียวกันแทนที่ GPU จะมี VRAM แยกเป็นของตัวเอง พูลหน่วยความจำที่ใช้ร่วมกันนี้มักถูกเรียกว่า unified memory
ความต่างแต่ละอย่างนี้กลายเป็นข้อจำกัดทางวิศวกรรมจริง ๆ และบทนี้จะพูดถึงแต่ละอย่างตามลำดับ: ความร้อนที่ไม่มีทางไป (thermal throttling), แบตเตอรี่ที่หมดเร็วขึ้นเมื่อชิปทำงานหนักขึ้น, ขีดจำกัดหน่วยความจำที่ระบบปฏิบัติการบังคับใช้ด้วยการปิดเกมของคุณทิ้ง, GPU ที่สร้างขึ้นบนหลักการเรนเดอร์ที่ต่างจาก GPU ของ PC โดยสิ้นเชิง, และความหลากหลายของหน้าจอกับวิธีการอินพุตที่มากกว่าที่ PC ต้องรับมือมาก เกม RPG live-service บนมือถือต้องรับมือกับทั้งหมดนี้พร้อมกัน ทุกวัน บนอุปกรณ์จริงนับล้านเครื่องที่ไม่เคยถูกทดสอบโดยตรงมาก่อน
Thermal throttling คือการที่ firmware ของชิปตั้งใจลด clock speed ของ CPU/GPU (จำนวนคำสั่งต่อวินาทีที่ชิปรันได้) ลง เมื่อเซนเซอร์วัดอุณหภูมิภายในข้ามเกณฑ์ความปลอดภัยที่ตั้งไว้ นี่ไม่ใช่บัค และไม่ใช่สิ่งที่คุณสามารถปิดได้จากโค้ดของเกม — มันคือกลไกป้องกันที่ฝังอยู่ในฮาร์ดแวร์เพื่อไม่ให้ชิปทำร้ายตัวเองหรือทำให้มือผู้เล่นร้อนจนไหม้
PC จัดการความร้อนด้วยอากาศที่เคลื่อนที่: พัดลมพัดอากาศเย็นผ่าน heatsink พาความร้อนออกจากเคส มือถือไม่มีสิ่งเหล่านั้นเลย ทางเดียวที่ความร้อนจะออกจากมือถือได้คือการนำความร้อนผ่านตัวเครื่องออกสู่อากาศรอบข้างอย่างช้า ๆ ซึ่งช้ากว่าพัดลมมาก รันชิปหนัก ๆ ไปสักสองสามนาที ความร้อนจะสะสมเร็วกว่าที่มันระบายออกได้ ดังนั้น firmware จึงลด clock speed ลงจนอุณหภูมิคงที่
ส่วนที่อันตรายสำหรับนักพัฒนาคือ throttling จะแสดงผลก็ต่อเมื่อมีโหลดต่อเนื่องเท่านั้น นี่คือสคริปต์ง่าย ๆ ที่บันทึกค่าเฉลี่ย frames per second (fps) ทุกช่วง 30 วินาที เพื่อให้คุณเห็นสิ่งที่เกิดขึ้นจริงตลอดทั้งเซสชัน ไม่ใช่แค่ช่วงเวลาเดียว:
using UnityEngine;
public class FpsSessionLogger : MonoBehaviour
{
float windowTime = 0f;
int windowFrames = 0;
float windowFpsSum = 0f;
void Update()
{
float fps = 1f / Time.unscaledDeltaTime;
windowFpsSum += fps;
windowFrames++;
windowTime += Time.unscaledDeltaTime;
if (windowTime >= 30f)
{
float avgFps = windowFpsSum / windowFrames;
Debug.Log("avg fps this 30s window: " + avgFps.ToString("F1"));
windowTime = 0f;
windowFrames = 0;
windowFpsSum = 0f;
}
}
}
ตัวอย่าง log ที่เป็นตัวแทนจากมือถือระดับกลางจริง ๆ ที่เล่นฉากที่กราฟิกหนักเป็นเวลา 20 นาที อาจมีหน้าตาแบบนี้ (ตัวเลขจริงต่างกันไปตามอุปกรณ์ แต่รูปแบบนี้พบได้บ่อยมาก):
[0:30] avg fps: 59.8
[1:00] avg fps: 59.6
[1:30] avg fps: 58.1
[2:00] avg fps: 54.3
[2:30] avg fps: 47.9
[3:00] avg fps: 41.2
[3:30] avg fps: 33.6
[4:00] avg fps: 30.4
[4:30] avg fps: 30.1
[10:00] avg fps: 29.9
[20:00] avg fps: 29.8
30 วินาทีแรกดูดีมาก — การรัน benchmark ระยะสั้นแบบนี้จะรายงานค่าใกล้ 60 fps และผ่าน แต่ ณ จุดนั้นชิปยังไม่มีเวลาร้อนขึ้นเลย พอถึงนาทีที่สามหรือสี่ โหลดต่อเนื่องทำให้อุณหภูมิของชิปสูงพอที่ firmware throttling จะเริ่มทำงาน และ frame rate จะตกลงมาคงที่ที่ค่าใหม่ที่ต่ำกว่า อย่างต่อเนื่อง ตลอดเซสชันที่เหลือ เซสชันของ RPG live-service มักจะยาว 15-30 นาทีต่อครั้ง (ดันเจี้ยนรายวัน, อีเวนต์, บอสไฟต์) — นานพอที่ throttling จะมีผลบนอุปกรณ์จริงเกือบทุกเครื่อง ทุกวัน
การรัน CPU และ GPU ที่ clock speed สูงไม่ได้แค่สร้างความร้อนเท่านั้น — ความร้อนคือส่วนของพลังงานไฟฟ้าที่ชิปดึงมาแล้วสูญเสียไป ยิ่งชิปทำงานหนักเท่าไหร่ มันก็ยิ่งดึงพลังงานจากแบตเตอรี่มากขึ้นและสร้างความร้อนมากขึ้นในเวลาเดียวกัน ด้วยสาเหตุเดียวกัน thermal throttling (ข้อ 2) และการกินแบตเป็นอาการสองอย่างจากสาเหตุเดียวกันเป๊ะ ๆ
แบตเตอรี่มือถือมักถูกระบุหน่วยเป็น mAh (milliamp-hours หน่วยของประจุไฟฟ้า — พูดง่าย ๆ คือแบตเตอรี่จ่ายกระแสไฟได้เท่าไหร่นานแค่ไหน) ที่แรงดันไฟตามมาตรฐานประมาณ 3.85V คูณสองค่านี้เข้าด้วยกันจะได้พลังงานรวมของแบตเตอรี่เป็น Wh (watt-hours):
battery capacity = 4000 mAh = 4.0 Ah
nominal voltage = 3.85 V
total energy = 4.0 Ah * 3.85 V = 15.4 Wh
demanding 3D game drawing ~4.0 W continuously:
playtime = 15.4 Wh / 4.0 W = 3.85 hours
well-optimized game drawing ~2.0 W continuously:
playtime = 15.4 Wh / 2.0 W = 7.7 hours
แบตเตอรี่เดียวกัน มือถือเครื่องเดียวกัน แต่เวลาเล่นได้ต่างกันเกือบสองเท่า แค่จากปริมาณพลังงานที่การเรนเดอร์และการจำลองของเกมต้องการในแต่ละวินาที ช่องว่างนี้ไม่ใช่รายละเอียดเล็ก ๆ สำหรับเกม live-service ที่สตูดิโออยากให้ผู้เล่นเปิดเล่นทุกวัน
ผู้เล่นสังเกตเห็นสองอย่างที่ต่างกันเมื่อเกมกินพลังงานมากเกินไป และทั้งสองอย่างทำร้าย retention:
ยังมีผลที่สามที่เงียบกว่า: เกมที่กินพลังงานมากพอจะกระตุ้น thermal throttling (ข้อ 2) จะทำให้ตัวเองรู้สึกแย่ลงในเวลาเดียวกับที่มันกินแบตเร็วที่สุด เพราะ frame rate ตกลงพอดีตอนที่มือถือร้อนที่สุด ประสบการณ์ที่แย่ที่สุดของผู้เล่นกับความไม่อยากเล่นต่อของพวกเขาเกิดขึ้นพร้อมกัน ไม่ได้แยกกัน
PC ที่ RAM เหลือน้อยจะทำงานช้าลง — ระบบปฏิบัติการเริ่มใช้พื้นที่ดิสก์เป็นหน่วยความจำสำรอง (เรียกว่า paging หรือ swapping) ซึ่งช้ากว่า RAM มากแต่ยังทำให้โปรแกรมทำงานต่อไปได้ มือถือส่วนใหญ่ไม่มีตาข่ายนิรภัยแบบนั้นให้แอป ระบบปฏิบัติการมือถือหลักทั้งสองระบบคอยจับตาดูว่าแต่ละแอปใช้หน่วยความจำเท่าไหร่ และเมื่อแอปข้ามขีดจำกัด — ไม่ว่าจะเป็นขีดจำกัดตายตัวต่อแอป หรือระบบทั้งหมดขาดหน่วยความจำ — OS ก็แค่ฆ่าแอปทิ้งเลย ไม่มีกล่องเตือน ไม่มี exception ที่ผู้เล่นแคปหน้าจอได้ ไม่มี crash log ให้ส่งมาหาคุณ เว้นแต่คุณจะสร้างระบบรายงานของตัวเองไว้ก่อน เกมแค่หายไปจากหน้าจอเฉย ๆ
Android เรียกกลไกนี้ว่า low memory killer / out-of-memory (OOM) killer; iOS เรียกเวอร์ชันของตัวเองว่า jetsam ชื่อต่างกันแต่แนวคิดเดียวกัน: แรงกดดันหน่วยความจำข้ามเส้น แล้วแอปที่อยู่หน้าจอถูกปิดเพื่อปกป้องระบบส่วนที่เหลือ
ทั้งสองบริษัทไม่ได้เผยแพร่ตัวเลขที่แน่นอนตายตัวว่าเส้นนั้นอยู่ตรงไหน — มันขึ้นอยู่กับ RAM รวมของอุปกรณ์, เวอร์ชัน OS, ซอฟต์แวร์เฉพาะของผู้ผลิตที่ครอบทับ Android อยู่, และมีแอปอื่นเปิดอยู่เบื้องหลังกี่ตัว ตัวเลขที่ตามมาต่อไปนี้เป็นตัวเลขคร่าว ๆ สำหรับวางแผนเท่านั้น ไม่ใช่การรับประกัน และคุณควรตรวจสอบพฤติกรรมจริงกับอุปกรณ์จริงและ kill log จริงเสมอก่อนจะเชื่อ budget ใด ๆ:
สังเกตรูปแบบนี้: budget ของอุปกรณ์ระดับล่างไม่ได้แค่ "น้อยกว่า" เรือธงนิดหน่อย — มันอาจต่างกันเป็นสิบเท่าได้เลย ชุดเท็กซ์เจอร์, audio bank, หรือด่านที่พอดีกับเรือธงสบาย ๆ สามารถทำให้เกมเดียวกันนั้น crash ทันทีบนมือถือราคาประหยัดอายุสามปี โดยไม่มีข้อความ error อะไรเลยนอกจาก "แอปปิดไปแล้ว"
คุณสามารถดูการใช้หน่วยความจำของเกมตัวเองระหว่างรันได้ด้วย profiler API ของ Unity ซึ่งมีประโยชน์ในการจับ memory leak (หน่วยความจำที่โตขึ้นเรื่อย ๆ และไม่เคยถูกปล่อยคืน) ก่อนที่อุปกรณ์ของผู้เล่นจะจับมันแทนคุณ:
using UnityEngine;
using UnityEngine.Profiling;
public class MemoryWatcher : MonoBehaviour
{
public float warnThresholdMB = 500f;
void Update()
{
long usedBytes = Profiler.GetTotalAllocatedMemoryLong();
float usedMB = usedBytes / (1024f * 1024f);
if (usedMB > warnThresholdMB)
{
Debug.LogWarning("Memory over budget: " + usedMB.ToString("F1") + " MB");
}
}
}
GPU เดสก์ท็อปและคอนโซลส่วนใหญ่เรนเดอร์ด้วยวิธีที่เรียกว่า immediate mode: แต่ละ draw call ระบายสี pixel ของตัวเองแล้วเขียนออกไปยังพูล VRAM (video memory) ขนาดใหญ่โดยตรง ผ่านบัสหน่วยความจำที่กว้างและเร็วมาก ถ้าสามเหลี่ยมสองรูปซ้อนทับกันบนหน้าจอ ทั้งคู่จะถูกระบายสีและเขียนลง VRAM — รูปที่สองก็แค่เขียนทับรูปแรก งานที่ทำซ้ำบน pixel ที่ซ้อนทับกันแบบนี้เรียกว่า overdraw และ GPU ของ PC มักรับมือกับมันได้พอสมควรเพราะบัสหน่วยความจำมี bandwidth เหลือเฟือ
GPU มือถือเกือบทั้งหมดทำงานต่างออกไป ด้วยวิธีที่เรียกว่า tile-based deferred rendering (TBDR): หน้าจอถูกแบ่งออกเป็น tile เล็ก ๆ และแต่ละ tile ถูกประมวลผลเกือบทั้งหมดด้วยหน่วยความจำ on-chip ที่เล็กแต่เร็วมาก ก่อนที่ผลลัพธ์สุดท้ายจะถูกเขียนออกไปยังหน่วยความจำหลักแค่ครั้งเดียว
นี่เป็นข่าวดีจริง ๆ สำหรับ geometry แบบทึบแสง (วัตถุทึบที่ไม่มีความโปร่งใส) — GPU มือถือมักรับมือกับวัตถุทึบที่ซ้อนทับกันได้ถูกกว่าที่คุณคาดไว้ เพราะมันคำนวณหาก่อนว่า pixel ไหนจะปรากฏจริง ๆ ก่อนที่จะเสียงานระบายสีไปกับมัน แต่มีเทคนิคการเรนเดอร์ที่พบบ่อยสองอย่างที่ทำลายกลไกที่ทำให้มันเร็วนี้:
เรื่องนี้มีผลโดยตรงกับ mobile action RPG ที่สร้างขึ้นรอบการต่อสู้ที่หวือหวา: สกิลธาตุของตัวละครหนึ่งตัวอาจสร้าง particle effect ที่ซ้อนทับกันสิบกว่าตัว, แฟลชเต็มจอ, และ bloom pass ทั้งหมดพร้อมกัน ทุกอย่างนั้นคือประเภทงานที่ TBDR รับมือได้แย่ที่สุด บนฮาร์ดแวร์ที่มีที่ว่างเหลือน้อยที่สุดพอดี — นี่คือเหตุผลที่ mobile build ของเกมพวกนี้มักจำกัดจำนวน particle และลดความซับซ้อนของ post-processing เมื่อเทียบกับ PC build ของสกิลเดียวกัน
ไฟล์เท็กซ์เจอร์อย่าง PNG หรือ JPEG บนดิสก์ถูกบีบอัดเพื่อขนาดไฟล์ แต่พอโหลดขึ้นมาแล้ว GPU ต้องการข้อมูล pixel ดิบที่ไม่บีบอัดเพื่อเรนเดอร์มัน — ดังนั้นเกมมักจะคลายบีบอัดทั้งหมดลงในหน่วยความจำก่อน GPU texture compression format ต่างออกไป: มันเป็นฟอร์แมตที่ฮาร์ดแวร์ของ GPU เองอ่านและถอดรหัสได้แบบ on the fly ทีละ pixel ระหว่างเรนเดอร์ ทำให้เท็กซ์เจอร์คงอยู่ในสถานะบีบอัดตลอดเวลาที่ถูกใช้งาน ความต่างนี้คือคันโยกที่ใหญ่ที่สุดที่คุณมีต่อว่าเท็กซ์เจอร์ของคุณกินหน่วยความจำจริงเท่าไหร่
ASTC (Adaptive Scalable Texture Compression) คือฟอร์แมต GPU compression มาตรฐานสมัยใหม่ทั้งบน GPU Android รุ่นปัจจุบันและอุปกรณ์ iOS/Metal จุดเด่นของมันคือสามารถเลือก block size ได้ (ขนาดพิกเซลของกลุ่ม pixel ที่ถูกบีบอัดรวมกัน) ตั้งแต่ 4x4 (คุณภาพสูงสุด ขนาดใหญ่สุด) ไปจนถึง 12x12 (คุณภาพต่ำสุด ขนาดเล็กสุด) ทุก ASTC block ไม่ว่า block size จะเป็นเท่าไหร่ ใช้พื้นที่จัดเก็บ 128 บิต (16 ไบต์) เสมอ ดังนั้น block size ที่ใหญ่ขึ้นก็แค่กระจาย 16 ไบต์เท่าเดิมไปยัง pixel มากขึ้น:
1024 x 1024 texture, one mip level
format bytes per pixel total size
RGBA32 (raw) 4.00 4.00 MB
ASTC 4x4 1.00 1.00 MB
ASTC 6x6 0.44 0.44 MB
ASTC 8x8 0.25 0.25 MB
ASTC 12x12 0.11 0.11 MB
การเลือกฟอร์แมตผิดสำหรับแพลตฟอร์มหนึ่งเป็นวิธีง่ายมากที่จะทำให้ budget หน่วยความจำระเบิดโดยไม่รู้ตัว: เท็กซ์เจอร์ RGBA32 ที่ไม่บีบอัด หรือฟอร์แมตเก่าที่หลงเหลือจากโปรเจกต์เก่า อาจกินหน่วยความจำมากกว่าเท็กซ์เจอร์เดียวกันที่เก็บเป็น ASTC ที่ block size สมเหตุสมผลถึงสี่ถึงแปดเท่า สำหรับความต่างด้านคุณภาพภาพที่ผู้เล่นส่วนใหญ่แทบไม่สังเกตเห็นบนหน้าจอมือถือที่ถืออยู่ห่างช่วงแขน
ใน Unity texture compression ถูกตั้งค่าแยกต่อแพลตฟอร์มผ่าน import settings ของเท็กซ์เจอร์ และควรตั้งค่ามันอย่างชัดเจนแทนที่จะเชื่อค่า default:
#if UNITY_EDITOR
using UnityEditor;
using UnityEngine;
public class SetMobileTextureFormat
{
[MenuItem("Tools/Set ASTC 6x6 For Selected Textures")]
static void SetAstc()
{
foreach (Object obj in Selection.objects)
{
string path = AssetDatabase.GetAssetPath(obj);
TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter;
if (importer == null) continue;
TextureImporterPlatformSettings settings = importer.GetPlatformTextureSettings("Android");
settings.overridden = true;
settings.format = TextureImporterFormat.ASTC_6x6;
settings.compressionQuality = 50;
importer.SetPlatformTextureSettings(settings);
importer.SaveAndReimport();
}
}
}
#endif
กฎที่ใช้ได้จริงที่โปรเจกต์มือถือส่วนใหญ่ลงตัว: ใช้ block size ที่เล็กและคุณภาพสูงกว่าอย่าง ASTC 4x4 หรือ 6x6 สำหรับสิ่งที่ผู้เล่นมองใกล้ ๆ และบ่อย ๆ — หน้าตัวละคร, ไอคอน UI, สกินอาวุธฮีโร่ — และ block size ที่ใหญ่และถูกกว่าอย่าง ASTC 8x8 สำหรับสิ่งที่เห็นจากระยะไกลหรือแค่ชั่วครู่ — ฉากหลัง, ของประดับสภาพแวดล้อม, ภูมิประเทศระยะไกล การสูญเสียคุณภาพที่ block size ใหญ่ขึ้นสังเกตเห็นได้ยากกว่ามากบนฉากหลังที่รกกว่าบนหน้าตัวละครที่ซูมใกล้
ความละเอียดที่เกมเรนเดอร์ไม่จำเป็นต้องตรงกับความละเอียด native ของหน้าจอมือถือ การเรนเดอร์ที่ความละเอียดภายในที่ต่ำกว่าแล้วขยายภาพที่เสร็จแล้วให้เต็มหน้าจอ ช่วยลดจำนวน pixel ที่ GPU ต้องระบายสีในแต่ละเฟรม (ต้นทุนนี้มักถูกเรียกว่า fill-rate จำนวน pixel ที่ GPU ระบายสีได้ต่อวินาที) ซึ่งมีผลมากที่สุดตรงจุดที่ข้อ 5 บอกว่า GPU มือถืออ่อนแอที่สุด: overdraw, alpha blending, และ full-screen effect ซึ่งล้วนแพงขึ้นเมื่อ pixel ยิ่งเยอะ
เวอร์ชันที่ง่ายที่สุดของสิ่งนี้คือ render scale แบบตายตัวที่เลือกตาม device tier (ข้อ 9) — เช่น เรนเดอร์ที่ 75% resolution บนมือถือระดับล่างและ 100% บนเรือธง เวอร์ชันที่ก้าวหน้ากว่าคือ dynamic resolution ซึ่งปรับ render scale อย่างต่อเนื่องระหว่างรัน โดยอิงจากประสิทธิภาพจริงของเกม ณ ตอนนั้น แทนที่จะยึดตัวเลขตายตัวค่าเดียว:
using UnityEngine;
public class DynamicResolutionController : MonoBehaviour
{
public float targetFrameTimeMs = 16.6f; // 60 fps budget
public float minScale = 0.6f;
float currentScale = 1f;
void Update()
{
float frameMs = Time.unscaledDeltaTime * 1000f;
if (frameMs > targetFrameTimeMs * 1.15f && currentScale > minScale)
{
currentScale = Mathf.Max(minScale, currentScale - 0.05f);
ScalableBufferManager.ResizeBuffers(currentScale, currentScale);
}
else if (frameMs < targetFrameTimeMs * 0.9f && currentScale < 1f)
{
currentScale = Mathf.Min(1f, currentScale + 0.02f);
ScalableBufferManager.ResizeBuffers(currentScale, currentScale);
}
}
}
ไล่ trace ไม่กี่เฟรมของฉากต่อสู้ที่วุ่นวาย โดยตั้งเป้าไว้ที่ 16.6ms (60 fps):
frame frameMs action scale after
100 15.9 within budget, no change 1.00
101 19.8 over 1.15x budget -> scale down 0.95
102 20.1 still over -> scale down 0.90
103 18.4 still over -> scale down 0.85
104 16.0 within budget, no change 0.85
105 13.2 under 0.9x budget -> scale up 0.87
scale จะลดลงอย่างรวดเร็วเมื่อ frame time พุ่งขึ้น (ช่วงต่อสู้หนัก ๆ) และค่อย ๆ ไต่กลับขึ้นอย่างช้า ๆ เมื่อทุกอย่างสงบลง เกมจึงใช้เวลาส่วนใหญ่อยู่ใกล้ full resolution เท่าที่มันจะรับไหว แทนที่จะเลือกจุดประนีประนอมค่าเดียวตายตัวแล้วทนใช้มันทั้งช่วงที่สงบและช่วงที่วุ่นวาย
ทุกเฟรมมี time budget: ที่ 30 fps เกมมีเวลา 33.3 มิลลิวินาทีในการทำทุกอย่างที่จำเป็นสำหรับหนึ่งเฟรม; ที่ 60 fps มันมีแค่ 16.6 มิลลิวินาที — สั้นกว่าครึ่งหนึ่ง การทำ 60 fps ให้คงที่ โดยเฉพาะเมื่อมือถือเล่นมาแล้วสิบนาทีและ thermal throttling (ข้อ 2) ได้ลดพลังงานที่ชิปใช้ได้ไปแล้ว เป็นเป้าหมายที่ยากกว่าการทำ 30 fps มาก
30 fps frame budget = 1000 ms / 30 = 33.3 ms per frame
60 fps frame budget = 1000 ms / 60 = 16.6 ms per frame
Frame rate ไม่ใช่แค่ตัวเลขบนหน้าจอตั้งค่า — มันเปลี่ยนความรู้สึกในการเล่นเกม เกมแอคชันต่อสู้ที่ผู้เล่นต้องจับจังหวะหลบและคอมโบกับการโจมตีของศัตรูที่รวดเร็ว ได้ประโยชน์มากจาก 60 fps เพราะทุกเฟรมที่เพิ่มขึ้นคือข้อมูลเพิ่มเติมว่าควรตอบสนองตอนไหนพอดี เกมแบบ turn-based ที่ผู้เล่นเลือกแอคชันแล้วดูผลลัพธ์ที่คลี่คลายแล้ว ทนต่อ 30 fps ได้มากกว่า เพราะไม่มีอินพุตที่จับจังหวะแบบไวต่อปฏิกิริยาที่จะรู้สึกหน่วง
เพราะความสามารถของอุปกรณ์ต่างกันมาก (ข้อ 9) และความชอบของผู้เล่นก็ต่างกันด้วย — ผู้เล่นบางคนอยากได้มือถือที่เย็นกว่าและอยู่ได้นานกว่ามากกว่า frame rate ที่ลื่นที่สุด — เกม live-service บนมือถือส่วนใหญ่เปิดให้เลือกตัวเลือกนี้ตรง ๆ ในเมนูตั้งค่า แทนที่จะเลือกเป้าหมายตายตัวค่าเดียวสำหรับทุกคน:
using UnityEngine;
public class FrameRateOption : MonoBehaviour
{
public void SetFrameRateOption(int fps)
{
QualitySettings.vSyncCount = 0; // targetFrameRate is ignored while vSync is on
Application.targetFrameRate = fps; // pass 30 or 60
}
}
Application.targetFrameRate โดยไม่ตั้งค่า QualitySettings.vSyncCount = 0 ควบคู่ไปด้วย ถ้า vSync เปิดอยู่ Unity จะเพิกเฉยต่อ targetFrameRate ทั้งหมด และล็อกไว้ที่อัตราที่ vSync ของจอยอมให้แทน ซึ่งทำให้ตัวเลือก frame rate ที่ผู้เล่นคิดว่าตัวเองเลือกไว้ใช้ไม่ได้ผลโดยไม่รู้ตัวเสนอค่า default ที่สมเหตุสมผลตาม device tier (อุปกรณ์ระดับล่างไม่ควรตั้งค่า default เป็นตัวเลือก 60 fps ที่มันรับไหวจริง ๆ ไม่ได้) แต่ให้ผู้เล่น override ได้เสมอ — ผู้เล่นบางคนบนมือถือระดับกลางจะยินดีรับ frame rate ที่ต่ำกว่าแต่คงที่ แลกกับมือถือที่เย็นตลอดเซสชันยาว ๆ
แทนที่จะปล่อยค่าคุณภาพตายตัวค่าเดียวสำหรับทุกอุปกรณ์ เกมมือถือส่วนใหญ่จัดอุปกรณ์เข้า device tier (กลุ่มอย่าง Low, Mid, และ High) โดยใช้ heuristic ที่เร็ว (กฎประมาณการที่รวดเร็วแทนที่จะเป็นการวัดที่แม่นยำสมบูรณ์) ตอนเปิดแอปครั้งแรก จากนั้นใช้ preset คุณภาพที่ตรงกับ tier ที่ตรวจจับได้โดยอัตโนมัติ แล้วยังคงให้ผู้เล่นเปลี่ยนการตั้งค่าแต่ละอย่างได้ทีหลัง
public enum DeviceTier { Low, Mid, High }
public static class DeviceTierDetector
{
public static DeviceTier Detect()
{
int ramMB = SystemInfo.systemMemorySize;
if (ramMB <= 3072) return DeviceTier.Low;
if (ramMB <= 6144) return DeviceTier.Mid;
return DeviceTier.High;
}
}
using UnityEngine;
public class QualityManager : MonoBehaviour
{
void Start()
{
DeviceTier tier = DeviceTierDetector.Detect();
ApplyTier(tier);
}
void ApplyTier(DeviceTier tier)
{
switch (tier)
{
case DeviceTier.Low:
QualitySettings.SetQualityLevel(0, true);
Application.targetFrameRate = 30;
QualitySettings.shadowDistance = 0f;
ScalableBufferManager.ResizeBuffers(0.75f, 0.75f);
break;
case DeviceTier.Mid:
QualitySettings.SetQualityLevel(1, true);
Application.targetFrameRate = 30;
QualitySettings.shadowDistance = 20f;
break;
case DeviceTier.High:
QualitySettings.SetQualityLevel(2, true);
Application.targetFrameRate = 60;
QualitySettings.shadowDistance = 40f;
break;
}
}
}
การเช็ค RAM ล้วน ๆ เป็นจุดเริ่มต้นที่ดี แต่ไม่สมบูรณ์แบบ — ทุกรุ่นมีมือถือบางรุ่นที่ RAM รวมดูดีบนกระดาษแต่ GPU จริงอ่อนแอ หรือกลับกัน โปรเจกต์มือถือที่จริงจังมักเก็บตารางแทนที่ (override) เล็ก ๆ ไว้สำหรับอุปกรณ์ที่รู้ว่ามีปัญหา (จับคู่ด้วย SystemInfo.deviceModel) เช็คก่อนที่จะ fallback ไปใช้ heuristic แบบ RAM แทนที่จะเชื่อตัวเลขเดียวว่าบอกความจริงเสมอ
เกม live-service แบบ cross-platform มักต้องรองรับ touch บนมือถือ, mouse และ keyboard บน PC, และ gamepad ทั้งบน PC และคอนโซล — สามวิธีที่ต่างกันจริง ๆ ในการบอกเกมว่าผู้เล่นต้องการอะไร ไม่ใช่แค่ผังปุ่มที่ต่างกันสามแบบสำหรับแนวคิดเดียวกัน:
เพราะเกมเดียวกันอาจรันด้วยอินพุตเหล่านี้แบบไหนก็ได้ขึ้นอยู่กับแพลตฟอร์ม (และในบางอุปกรณ์ ผู้เล่นอาจเสียบ gamepad เข้ามาขณะที่ยังถือมือถืออยู่) โค้ด UI และการควบคุมควรตรวจจับว่าจริง ๆ แล้วมีอะไรเชื่อมต่ออยู่ตอนนี้ แทนที่จะสันนิษฐานว่าอินพุตแบบใดแบบหนึ่งมีอยู่เสมอ:
using UnityEngine;
using UnityEngine.InputSystem;
public class InputModeSwitcher : MonoBehaviour
{
public GameObject virtualJoystick;
void Update()
{
bool gamepadConnected = Gamepad.current != null;
virtualJoystick.SetActive(!gamepadConnected && Application.isMobilePlatform);
}
}
เช็คนี้รันทุกเฟรมโดยเฉพาะเพราะอินพุตที่เชื่อมต่ออยู่อาจเปลี่ยนกลางเซสชันได้ — ผู้เล่นอาจเสียบ gamepad เข้ามาระหว่างเล่น — และ UI ควรตอบสนองทันทีแทนที่จะต้องรีสตาร์ทก่อน
หน้าจอมือถือสมัยใหม่ไม่ใช่สี่เหลี่ยมผืนผ้าที่สะอาดในมุมมองของเกม กล้องที่เจาะไว้ (notch หรือรูเจาะที่กล้องหน้าอยู่) อาจซ้อนทับอยู่บนขอบบนของหน้าจอ มุมมักถูกมนจริง ๆ ทางกายภาพ และแถบท่าทางระบบ (gesture bar) อาจอยู่ที่ขอบด้านหนึ่ง — ทั้งหมดนี้คือบริเวณที่ระบบปฏิบัติการอาจวาด UI ของตัวเองทับ UI ของคุณ หรือที่ UI ของคุณจะถูกซ่อนหรือถูกตัดถ้าคุณวางมันตรงนั้น
Unity เปิดให้ใช้ Screen.safeArea: สี่เหลี่ยมที่รายงานโดย OS อธิบายบริเวณของหน้าจอที่รับประกันว่าจะไม่ถูกบดบังโดยสิ่งเหล่านั้น การ anchor UI สำคัญ — health bar, ตัวนับเงินในเกม, ปุ่ม "skip" — ไว้ในสี่เหลี่ยมนี้แทนที่จะเป็นขอบหน้าจอดิบ ทำให้มันมองเห็นได้บนทุกอุปกรณ์ ไม่ว่าจะมี notch หรือไม่ก็ตาม:
using UnityEngine;
public class SafeAreaFitter : MonoBehaviour
{
RectTransform panel;
void Awake()
{
panel = GetComponent<RectTransform>();
Apply();
}
void Apply()
{
Rect safe = Screen.safeArea;
Vector2 anchorMin = safe.position;
Vector2 anchorMax = safe.position + safe.size;
anchorMin.x /= Screen.width;
anchorMin.y /= Screen.height;
anchorMax.x /= Screen.width;
anchorMax.y /= Screen.height;
panel.anchorMin = anchorMin;
panel.anchorMax = anchorMax;
}
}
Screen.safeArea มันดูถูกต้องบนอุปกรณ์ทดสอบของนักพัฒนาเอง แล้วก็ไปซ่อนตัวนับเงินในเกมไว้หลังกล้องที่เจาะไว้ หรือตัดปุ่ม "skip" ใต้มุมมน บนอุปกรณ์ที่นักพัฒนาไม่เคยทดสอบเองเลยมือถือของนักพัฒนาเองแทบทุกครั้งดีกว่ามือถือที่ผู้เล่นส่วนใหญ่มีจริง ๆ มาก — สตูดิโอซื้อฮาร์ดแวร์ที่แรงให้วิศวกรของตัวเอง และมือถือที่แรงมี headroom ด้าน thermal, memory, และ GPU เหลือมากพอที่จะซ่อนปัญหาที่บทนี้พูดถึงพอดี การทดสอบบนอุปกรณ์พัฒนาระดับเรือธงเท่านั้นไม่ได้แค่พลาดบัคบางตัว — มันไม่สามารถหา thermal throttling, memory kill, หรือปัญหาการเรนเดอร์บน GPU ระดับล่างได้เลยโดยโครงสร้าง เพราะเรือธงมี margin เหลือมากพอที่ปัญหาเหล่านั้นจะไม่มีวันถูกกระตุ้นระหว่างเซสชันทดสอบปกติ
same scene, same build, two devices, 12-minute play session
flagship device budget device
start fps 60 45
fps at 12 min 58 22
skin temperature 38C 44C
memory at 12 min 620 MB (fine) 340 MB
result smooth session app killed by OS at 11:40
ไม่มีอะไรในการรันบนเรือธงเลยที่จะบอกคุณได้ว่าอุปกรณ์ราคาประหยัดจะถูก OS ฆ่าทิ้งก่อนที่เซสชันจะจบด้วยซ้ำ บัคนี้มีอยู่จริงเฉพาะบนฮาร์ดแวร์ที่มี headroom ให้ดูดซับน้อยกว่าเท่านั้น
ทางแก้ที่ใช้ได้จริงคือเก็บ device lab ทางกายภาพเล็ก ๆ ไว้ ไม่ใช่พึ่ง simulator หรือมือถือของตัวเองอย่างเดียว: อย่างน้อยหนึ่งเครื่อง Android ที่เก่าหรือถูกโดยตั้งใจ (อายุสองถึงสามปี, RAM น้อย, GPU ระดับล่าง) คู่กับอุปกรณ์ระดับกลาง และรันเซสชันการเล่นเต็ม 15-20 นาทีบนมันเป็นประจำ — ไม่ใช่แค่ smoke test แบบติดตั้งแล้วเปิดดู เพราะทั้ง throttling (ข้อ 2) และการโตของหน่วยความจำ (ข้อ 4) ต้องการเวลาต่อเนื่องถึงจะแสดงผลออกมาให้เห็น
[0:30] 58.9
[1:00] 58.5
[1:30] 57.8
[2:00] 55.0
[2:30] 49.6
[3:00] 42.1
[3:30] 35.4
[4:00] 30.8
[4:30] 30.2
[5:00] 30.0
[5:30] 29.9
[6:00] 29.8
(a) frame rate เริ่มตกลงมาต่ำกว่า 90% ของค่าในช่วงแรกที่จุดไหนโดยประมาณ และมันคงอยู่แบบนั้นต่อไปไหม? (b) คำนวณค่าเฉลี่ย fps จากทั้ง 12 ช่วงที่แสดง (c) อธิบายในหนึ่งหรือสองประโยคว่าทำไมการรัน benchmark 20 วินาทีตอนเริ่มต้นเซสชันนี้จะรายงานตัวเลขที่ทำให้เข้าใจผิด(a) 90% ของค่าในช่วงแรก (58.9) อยู่ที่ประมาณ 53.0 ช่วง 2:00 (55.0) ยังสูงกว่าค่านั้น แต่ช่วง 2:30 (49.6) ตกลงมาต่ำกว่ามัน และทุกช่วงหลังจากนั้นก็ยังคงต่ำกว่าเช่นกัน ดังนั้น throttling จึงเริ่มเห็นได้ชัดตั้งแต่ช่วงประมาณ 2:30-3:00
sum = 58.9+58.5+57.8+55.0+49.6+42.1+35.4+30.8+30.2+30.0+29.9+29.8
= 508.0
average = 508.0 / 12 = 42.3 fps
(b) ค่าเฉลี่ยตลอด 6 นาทีเต็มอยู่ที่ประมาณ 42.3 fps
(c) การรัน benchmark 20 วินาทีตอนเริ่มต้นเซสชันจะตกอยู่ในช่วง 0:30 พอดี รายงานค่าใกล้เคียง 58.9 fps — ตัวเลขที่สูงกว่าค่าเฉลี่ยจริงตลอด 6 นาทีมากกว่า 16 fps และสูงเกือบสองเท่าของ 29-30 fps ที่อุปกรณ์ตกลงมาคงที่จริง ๆ เมื่อมันรันนานพอที่จะร้อนขึ้น
DeviceTierDetector จากข้อ 9 จัดอุปกรณ์ตาม RAM ล้วน ๆ สมมติว่ารุ่นหนึ่งโดยเฉพาะ "BudgetPhone X9" รายงาน RAM 4096 MB (ซึ่งการเช็ค RAM อย่างเดียวจะจัดเป็น Mid tier) แต่รู้กันว่ามี GPU ที่อ่อนแอมากจนรับ Mid-tier setting จริง ๆ ไม่ไหว แก้ไข DeviceTierDetector.Detect() ให้รุ่นนี้ถูกบังคับเป็น DeviceTier.Low เสมอ โดยเช็คก่อนที่ heuristic แบบ RAM จะทำงาน ในขณะที่อุปกรณ์อื่นทั้งหมดยัง fallback ไปใช้ตรรกะแบบ RAM ปกติpublic static class DeviceTierDetector
{
static readonly string[] ForcedLowTierModels = { "BudgetPhone X9" };
public static DeviceTier Detect()
{
foreach (string model in ForcedLowTierModels)
{
if (SystemInfo.deviceModel == model)
return DeviceTier.Low; // known weak GPU -- override before the RAM check
}
int ramMB = SystemInfo.systemMemorySize;
if (ramMB <= 3072) return DeviceTier.Low;
if (ramMB <= 6144) return DeviceTier.Mid;
return DeviceTier.High;
}
}
รายการ override ถูกเช็คก่อนและ return ทันทีเมื่อเจอที่ตรงกัน ดังนั้น "BudgetPhone X9" จึงไม่มีวันไปถึงการเปรียบเทียบ RAM เลย ไม่ว่า SystemInfo.systemMemorySize จะรายงานค่าอะไรสำหรับมันก็ตาม อุปกรณ์รุ่นอื่นทั้งหมดจะผ่าน loop นี้ไปโดยไม่ถูกแตะต้อง และถูกจัดประเภทเหมือนเดิมทุกประการ ด้วย RAM เพียงอย่างเดียว
(a) RGBA32, 2048x2048:
pixels = 2048 * 2048 = 4,194,304
bytes = 4,194,304 * 4 bytes/pixel = 16,777,216 bytes = 16.00 MB
8 textures = 8 * 16.00 MB = 128.00 MB
(b) ASTC 6x6:
bytes per pixel = 16 bytes / 36 pixels = 0.444 bytes/pixel
bytes per texture = 4,194,304 * 0.444 = 1,864,135 bytes = 1.78 MB
8 textures = 8 * 1.78 MB = 14.22 MB
(c) before: 300 MB + 128.00 MB = 428.00 MB -> over the 400 MB budget
after: 300 MB + 14.22 MB = 314.22 MB -> comfortably under budget
เมื่อเก็บเป็น RGBA32 ตัวละครตัวเดียวนี้ดันให้ฉากเกิน Mid-tier memory budget ไปประมาณ 28 MB ทำให้เสี่ยงจริง ๆ ที่จะถูก OS ฆ่าทิ้งบนอุปกรณ์ระดับกลาง การแปลงเท็กซ์เจอร์ทั้ง 8 ชิ้นเดียวกันนี้เป็น ASTC 6x6 ลดต้นทุนหน่วยความจำลงประมาณ 9 เท่า (จาก 128.00 MB เหลือ 14.22 MB) โดยไม่เปลี่ยน pixel แม้แต่จุดเดียวบนดิสก์ในแบบที่ผู้เล่นส่วนใหญ่จะสังเกตเห็น และทำให้ทั้งฉากอยู่ใต้ budget สบาย ๆ โดยมี headroom เหลืออีกประมาณ 85 MB