กระบวนการรับสมัครงานของทุกสตูดิโอเกม ตั้งแต่ทีมมือถือ 5 คนไปจนถึงบริษัทขนาด HoYoverse สุดท้ายแล้วก็ถามคำถามชุดเดียวกันซ้ำๆ อยู่ดี แค่คำพูดที่ใช้ในแต่ละวันอาจต่างกันไป บทนี้คือเฉลยตรงๆ: pipeline จริงที่ต้องผ่าน, คำถามคณิตศาสตร์ที่โผล่มาแทบทุก technical round, คำถาม C++ ที่ interviewer สาย engine ชอบถาม, คำถาม C#/Unity ที่ interviewer สาย gameplay ชอบถาม, และคำถาม debugging ที่สำคัญกว่า trivia ข้างบนทั้งหมดรวมกัน ทุกคำถามข้างล่างนี้มีคำตอบที่ worked out ให้ครบ เหมือนกับที่โค้ดทุกตัวอย่างในหลักสูตรนี้มี output จริงให้ดู — บทนี้ตั้งใจให้เอาไปซ้อมพูดออกเสียงจริงๆ ไม่ใช่แค่อ่านผ่านตาครั้งเดียว
เนื้อหาที่ถูกทดสอบในบทนี้ คุณรู้อยู่แล้วทั้งหมด คณิตศาสตร์คือบท 2.1 (vector, dot product, cross product) กับบท 2.3 (rotation, quaternion, slerp) C++ คือบท 1.3 (RAII, reference กับ pointer, copy กับ move) และบท 3.1-3.2 (cache line, memory layout) เนื้อหา C# กับ Unity คือบท 1.2 กับบท 4.2 (MonoBehaviour lifecycle, coroutine, ScriptableObject) สิ่งที่ใหม่ในบทนี้ไม่ใช่ความรู้พื้นฐาน — แต่เป็นรูปแบบที่บีบอัดให้พอดีกับสถานการณ์สัมภาษณ์ ที่ต้องพูดออกมาให้ได้ภายใต้แรงกดดันเรื่องเวลา บวกกับแผนสำหรับหนึ่งเดือนก่อนที่คุณจะเดินเข้าไปนั่งในห้องแบบนั้นจริงๆ
สเตจ 1 การส่งใบสมัคร ส่วนใหญ่เป็นแค่ตัวกรองที่ต้องผ่านหรือตกก่อนที่มนุษย์จะมาอ่านมันอย่างละเอียดจริงๆ resume ถูกสแกนผ่านๆ แค่ไม่กี่วินาที ไม่ใช่เป็นนาที — ลิงก์ไปยัง playable build, GitHub สั้นๆ ที่มี commit history จริง หรือ referral จากคนที่ทำงานในสตูดิโออยู่แล้ว ช่วยได้มากกว่าอีกหนึ่งย่อหน้าข้อความอธิบายตัวเองเยอะมาก referral โดยเฉพาะมักจะข้ามคิวไปตกอยู่บนโต๊ะ recruiter ตรงๆ เลย นี่คือเหตุผลว่าทำไม "มีคนรู้จักทำงานที่นั่นไหม" ถึงควรเช็คก่อนสมัครแบบ cold apply
สเตจ 2 สายจาก recruiter ไม่ใช่รอบเทคนิค การเตรียมตัวเหมือนเป็นรอบเทคนิคเสียเวลาเปล่า recruiter กำลังคัดกรองความเข้ากันได้พื้นฐาน: ตำแหน่งที่คุณหาจริงๆ คืออะไร ช่วงเงินเดือนที่คุณคาดหวังทับซ้อนกับงบไหม คุณมีสิทธิ์ทำงานในประเทศที่สตูดิโอจ้างไหม คร่าวๆ เริ่มงานได้เมื่อไหร่ สิ่งเดียวที่ควรซ้อมมาให้ดีคือคำตอบยาว 60-90 วินาทีสำหรับ "เล่าเรื่องตัวเองให้ฟังหน่อย" ที่จบด้วยเหตุผลว่าทำไมถึงอยากมาสตูดิโอนี้โดยเฉพาะ ไม่ใช่คำตอบทั่วไปแบบ "ผมรักเกม" — recruiter ได้ยินคำตอบทั่วไปแบบนี้มาเป็นพันครั้งแล้ว มันไม่บอกอะไรพวกเขาเลย
สเตจ 3 แบบทดสอบเทคนิค แบ่งเป็นสองรูปแบบที่ต่างกันมาก take-home ให้เวลาเป็นวันๆ คาดหวังโค้ดที่มีคุณภาพระดับ production และมีอธิบายละเอียดในเซคชัน 6 ส่วน live test — สภาพแวดล้อมเขียนโค้ดร่วมกัน, whiteboard, บางทีก็เป็น online judge จับเวลา — ใกล้เคียงกับสิ่งที่เซคชัน 2 ถึงเซคชัน 4 ข้างล่างนี้ซ้อมให้: คำถามสั้นๆ เจาะจง มีคำตอบที่ถูกหรืออย่างน้อยก็อธิบายได้ ภายใต้แรงกดดันเรื่องเวลา บางทีก็มีคนนั่งดูคุณคิดอยู่ด้วย
สเตจ 4 รอบสัมภาษณ์เทคนิค ปกติมีมากกว่าหนึ่งรอบ: การรีวิว take-home ของคุณที่เขาจะถามว่า "ทำไมถึงทำแบบนี้" (บ่อยครั้งเผยตัวตนได้มากกว่าตัวโค้ดเองซะอีก), รอบคณิตศาสตร์หรือ algorithm, บางทีก็เป็นบทสนทนา system design สำหรับตำแหน่งซีเนียร์ขึ้นไป และที่เจอบ่อยมากคือแบบฝึก live debugging — อ่านเซคชัน 5 ให้ดีๆ เพราะ "ใช้เหตุผลไล่บั๊กที่ไม่เคยเห็นมาก่อนออกมาเป็นคำพูด" คือสิ่งที่ให้ signal สูงที่สุดอย่างหนึ่งที่สตูดิโอจะได้เห็นคุณทำในหนึ่งชั่วโมง
สเตจ 5 สัมภาษณ์ทีมและวัฒนธรรมองค์กร ส่วนใหญ่มีไว้เพื่อยืนยัน fit ที่ทั้งสองฝ่ายสงสัยอยู่แล้วว่าใช่ ณ จุดนี้ — คุณแทบไม่โดน reject ในสเตจนี้ด้วยเหตุผลเทคนิค นี่ยังเป็นสเตจที่คำถามที่คุณถาม (เซคชัน 8) สำคัญที่สุดด้วย เพราะห้องนี้เต็มไปด้วยคนที่คุณจะทำงานด้วยจริงๆ ทุกวัน
สเตจ 6 คือ offer: เงินเดือนฐาน บางทีมี signing bonus หรือ relocation bonus บางทีมี equity หรือโบนัสตามผลงานของเกม การตัดสินใจเรื่อง level (ซึ่งกระทบทั้งช่วงเงินเดือนและความคาดหวัง) และช่วงเวลาต่อรอง การต่อรองอย่างสุภาพด้วยข้อมูลตลาดจริงเป็นเรื่องปกติและคาดหวังได้ สตูดิโอตั้งงบไว้รองรับส่วนนี้อยู่แล้ว
คำถามหกข้อนี้โผล่มาในบางรูปแบบแทบทุก technical round สำหรับตำแหน่ง gameplay หรือ engine แต่ละข้อข้างล่างนี้มีคำตอบแบบบีบอัดพร้อมใช้ในห้องสัมภาษณ์ บวกตัวอย่างตัวเลขที่ worked out — การพิสูจน์เต็มรูปแบบอยู่ในบท 2.1 กับบท 2.3 อยู่แล้ว เซคชันนี้คือเวอร์ชันเร็วและถูกต้องที่คุณควรพูดออกมาจากความจำได้เลย บน whiteboard โดยไม่ต้องลังเล
float Dot(Vec3 a, Vec3 b) {
return a.x * b.x + a.y * b.y + a.z * b.z;
}
Vec3 forward = { 0.0f, 0.0f, 1.0f }; // where the character is facing
Vec3 toEnemyA = { 2.0f, 0.0f, 3.0f }; // enemy is ahead and to the side
Vec3 toEnemyB = { 2.0f, 0.0f, -3.0f }; // enemy is behind and to the side
float dotA = Dot(forward, toEnemyA); // 0*2 + 0*0 + 1*3 = 3.0
float dotB = Dot(forward, toEnemyB); // 0*2 + 0*0 + 1*(-3) = -3.0
dotA ออกมาเป็นบวก dotB ออกมาเป็นลบ Dot(a,b) = |a||b|cos(theta) และ SIGN ของนิพจน์นี้ถูกกำหนดโดย cos(theta) ล้วนๆ: บวกหมายความว่ามุมระหว่างเวกเตอร์ทั้งสองน้อยกว่า 90 องศา (ทิศทางใกล้เคียงกัน) ลบหมายความว่ามากกว่า 90 องศา (ทิศทางตรงข้ามกันโดยประมาณ) และเป็นศูนย์พอดีหมายความว่าตั้งฉากกัน ในตัวอย่างนี้อ่านได้ว่า: enemy A อยู่ในซีกที่หันหน้าไปทางเดียวกับตัวละคร, enemy B อยู่ด้านหลังมันสักที่หนึ่ง — นี่คือวิธีเขียนเช็ค "เป้าหมายนี้อยู่ข้างหน้าฉันไหม", การตรวจจับ backstab, และการเช็ค visibility แบบด้านเดียว โดยไม่ต้องคำนวณมุมจริงๆ เลยสักครั้ง
สิ่งที่ควรพูดเพิ่มออกมาดังๆ ในห้องสัมภาษณ์: ถ้าเวกเตอร์ทั้งสองถูก normalize แล้ว (ความยาว 1) dot product ไม่ใช่แค่มี sign เดียวกับ cos(theta) แต่มันเท่ากับ cos(theta) เป๊ะๆ เลย ซึ่งนี่คือสิ่งที่ทำให้การเช็ค field-of-view ทำได้ถูกและเร็ว — เอา dot product สดๆ มาเทียบกับ threshold cosine ที่คำนวณไว้ล่วงหน้า แทนที่จะต้องคำนวณ inverse-cosine (acos) ทุกครั้งที่เช็ค
if (toTarget.sqrMagnitude <= range * range && Dot(forward, toTarget.normalized) >= cosHalfFov) then raycast to confirm line of sight การเช็คที่ถูกจะคัด candidate ส่วนใหญ่ออกไปก่อนที่ raycast ตัวแพงจะได้รันเลยด้วยซ้ำVec3 Cross(Vec3 a, Vec3 b) {
return {
a.y * b.z - a.z * b.y,
a.z * b.x - a.x * b.z,
a.x * b.y - a.y * b.x
};
}
float Dot(Vec3 a, Vec3 b) { return a.x*b.x + a.y*b.y + a.z*b.z; }
Vec3 forward = { 0.0f, 0.0f, 1.0f }; // character facing +Z
Vec3 up = { 0.0f, 1.0f, 0.0f };
Vec3 toTarget = { 1.0f, 0.0f, 1.0f }; // target is ahead AND off to the +X side
Vec3 c = Cross(forward, toTarget);
// c.x = 0*1 - 1*0 = 0
// c.y = 1*1 - 0*1 = 1
// c.z = 0*0 - 0*1 = 0
// c = (0, 1, 0)
float side = Dot(c, up); // 0*0 + 1*1 + 0*0 = 1.0 -- POSITIVE
cross product ดิบๆ ตอบได้แค่ "ขอเวกเตอร์ที่ตั้งฉากกับทั้งสองตัวนี้" — ตัวมันเองแค่ c = (0, 1, 0) แปลว่า "ชี้ขึ้นตรงๆ" เฉยๆ ซึ่งยังไม่ใช่คำตอบซ้าย/ขวาที่ interviewer ถามจริงๆ ขั้นตอนเพิ่มเติมนี่แหละที่ทำให้มันมีประโยชน์: ในระบบพิกัดของ Unity ที่เป็น left-handed, Y-up (+X คือขวา, +Y คือขึ้น, +Z คือหน้า) Dot(Cross(forward, toTarget), up) > 0 หมายความว่าเป้าหมายอยู่ทางขวาของตัวละคร และผลลัพธ์เป็นลบหมายความว่าอยู่ทางซ้าย ในตัวอย่างนี้ side = 1.0 เป็นบวก และค่า +X ของเป้าหมายเอง — ทิศ "ขวา" ในระบบพิกัดนี้ — ก็ยืนยันตรงกัน
อีกสองข้อเท็จจริงที่ควรพูดออกมาดังๆ: ขนาดของ cross product คือ |a||b|sin(theta) ดังนั้นมันจะเล็กลงเข้าใกล้ศูนย์เมื่อเวกเตอร์สองตัวเกือบขนานกันหรือขนานกันแบบตรงข้าม (ใช้เช็คซ้าย/ขวาไม่ได้เลยตรงจุดสุดโต่งพวกนี้) — และ cross product เป็น anti-commutative: Cross(toTarget, forward) ในตัวอย่างนี้ออกมาเป็น (0, -1, 0) ซึ่งเป็นค่าตรงข้ามของ c พอดี การสลับลำดับ operand จะพลิก sign เสมอ ดังนั้นตัวไหนมาก่อนไม่ใช่เรื่องที่เลือกได้ตามใจ
คำตอบสั้นๆ พร้อมใช้ในห้องสัมภาษณ์: Euler angle (pitch, yaw, roll) มีปัญหา gimbal lock — เมื่อแกนหมุนหนึ่งไปเรียงตัวตรงกับอีกแกนหนึ่ง องศาอิสระทั้งหมดหนึ่งตัวจะยุบหายไป ตัวเลขหยุดเคลื่อนที่แบบราบรื่นทั้งที่ตัววัตถุยังเคลื่อนที่ได้อยู่ quaternion ไม่มีปัญหาแบบนี้ และมัน interpolate ระหว่างสอง orientation ได้ถูกต้อง (ผ่าน slerp, เซคชัน 2.6) ไปตามเส้นโค้งที่สั้นที่สุด ในขณะที่การ lerp ตัวเลข Euler ดิบๆ อาจเห็นได้ชัดว่าไปอ้อมทางไกล หรือกระตุกพลิกกลับ พอดีตรงท่าที่ gimbal lock เกิดขึ้น บท 2.3 พิสูจน์เรื่องนี้แบบเต็มรูปแบบไว้แล้ว นี่คือเวอร์ชันตัวเลขแบบบีบอัดของหน้าตาความล้มเหลวที่ว่า
// orientation A: pitch = 89.9, yaw = 0.0, roll = 0.0 (just under straight up)
// orientation B: pitch = 90.1, yaw = 180.0, roll = 180.0 (just past straight up)
//
// A and B describe almost the SAME physical orientation -- tipped a fraction of a degree
// past vertical, yaw and roll effectively cancel out that close to the pole -- but every
// single number is wildly different between A and B
//
// if two nearby FRAMES happen to land on opposite sides of this pole, and code naively
// lerps the raw pitch/yaw/roll numbers between them, the object visibly SNAPS or spins
// through a huge angle for what should have been a barely-visible movement
นั่นคือต้นทุนจริงของ gimbal lock: ไม่ใช่แค่ "องศาอิสระหายไปหนึ่งตัว" แบบนามธรรม แต่เป็นการกระตุกที่มองเห็นได้จริงในการหมุนของกล้องหรือตัวละคร พอดีตรงทิศทางมองใกล้แนวดิ่งที่กล้อง third-person เจอตลอดเวลา quaternion หลบปัญหานี้ได้เต็มๆ เพราะมันแทน orientation เป็นจุดเดียวบนทรงกลมหนึ่งหน่วย 4 มิติ ไม่มีแกนไหนยุบซ้อนทับกันได้เลย
// object: position (5, 0, 0), rotated 90 degrees around Y, scale 1 (uniform)
// local point: (1, 0, 0) -- one unit along the object's own local +X axis
// step 1: scale (skipped here, scale is 1)
Vec3 scaled = { 1.0f, 0.0f, 0.0f };
// step 2: rotate 90 degrees around Y
// x' = x*cos(90) + z*sin(90) = 1*0 + 0*1 = 0
// y' = y = 0
// z' = -x*sin(90) + z*cos(90) = -1*1 + 0*0 = -1
Vec3 rotated = { 0.0f, 0.0f, -1.0f };
// step 3: translate by the object's world position, (5, 0, 0)
Vec3 worldPos = { rotated.x + 5.0f, rotated.y + 0.0f, rotated.z + 0.0f };
// worldPos = (5.0, 0.0, -1.0)
ลำดับคือ scale ก่อน แล้วค่อย rotate แล้วค่อย translate — ไม่ใช่ลำดับอื่นเด็ดขาด และห้ามสลับเป็น rotate-แล้ว-translate เพราะการหมุนจุดที่ถูกเลื่อนออกจาก origin ไปแล้ว จะหมุนรอบ origin ของโลกแทนที่จะหมุนรอบจุดศูนย์กลางของตัวมันเอง เป็นบั๊กคลาสสิกที่มองเห็นได้ชัดมาก (วัตถุที่ควรหมุนอยู่กับที่กลับไปโคจรรอบจุดอื่นในฉากแทน) เซคชันเรื่อง combining-transforms ของบท 2.1 (บท 2.1, เซคชัน 6-9) อธิบายไว้ว่าทำไมลำดับนี้ถึงเป็นผลโดยตรงจากการคูณเมทริกซ์ที่ apply จากขวาไปซ้าย: M = T * R * S การทำย้อนทิศทาง — จาก world space กลับไป local space — ก็ทำย้อนแต่ละสเต็ป: ลบ position ออก, apply inverse rotation (สำหรับเมทริกซ์ rotation ล้วนๆ คือ transpose ของมันเอง), แล้วค่อยหารด้วย scale ใน Unity ตัวอย่างที่ worked out นี้ทั้งหมดคือสิ่งที่ transform.TransformPoint(localPos) ทำภายในตัวมันเองพอดี และ transform.InverseTransformPoint(worldPos) ก็ทำย้อนกลับ
bool IsInsideSphere(Vec3 point, Vec3 center, float radius) {
Vec3 d = { point.x - center.x, point.y - center.y, point.z - center.z };
float distSq = d.x * d.x + d.y * d.y + d.z * d.z; // squared distance, no sqrt needed
return distSq <= radius * radius;
}
// center = (0,0,0), radius = 5
bool insideA = IsInsideSphere({3, 4, 0}, {0, 0, 0}, 5.0f); // distSq = 9+16+0 = 25, r*r = 25 -> true
bool insideB = IsInsideSphere({3, 4, 1}, {0, 0, 0}, 5.0f); // distSq = 9+16+1 = 26, r*r = 25 -> false
รายละเอียดที่ interviewer ตกเบ็ดถามจริงๆ ตรงนี้คือ: เทียบระยะทางยกกำลังสองกับรัศมียกกำลังสอง แทนที่จะถอดรากที่สอง ทั้งสองข้างของการเทียบไม่ติดลบอยู่แล้ว ดังนั้น distSq <= radius*radius เท่ากับ dist <= radius เป๊ะๆ โดยไม่ต้องเรียก sqrt เลยสักครั้ง — หนึ่งใน operation ระดับ scalar ที่แพงที่สุดที่มี และไม่มีเหตุผลที่จะต้องมาโผล่ในเช็คง่ายๆ แบบนี้
struct Plane { Vec3 normal; float d; }; // plane: dot(normal, point) + d == 0, normal points INWARD
float SignedDistance(Plane p, Vec3 point) {
return p.normal.x * point.x + p.normal.y * point.y + p.normal.z * point.z + p.d;
}
bool IsInsideFrustum(Vec3 point, Plane planes[6]) {
for (int i = 0; i < 6; i++) {
if (SignedDistance(planes[i], point) < 0.0f)
return false; // outside THIS plane means outside the whole frustum
}
return true;
}
// near plane example: plane at z = 2, normal (0,0,1) meaning "inward" is +z, so d = -2
// signedDistance = z - 2
// point (0,0,5): distance = 5 - 2 = 3 -> inside this plane
// point (0,0,1): distance = 1 - 2 = -1 -> outside (in front of the near clip plane)
camera frustum คือระนาบ 6 อัน (near, far, left, right, top, bottom) จุดหนึ่งจะอยู่ใน frustum ก็ต่อเมื่อมันอยู่ด้านในของระนาบทั้ง 6 อันครบทุกอัน ในเอนจิ้นจริงๆ เช็คแบบนี้แทบไม่เคยรันกับจุดเดียวเลย — มันรันกับ bounding sphere หรือ bounding box ของทั้งวัตถุ ใช้คณิตศาสตร์ระนาบตัวเดียวกับข้างบนนี้ซ้ำ เพราะเช็ค bounding volume ราคาถูกแค่อันเดียวต่อวัตถุเร็วกว่าเช็คทุก vertex เยอะมาก Unity ทำแบบนี้ให้อัตโนมัติสำหรับกล้อง (GeometryUtility.TestPlanesAABB คือเวอร์ชัน built-in ของ loop ข้างบนนี้) ดังนั้นในทางปฏิบัติคุณแทบไม่ต้องเขียนเองเลย interviewer ถามอยู่ดีเพราะมันเป็นวิธีเช็คที่สะอาดว่าคุณเข้าใจจริงๆ ไหมว่าสมการระนาบกับ dot product ทำงานร่วมกันยังไง
Lerp คือเส้นตรงในพื้นที่แบบราบทั่วไป — ถูกต้องและราคาถูกสำหรับ position, สี, และตัวเลขธรรมดา rotation ที่เก็บเป็น quaternion คือจุดบนทรงกลมหนึ่งหน่วย 4 มิติ และเส้นตรงระหว่างสองจุดบนทรงกลมจะตัดผ่านเข้าไปข้างในทรงกลม ทำให้ความยาวสั้นลงถ้าไม่ renormalize ทีหลัง Slerp (spherical linear interpolation) ใช้พื้นผิวของทรงกลมเองแทน เคลื่อนที่ด้วยความเร็วเชิงมุมคงที่ไปตามเส้นโค้งที่สั้นที่สุด — เครื่องมือที่ถูกต้องทางคณิตศาสตร์สำหรับ interpolate rotation บท 2.3 (เซคชัน 10-11) พูดถึงทั้งสองตัวแบบเต็มรูปแบบ รวมถึง nlerp (lerp ธรรมดาแล้วค่อย renormalize) ในฐานะญาติราคาถูกกว่าของ slerp นี่คือช่องว่างเชิงตัวเลขระหว่างทั้งสอง คำนวณด้วยมือ:
// q0 = identity = (0, 0, 0, 1) -- 0 degrees around Y
// q1 = (0, 0.7071, 0, 0.7071) -- 90 degrees around Y
// dot(q0, q1) = 0.7071 = cos(45 deg) -- angle between them in quaternion space is 45 degrees
// slerp(q0, q1, t) = [sin((1-t)*45)/sin(45)] * q0 + [sin(t*45)/sin(45)] * q1
// at t = 0.25:
// slerp = (0.0000, 0.1951, 0.0000, 0.9808)
// this IS the exact quaternion for 22.5 degrees (0.25 * 90) -- slerp moves at
// constant angular speed, so 25% of the way through really is 22.5 degrees there
// nlerp (plain lerp, then re-normalize) at t = 0.25:
// raw lerp = (0.0000, 0.1768, 0.0000, 0.9268) -- length 0.9435, NOT unit length
// normalized = (0.0000, 0.1874, 0.0000, 0.9823) -- corresponds to about 21.6 degrees
ที่ t = 0.25 slerp ลงเป๊ะที่คำตอบความเร็วคงที่ 22.5 องศา เส้นทางเส้นตรงของ nlerp เบี่ยงออกจากตารางเวลานั้นเล็กน้อยและลงที่ประมาณ 21.6 องศาแทน — เล็กน้อยในตัวอย่างนี้ แต่ช่องว่างจะโตขึ้นตามมุมระหว่างสอง rotation และตามระยะที่ t ห่างจากจุดปลาย สำหรับการอัปเดตทีละเฟรมที่ rotation ปัจจุบันกับเป้าหมายใกล้กันอยู่แล้ว ช่องว่างนั้นมองไม่เห็นเลย และเอนจิ้นส่วนใหญ่ใช้ nlerp กันทุกที่ด้วยเหตุผลนี้แหละ — มันไม่ต้องใช้ตรีโกณมิติเลย แค่บวก คูณ แล้ว normalize ครั้งเดียว สำหรับการหมุนใหญ่ครั้งเดียวแบบตั้งใจข้ามมุมกว้างๆ — กล้องที่ orbit รอบใหญ่ครั้งเดียว, การเลี้ยว 90 องศาที่ animate ตลอดหนึ่งวินาทีเต็ม — ช่องว่างนั้นมองเห็นได้ชัดพอที่ slerp จะคุ้มกับต้นทุนที่เพิ่มขึ้น กฎง่ายๆ: ก้าวเล็กๆ ทีละเฟรมเยอะๆ ใช้ nlerp; การหมุนใหญ่ครั้งเดียว ใช้ slerp; และห้าม lerp quaternion ตรงๆ โดยไม่ renormalize ทีหลังเด็ดขาด
รอบสัมภาษณ์สาย engine หยิบคำถามหกข้อนี้มาถามแทบทุกครั้ง ทั้งหกข้อต่อยอดตรงจากบท 1.3 (พื้นฐาน C++) และบท 3.1-3.2 (memory กับ cache) เซคชันนี้คือเวอร์ชันบีบอัดพร้อมใช้บน whiteboard ของแต่ละข้อ
#include <iostream>
void AddOnePointer(int* p) {
*p = *p + 1; // must dereference to reach the value
}
void AddOneReference(int& r) {
r = r + 1; // r IS the variable, no dereference needed
}
int main() {
int x = 10;
AddOnePointer(&x); // must pass the ADDRESS explicitly
std::cout << x << "\n"; // 11
AddOneReference(x); // just pass x, the reference binds to it directly
std::cout << x << "\n"; // 12
int* p = nullptr; // a pointer CAN be null
// int& r; // ERROR: a reference must be bound the moment it's declared
}
โค้ดนี้ print 11 แล้วก็ 12 pointer คือตัวแปรธรรมดาที่บังเอิญเก็บ address ของ memory: มันเป็น null ได้, reassign ให้ชี้ที่อื่นทีหลังได้, และการเข้าถึงค่าที่มันชี้ไปต้องใช้ * อย่างชัดเจน reference คือ alias ของตัวแปรที่มีอยู่แล้ว: มันต้อง bind ตั้งแต่ตอนประกาศ, ไม่มีทางเป็น null ได้เลยในโค้ดที่ well-defined, ไม่มีทาง reseat ไปอ้างอิงอย่างอื่นทีหลังได้ และใช้งานไม่ต้องมี syntax พิเศษอะไร — r ก็คือ x ในชื่ออื่นเฉยๆ กฎที่ใช้จริง: ใช้ reference สำหรับ "พารามิเตอร์นี้รับประกันว่ามีอยู่จริง และฉันไม่ต้อง rebind มัน" (ส่วนใหญ่ของพารามิเตอร์ฟังก์ชัน) และหยิบ pointer มาใช้เฉพาะตอนที่ต้องการ "อันนี้อาจจะไม่มีอยู่จริง", "ฉันต้องเปลี่ยนสิ่งที่ฉันชี้ไปได้" หรือกำลังทำงานกับ array หรือ memory ที่ dynamically-owned
#include <iostream>
class Enemy {
public:
virtual void TakeDamage(int amount) {
std::cout << "Enemy takes " << amount << " damage\n";
}
virtual ~Enemy() {} // virtual destructor -- section 3.5 explains why this matters
};
class Boss : public Enemy {
public:
void TakeDamage(int amount) override {
std::cout << "Boss takes " << amount << " damage, and roars\n";
}
};
int main() {
Enemy* e = new Boss(); // base-class pointer, but a DERIVED object underneath
e->TakeDamage(10); // which version actually runs?
delete e;
}
โค้ดนี้ print Boss takes 10 damage, and roars — ไม่ใช่เวอร์ชันของ Enemy ทั้งที่ e ถูกประกาศเป็น Enemy* การมาร์ก TakeDamage เป็น virtual ทำให้การเรียกฟังก์ชัน resolve ตอน runtime ตามชนิดจริงของ object ซึ่งกลไกนี้เรียกว่า dynamic dispatch และนี่คือวิธีที่มันทำงานจริงๆ ข้างใต้:
ทุก object ของ class ที่มี virtual function อย่างน้อยหนึ่งตัว จะพก hidden pointer หนึ่งตัวติดตัวไปด้วย เรียกว่า vptr ที่ชี้ไปยัง vtable ของ class นั้น — ตารางของ function pointer หนึ่งช่องต่อ virtual function หนึ่งตัว ถูกสร้างครั้งเดียวต่อ class ไม่ใช่ครั้งเดียวต่อ object การเรียก virtual function หมายความว่า: ตาม vptr ของ object ไป, หาช่องที่ถูกต้องในตาราง, เรียก function pointer ที่อยู่ในช่องนั้น การ indirection ผ่าน pointer เพิ่มขึ้นมาหนึ่งครั้งนั้น บวกกับต้นทุน memory เล็กน้อยของตัว vptr เอง คือ "ต้นทุน" จริงๆ ของ virtual function — และมันคือสิ่งที่ทำให้ dynamic dispatch เป็นไปได้ตั้งแต่แรก
#include <iostream>
class LockGuard {
public:
LockGuard(bool& lockedFlag) : locked(lockedFlag) {
locked = true;
std::cout << "lock acquired\n";
}
~LockGuard() {
locked = false;
std::cout << "lock released\n";
}
private:
bool& locked;
};
bool isLocked = false;
void DoWork() {
LockGuard guard(isLocked); // acquires the lock
std::cout << "isLocked = " << isLocked << "\n";
// ... work happens here, including a possible early return or exception ...
} // guard's destructor runs HERE, automatically -- lock released no matter how we leave
int main() {
DoWork();
std::cout << "isLocked = " << isLocked << "\n";
}
Output: lock acquired, isLocked = 1, lock released, isLocked = 0 RAII ย่อมาจาก Resource Acquisition Is Initialization — ชื่อที่ฟังดูอึดอัดสำหรับไอเดียง่ายๆ ที่บท 1.3 เซคชัน 2 พูดถึงไว้แล้ว: ผูก lifetime ของ resource เข้ากับ lifetime ของ object โดยตรง acquire resource ใน constructor, release มันใน destructor แล้วปล่อยให้การรับประกันของภาษาเองที่ว่า destructor จะรันเสมอ — แม้แต่ตอน return ก่อนเวลา, แม้แต่ตอนที่ exception unwind stack — ทำงาน cleanup ให้เราเอง คำตอบที่พร้อมใช้ในห้องสัมภาษณ์คือพูดชื่อย่อออกมา, ยกตัวอย่างจริงหนึ่งอัน (std::unique_ptr สำหรับ heap memory, std::lock_guard สำหรับ mutex — บท 1.3 เซคชัน 3 พูดถึง smart pointer แบบนี้ไว้แล้ว) และเปรียบเทียบกับการ acquire/release แบบมือ ซึ่งแค่ลืม cleanup call เดียวก็ leak หรือถือ lock ค้างไว้ถาวรได้เลย
#include <iostream>
#include <utility>
class Buffer {
public:
Buffer(int n) : size(n), data(new int[n]) {
std::cout << "constructed, size " << size << "\n";
}
Buffer(const Buffer& other) : size(other.size), data(new int[other.size]) {
std::cout << "COPIED, size " << size << "\n"; // expensive: new allocation + copy
}
Buffer(Buffer&& other) noexcept : size(other.size), data(other.data) {
other.data = nullptr;
other.size = 0;
std::cout << "MOVED, size " << size << "\n"; // cheap: just copied a pointer
}
~Buffer() { delete[] data; }
private:
int size;
int* data;
};
Buffer MakeBuffer() {
Buffer b(1000);
return b; // the compiler moves this out instead of copying it
}
int main() {
Buffer a = MakeBuffer(); // move (or elided entirely by the compiler)
Buffer c = std::move(a); // explicit move: "a" is about to be discarded
}
โค้ดนี้ print constructed, size 1000 แล้วก็ MOVED, size 1000 — บรรทัด COPIED ไม่ต้องรันเลยแม้แต่ครั้งเดียว Buffer&& other คือ rvalue reference ชนิด reference ที่ bind กับค่าที่กำลังจะถูกทิ้งเท่านั้น (ค่า temporary หรืออะไรก็ตามที่ห่อด้วย std::move) std::move ไม่ได้ move อะไรเลยด้วยตัวมันเอง — มันแค่เป็น cast ที่บอก compiler ว่า "ปฏิบัติกับตัวนี้เป็น rvalue" ซึ่งทำให้ overload ของ move constructor ถูกเลือกแทน copy constructor ผลลัพธ์ที่ได้เป็นรูปธรรม: move constructor ขโมย heap pointer ที่มีอยู่แล้ว (คัดลอกไม่กี่ byte) แทนที่จะ allocate array ใหม่ทั้งอันแล้ว copy ทุก element เข้าไป (บท 1.3 เซคชัน 6 แนะนำ copy กับ move ไว้แล้ว — นี่คือไอเดียเดียวกัน แต่สะกดกลไกจริงออกมาให้ดู) การ return local ด้วย value ยังได้ compiler optimization เพิ่มเติมเรียกว่า RVO ที่ข้าม move ไปเลยได้ด้วยซ้ำ แต่หลักการข้างใต้ — ย้าย ownership แทนที่จะ duplicate — คือสิ่งที่ move semantics generalize ไปใช้ที่อื่นทุกที่
#include <iostream>
class Player {
public:
Player() { std::cout << "Player constructed\n"; }
~Player() { std::cout << "Player destroyed\n"; }
};
int main() {
Player* p = new Player(); // step 1: operator new gets raw memory
// step 2: the constructor runs IN that memory
std::cout << "using player\n";
delete p; // step 1: the destructor runs
// step 2: operator delete frees the memory
// using p after this line reads freed memory -- undefined behavior
}
Output: Player constructed, using player, Player destroyed new คือสองสเต็ปที่ยุบรวมกันในคีย์เวิร์ดเดียว: allocate raw memory (operator new แนวคิดคล้ายกับ malloc จากพื้นฐาน C ในบท 1.1) แล้วรัน constructor ข้างในหน่วยความจำนั้น delete ทำย้อนทั้งสองสเต็ปในลำดับตรงข้าม: รัน destructor ก่อน แล้วค่อย free หน่วยความจำ (operator delete) บั๊กสามอย่างที่ควรพูดถึงถ้าถูกถามว่า "ตรงนี้พังยังไงได้บ้าง": ลืม delete ไปเลย ทำให้ memory leak ตลอดไป; เรียก delete สองครั้งกับ pointer เดียวกันคือ double-free เป็น undefined behavior และมักจะ crash ทันที; ใช้ pointer หลังจากที่มันถูก delete ไปแล้วคือการอ่าน memory ที่ freed ไปแล้ว บั๊กแบบ dangling-pointer / use-after-free — เนื้อหา undefined-behavior ในบท 1.1 และเนื้อหา sanitizer ในบท 1.3 เซคชัน 10 ใช้ได้ตรงๆ กับตรงนี้เลย อีกกฎการจับคู่หนึ่งที่ควรพูดถึง: memory จาก new[] ต้อง free ด้วย delete[] เท่านั้น ห้ามใช้ delete เฉยๆ เด็ดขาด — การจับคู่ผิดก็เป็น undefined behavior เหมือนกัน นี่คือเหตุผลที่ smart pointer (บท 1.3 เซคชัน 3) มีอยู่: มันเปลี่ยน "ต้องจำให้เรียก delete ให้ถูก" ให้กลายเป็น "destructor ทำให้อัตโนมัติ" ไอเดีย RAII เดียวกับเซคชัน 3.3 ข้างบน
// Array of Structs (AoS) -- the usual first instinct
struct Particle {
float x, y, z; // position
float vx, vy, vz; // velocity
float life;
int id; // rarely touched by the hot update loop
};
Particle particles[10000]; // one Particle is 8 floats/ints = 32 bytes
// Struct of Arrays (SoA) -- reorganized for the hot loop
struct ParticleSystem {
float x[10000], y[10000], z[10000];
float vx[10000], vy[10000], vz[10000];
float life[10000];
int id[10000];
};
void UpdatePositions(ParticleSystem& ps, float dt, int count) {
for (int i = 0; i < count; i++) {
ps.x[i] += ps.vx[i] * dt;
ps.y[i] += ps.vy[i] * dt;
ps.z[i] += ps.vz[i] * dt;
}
}
บท 3.2 พาไล่การจัดโครงสร้างใหม่แบบนี้เป๊ะๆ ให้ particle system ไว้แล้วอย่างละเอียด ห้องสัมภาษณ์แค่อยากได้เวอร์ชันเร็วและถูกต้องของมัน พร้อมใช้ทันที CPU สมัยใหม่ดึง memory มาเป็นก้อนขนาดคงที่เรียกว่า cache line (64 byte บน hardware desktop และมือถือส่วนใหญ่, บท 3.1) — ไม่ใช่ทีละตัวแปร ใน AoS layout หนึ่ง Particle คือ 32 byte ดังนั้น cache line เดียวโหลดมาได้ทั้ง particle สองตัว แต่ UpdatePositions อ่านแค่ 6 จาก 8 field ของ particle แต่ละตัว — life กับ id ติดมาด้วยแบบไม่ได้ใช้ เสียแบนด์วิดท์ไปเยอะพอสมควรในทุก cache line ที่ loop แตะถึง ใน SoA layout array x ถูก pack ติดกันเป็นเนื้อเดียว มีแต่ค่า x ล้วนๆ ดังนั้น cache line 64 byte เดียวส่งค่าติดกัน 16 ค่าที่ loop จะใช้จริงๆ มาให้ ไม่เสียอะไรเลย นี่คือเหตุผลที่ hot loop ต่อเฟรมที่วนผ่านวัตถุเล็กๆ เป็นพันตัว — particle, physics body, boid, อะไรก็ตามที่ data-oriented — มักถูกจัดโครงสร้างใหม่แบบนี้ ทั้งที่การเก็บ field ที่เกี่ยวข้องกันไว้ใน struct เดียวคือสัญชาตญาณ OOP ปกติ
vector มาใช้เป็นค่าเริ่มต้นไหม (บท 1.4), อธิบายได้ไหมว่าทำไม linked list ถึงแพ้ array ที่ต่อเนื่องกันบน hardware สมัยใหม่ทั้งที่ Big-O ของมันดูดีบนกระดาษ (บท 1.4 เซคชัน 2's cache argument) และคุณพูดถึง memory layout ได้เองโดยไม่ต้องถูกกระตุ้นไหม ความลึกในสองสามหัวข้อชนะรายการตื้นๆ ยี่สิบหัวข้อรอบสัมภาษณ์สาย gameplay หยิบคำถามห้าข้อนี้มาถามตลอด ทั้งห้าข้อต่อยอดจากบท 4.2 (MonoBehaviour lifecycle, coroutine, ScriptableObject) และบท 1.2 (พื้นฐาน C#, value กับ reference type)
using UnityEngine;
public class UpdateVsFixed : MonoBehaviour
{
public float moveSpeed = 5f;
public float forceAmount = 10f;
void Update()
{
// once per RENDERED frame -- Time.deltaTime varies frame to frame
// good for: input, camera movement, anything tied to what's on screen
transform.Translate(Vector3.forward * moveSpeed * Time.deltaTime);
}
void FixedUpdate()
{
// runs on a FIXED clock (default: every 0.02s = 50 times/second),
// independent of the render frame rate -- good for physics forces
GetComponent<Rigidbody>().AddForce(Vector3.forward * forceAmount);
}
}
Update รันครั้งเดียวต่อเฟรมที่ render ดังนั้นมันรันบ่อยเท่ากับความเร็วที่เกม render — 200 ครั้งต่อวินาทีบนเครื่องแรงๆ, 20 ครั้งต่อวินาทีบนเครื่องที่กำลังฝืด — และ Time.deltaTime ยืดหรือหดตามให้พอดี FixedUpdate รันบนนาฬิกาคงที่ของตัวมันเอง (บท 4.2 เซคชัน 4-5; เนื้อหา fixed-vs-variable-timestep ในบท 6.1 อธิบายว่าทำไม physics ถึงต้องการแบบนี้) ไม่ขึ้นกับอัตรา render เลย: บนเครื่องที่ holding 200fps อยู่ FixedUpdate ก็ยังยิงพอดี 50 ครั้งต่อวินาที ไม่มากไปกว่านั้น; บนเครื่องที่ฝืดอยู่ที่ 20fps เฟรมที่ render เฟรมเดียวอาจมี FixedUpdate ถูกเรียกศูนย์, หนึ่ง, หรือหลายครั้งติดกันในขณะที่ scheduler ของ Unity พยายามไล่นาฬิกา physics ให้ทันเวลาจริง physics force ควรอยู่ใน FixedUpdate โดยเฉพาะเพราะการอินทิเกรต physics ต้องการ timestep ที่คงที่เพื่อให้เสถียรทางตัวเลข — การอ่าน Time.deltaTime แทน Time.fixedDeltaTime ในโค้ด physics เป็นบั๊กที่แอบซ่อนพบบ่อย ที่โผล่มาเป็นความไม่เสถียรเฉพาะตอนเฟรมแย่ๆ เท่านั้น
using UnityEngine;
using System.Collections;
public class PoisonEffect : MonoBehaviour
{
public void ApplyPoison(int totalDamage, int ticks, float tickInterval)
{
StartCoroutine(PoisonRoutine(totalDamage, ticks, tickInterval));
}
IEnumerator PoisonRoutine(int totalDamage, int ticks, float tickInterval)
{
int perTick = totalDamage / ticks;
for (int i = 0; i < ticks; i++)
{
yield return new WaitForSeconds(tickInterval);
TakeDamage(perTick);
Debug.Log("tick " + (i + 1) + " of " + ticks + ", dealt " + perTick);
}
}
void TakeDamage(int amount) { /* subtract from health here */ }
}
ApplyPoison(30, 3, 1f) คำนวณ perTick = 10 แล้ว log tick 1 of 3, dealt 10, รอหนึ่งวินาที, log tick 2 of 3, dealt 10, รออีกหนึ่งวินาที, log tick 3 of 3, dealt 10 — สามช่วงเวลาแยกกัน ห่างกันหนึ่งวินาที ไม่ใช่พร้อมกันหมด coroutine คือฟังก์ชันที่หยุดพักได้ที่ statement yield แล้วกลับมาทำงานต่อทีหลัง ขับเคลื่อนโดย main thread เดียวของ Unity ทั้งหมด ครั้งละหนึ่งเฟรม เช็คว่าเงื่อนไขการกลับมาทำงานของ coroutine ที่ถูกพักไว้แต่ละตัว (ในที่นี้คือ "ผ่านไป tickInterval วินาทีหรือยัง") ผ่านหรือยัง เรื่องนี้ควรพูดออกมาให้ชัด เพราะมันคือความเข้าใจผิดที่พบบ่อยที่สุด: coroutine ไม่ใช่ thread (บท 3.3 พูดถึง thread จริงๆ กับ data race ที่มาพร้อมกัน) — ไม่มีอะไรรันขนานกันเลย มันคือการหยุดพักและกลับมาทำงานต่อแบบ cooperative บน thread เดียวกับที่ game logic ที่เหลือรันอยู่ สองข้อควรระวังเชิงปฏิบัติ: coroutine ที่ start บน GameObject ที่ถูก disable หรือ destroy จะหยุดเงียบๆ โดยไม่มีคำเตือนใดๆ และ coroutine ไม่มีค่า return — สื่อสารผลลัพธ์ออกมาผ่าน callback หรือ field ที่แชร์กันแทน
using UnityEngine;
using UnityEngine.UI;
public class AllocatesEveryFrame : MonoBehaviour
{
public Text hpLabel;
int currentHp = 50;
int maxHp = 100;
void Update()
{
// builds a brand-new string on the heap every single frame,
// even on the frames where HP hasn't changed at all
hpLabel.text = "HP: " + currentHp + " / " + maxHp;
}
}
Profiler ของ Unity มีคอลัมน์ "GC Alloc" ต่อเฟรม เวอร์ชันนี้แสดงตัวเลขที่ไม่ใช่ศูนย์ในทุกๆ เฟรมเลย 60 ครั้งขึ้นไปต่อวินาที ไม่ว่าตัวเลข HP จะเปลี่ยนจริงหรือไม่ก็ตาม การ allocate เล็กๆ แต่ละครั้งจะถูก garbage collector เก็บกวาดในที่สุด และ collection pause ก็คือจังหวะกระตุกแบบที่การ profiling ตามหาเจอพอดี — เรื่องนี้จะกลับมาโดยตรงในสถานการณ์ debugging ของเซคชัน 5 นี่คือเวอร์ชันที่แก้แล้ว:
using UnityEngine;
using UnityEngine.UI;
using System.Text;
public class NoPerFrameAlloc : MonoBehaviour
{
public Text hpLabel;
int currentHp = 50;
int maxHp = 100;
int lastShownHp = -1;
StringBuilder sb = new StringBuilder(32); // built ONCE, reused every frame
void Update()
{
if (currentHp == lastShownHp) return; // nothing changed, skip the work entirely
sb.Clear();
sb.Append("HP: ").Append(currentHp).Append(" / ").Append(maxHp);
hpLabel.text = sb.ToString(); // still allocates one string, but only when HP changed
lastShownHp = currentHp;
}
}
สองการแก้แยกกันที่ซ้อนกัน: การเช็ค dirty-flag ที่ข้ามงานทั้งหมดไปเลยในเฟรมส่วนใหญ่ (ปกติเป็นสัดส่วนใหญ่มาก) ที่ไม่มีอะไรเปลี่ยน และ StringBuilder ที่ใช้ซ้ำแทนการต่อ + เป็นเชน ซึ่งข้างใต้สร้างแล้วทิ้ง string กลางทางไปหลายตัวในทุกบรรทัดเดียว มันยัง allocate string สุดท้ายผ่าน ToString() อยู่ดี แต่แค่ในเฟรมที่ HP เปลี่ยนจริงๆ ไม่ใช่หกสิบครั้งต่อวินาทีไม่ว่าอะไรจะเกิดขึ้น แหล่งอื่นๆ ของการ allocation ที่ควรพูดถึงจากความจำได้ แม้ไม่มีตัวอย่างโค้ดให้แต่ละอัน: การ boxing value type เข้าไปใน API ที่รับ object, lambda closure ที่ capture ตัวแปร local, foreach บน collection บางชนิด, LINQ method อย่าง Where กับ Select, และ — คำถามหลอกยอดฮิต — Camera.main ที่แอบทำการค้นหาแบบ GameObject.Find ข้างในทุกครั้งที่ถูกเรียก ดังนั้นการเรียกมันใน Update ทั้งเสี่ยง allocation แฝงและมีต้นทุนการค้นหาแฝงด้วย ทั้งหมดนี้โอเคถ้าอยู่ใน Awake หรือ Start ที่รันแค่ครั้งเดียว วินัยตรงนี้เจาะจงเฉพาะอะไรก็ตามที่รันทุกเฟรม
using UnityEngine;
[CreateAssetMenu(menuName = "Data/EnemyStats")]
public class EnemyStats : ScriptableObject
{
public string enemyName;
public int maxHp;
public float moveSpeed;
}
public class Enemy : MonoBehaviour
{
public EnemyStats stats; // many Enemy instances can point at the SAME asset
int currentHp;
void Awake()
{
currentHp = stats.maxHp; // read shared config, keep per-instance state separate
}
}
ScriptableObject คือ data container asset ที่อยู่บนดิสก์ในโปรเจกต์ ไม่ได้ผูกกับ GameObject ไหน หรือผูกกับ lifetime ของ scene ใดเลย — ทำให้มันแชร์กันได้ตามธรรมชาติ Enemy แต่ละตัวของชนิดเดียวกันชี้ไปที่ asset EnemyStats ตัวเดียวกันได้ แทนที่แต่ละตัวจะพกสำเนาส่วนตัวของตัวเอง เหมือนกับที่บท 4.2 (เซคชัน 13) และระบบ item-definition ในบท 6.4 ใช้อยู่แล้ว หยิบมันมาใช้สำหรับข้อมูล config ที่แชร์กันและอ่านเป็นหลัก, สำหรับค่าที่ designer ควรปรับได้โดยไม่ต้องแตะโค้ดหรือ reload scene, และสำหรับ event channel แบบเบาๆ (เซคชัน observer-pattern ในบท 6.7) ที่แยก raiser ออกจาก listener โดยไม่มี hard reference ระหว่างกันเลย
MonoBehaviour (หรือ plain class ที่มันเป็นเจ้าของ) reset ใน Awake; ScriptableObject ควรคงสถานะเป็น config ที่แชร์กันและอ่านเป็นหลักไว้เวอร์ชันสั้นๆ ตามลำดับ: profile ก่อน บน target device จริง ก่อนจะไปเปลี่ยนอะไรทั้งนั้น; หาให้ได้ว่าต้นทุนเป็น CPU-bound หรือ GPU-bound; แล้วค่อยใช้วิธีแก้ที่ตรงกับปัญหา เซคชัน 5 ข้างล่างนี้ไล่วิธีการแบบเต็มรูปแบบผ่านสถานการณ์จริง — checklist ตรงนี้คือกล่องเครื่องมือเฉพาะของ Unity ที่วิธีการนั้นจะหยิบมาใช้พอรู้แล้วว่าเวลาไปอยู่ที่ไหน:
กฎเดียวที่สำคัญกว่ารายการข้างบนทั้งหมดรวมกัน: ห้ามใช้วิธีแก้โดยไม่มีการวัดจาก profiler ทั้งก่อนและหลัง ผู้ต้องสงสัยที่ "ดูชัดเจน" — texture ใหญ่, shader ซับซ้อน — บางทีก็ไม่ใช่ต้นทุนจริง และการเปลี่ยนแปลงที่ทำจากการเดาไม่พิสูจน์อะไรเลยทั้งสองทาง เซคชัน 5 แสดงให้ดูเป๊ะๆ ว่ากระบวนการที่วัดผลแบบทำซ้ำไปเรื่อยๆ นั้นหน้าตาเป็นยังไงในทางปฏิบัติ
สตูดิโออยากดูคุณใช้เหตุผลไล่ปัญหาที่ไม่เคยเห็นมาก่อนด้วยวิธีการที่ทำซ้ำได้ มากกว่าฟังคุณท่องข้อเท็จจริงที่จำมา "เกมรันที่ 20fps บนมือถือ คุณจะเช็คอะไรก่อน?" ไม่ใช่คำถามเรื่อง performance จริงๆ หรอก — มันคือคำถามว่าคุณมีกระบวนการที่เป็นระบบอยู่จริงไหม หรือมีแค่รายการของสิ่งที่เคยได้ยินว่าช่วยได้บางที
ก่อนจะเปิด Profiler แบบละเอียด มีวิธีแยกที่เร็วและไม่ต้องออกแรงมากที่ควรทำก่อน: ลดความละเอียดของภาพลงชั่วคราว — ถ้า frame rate ดีขึ้น เกมนี้อย่างน้อยส่วนหนึ่งเป็น GPU-bound (fill rate, overdraw, ต้นทุน shader) ลดจำนวน enemy/object ลงชั่วคราวแทน — ถ้าอันนั้นทำให้ดีขึ้น เกมนี้อย่างน้อยส่วนหนึ่งเป็น CPU-bound (scripts, การส่ง draw call, physics) การทดลองราคาถูกอันเดียวนี้ ทำได้ในไม่ถึงหนึ่งนาที ก็บอกได้แล้วว่าควรไปขุดครึ่งไหนของ diagram ข้างบนก่อน
นี่คือ model answer โครงสร้างเป็น trace ที่ worked out จริงๆ แทนที่จะเป็นรายการทิป — นี่คือหน้าตาของคำว่า "เป็นระบบ" เมื่อมีตัวเลขจริงติดมาด้วย:
// a model trace of the debugging session, start to finish
// baseline, profiled on a mid-range Android device (NOT the editor):
// total frame time: 50ms (20 fps) CPU main: 38ms CPU render: 6ms GPU: 6ms
// CPU main is by far the largest slice -> this is CPU-bound, so shader or
// texture changes would not have helped here -- ignore the GPU side for now
// step 1: check the GC Alloc track -> spiking about 4KB every frame, traced to
// a UI label rebuilding its string every Update even when HP hasn't changed
// fix: dirty-flag the label (section 4.3's fixed version), only rebuild on change
// total frame time: 41ms (24 fps) CPU main: 29ms -- improved, not solved, keep going
// step 2: re-profile -> CPU main still dominant. Deep Profile shows 200 calls to
// GetComponent per frame, inside a loop that runs in Update
// fix: cache the reference once in Awake (chapter 4.2, section 9) instead of
// calling GetComponent every single frame
// total frame time: 24ms (41 fps) CPU main: 12ms -- CPU is no longer the bottleneck
// step 3: re-profile again -> GPU time is now the largest slice (9ms of 24ms).
// the Frame Debugger shows heavy overdraw from three stacked transparent
// UI panels
// fix: this becomes the NEXT thing to investigate -- the bottleneck MOVED, it
// did not disappear, and reporting that out loud is the correct, expected answer
สังเกตรูปแบบของคำตอบนั้น: วัดบน target จริงก่อน, ตั้งสมมติฐานแค่หนึ่งข้อ, เปลี่ยนแค่หนึ่งอย่าง, วัดใหม่เทียบกับตัวเลขก่อนหน้า ไม่ใช่เทียบกับความรู้สึก และคาดหวังว่า bottleneck จะย้ายที่ ไม่ใช่หายไปเลย จุดสุดท้ายนี้สำคัญกว่าที่ฟังดู — candidate ที่พูดว่า "ผมแก้แล้ว" หลัง step 1 แล้วหยุด ยังไม่ได้พิสูจน์อะไรจริงๆ เลย; candidate ที่วัดใหม่ทุกครั้งหลังการเปลี่ยนแปลง แล้วชี้ออกมาว่าตอนนี้ GPU เป็นตัวจำกัดแล้ว กำลังแสดงกระบวนการเป๊ะๆ ที่สตูดิโออยากเห็นถูกทำซ้ำกับบั๊กจริงหกเดือนหลังเข้างาน
รายการสั้นๆ ของคำถามสถานการณ์อื่นๆ ที่ควรมีวิธีการหนึ่งบรรทัดพร้อมไว้ แม้จะไม่มี trace ที่ worked out เต็มรูปแบบให้แต่ละอัน: "ทำงานได้ใน editor แต่ crash เฉพาะใน build" (เช็ค platform-specific #if block, asset ที่หายไปจาก build, หรือโค้ดที่ถูก IL2CPP strip ออกไปเพราะเข้าถึงได้ผ่าน reflection เท่านั้น); "กระตุกทุกๆ สองสามวินาที ไม่ใช่ตลอดเวลา" (pattern เป็นช่วงๆ แบบนี้ชี้ไปทาง garbage collection pause มากกว่าต้นทุนต่อเฟรมที่คงที่ — แบบฝึกหัด 3 ท้ายบทนี้ไล่ตัวอย่างนี้เป๊ะๆ ให้ดู); "ทำงานคนเดียวได้ดี แต่พังตอน multiplayer" (มองหา client-only assumption กับลำดับที่ non-deterministic บท 14.2 เซคชัน 11)
take-home ไม่ได้ถูกตัดสินเป็นหลักจากว่าฟีเจอร์ทำงานไหม — งานส่วนใหญ่ทำงานได้อยู่แล้ว มันถูกตัดสินจากว่าโค้ดอ่านแล้วเหมือนของที่ทีมสร้างต่อยอดได้อย่างปลอดภัยในสัปดาห์หน้าไหม ปฏิบัติกับมันเหมือน pull request production เล็กๆ จริงๆ อันหนึ่ง ไม่ใช่การโชว์พลังแบบ coding challenge: แก้ตามสโคปที่ถูกขอมาเป๊ะๆ, รักษาความสะอาดและอ่านง่ายเหนือความฉลาดโอ้อวด, และอธิบายการตัดสินใจของคุณแทนที่จะปล่อยให้ผู้รีวิวต้องเดาเอาเอง
อดใจไม่เพิ่มฟีเจอร์ที่ไม่มีใครขอ มันน่าเสียดายที่จะอยากโชว์ความกว้างด้วยการแปะระบบ save, เมนู settings, หรือกลไกเสริมที่ brief ไม่เคยพูดถึง — แต่สตูดิโออ่านสโคปที่ไม่ได้ถูกขอมาว่าเป็น signal เกี่ยวกับพฤติกรรมของคุณบน deadline จริง ไม่ใช่คะแนนพิเศษ การรู้ว่าเมื่อไหร่ควรหยุดคือทักษะในตัวมันเอง และงานที่บวมเกินไปมักอ่านได้ว่าเป็น candidate ที่ยังไม่มีทักษะนี้
README.md
## What this does
One or two sentences. What the take-home asked for, in your own words.
## How to run it
Exact commands. Assume the reviewer has 2 minutes, not 20.
## Decisions and tradeoffs
- Chose X over Y because [reason]. This is the part interviewers actually read.
- Left Z out of scope because the brief didn't ask for it -- happy to add it,
wanted to keep this focused instead of guessing what else you wanted.
## What I'd do with more time
- One or two concrete next steps, not a wishlist.
## Known limitations
- Anything you know is incomplete or fragile. Say it yourself, first.
README ที่จัดแบบนี้มีค่ามากกว่าโค้ดฉลาดๆ บรรทัดเดียวในงานทั้งหมด เพราะมันเป็นส่วนที่แสดง judgment ไม่ใช่แค่ execution — การพูดถึง tradeoff ของตัวเองก่อนที่ผู้รีวิวจะต้องไปหาเจอเอง คือสิ่งที่เพื่อนร่วมทีมซีเนียร์ทำใน pull request description จริงๆ เป๊ะๆ รักษา commit history ให้อ่านง่ายด้วย: commit ที่มีความหมายจำนวนน้อยและสโคปดี อ่านดีกว่า commit ยักษ์อันเดียวที่ซ่อนกระบวนการคิดทั้งหมด และดีกว่า commit เล็กๆ ห้าสิบอันแบบ "wip" ที่ซ่อนมันในอีกทางหนึ่ง ถ้าคุณได้รับ codebase ที่มีอยู่แล้วให้ต่อยอด แทนที่จะเป็น repo เปล่า ให้เข้ากับสไตล์เดิมของมัน แม้ในจุดที่คุณจะเลือกทำต่างออกไปเอง — ความสอดคล้องกับโค้ดรอบข้างเป็นส่วนหนึ่งของสิ่งที่ถูกให้คะแนนด้วยตัวมันเอง
STAR คือโครงสร้างง่ายๆ สำหรับตอบคำถาม "เล่าเรื่องตอนที่คุณ..." โดยไม่พูดวกวน: Situation (บริบทหนึ่งหรือสองประโยค), Task (สิ่งที่คุณรับผิดชอบเจาะจง), Action (สิ่งที่คุณทำจริงๆ — ตรงนี้ควรเป็นส่วนที่ใช้คำมากที่สุดในคำตอบ และควรใช้ "ผม/ฉัน" มากกว่า "เรา" เพราะ interviewer กำลังพยายามแยกแยะ contribution เฉพาะตัวของคุณ) และ Result (ผลลัพธ์ ยิ่งมีตัวเลขจริงติดมาด้วยยิ่งดี)
นี่คือตัวอย่างที่ worked out เต็มรูปแบบ สำหรับคำถาม "เล่าถึงความท้าทายเชิงเทคนิคที่คุณแก้ไขในโปรเจกต์หนึ่งของคุณ":
Situation: "ผมกำลังสร้างเกม third-person platformer เล็กๆ ใน Unity เป็นโปรเจกต์เดี่ยว โดยตั้งเป้าแท็บเล็ต Android รุ่นล่างเป็นสเปกขั้นต่ำ" Task: "ระหว่างทาง frame rate ตกลงมาเหลือประมาณ 20fps พอมี enemy อยู่บนจอราวสามสิบตัวพร้อมกัน และผมต้องดึงมันกลับไปที่ 30fps ที่เสถียร โดยไม่ตัดจำนวน enemy ออก เพราะความหนาแน่นของฝูงชนเป็นส่วนหนึ่งของการออกแบบด่าน" Action: "ผม profile บนแท็บเล็ตจริงแทนที่จะเป็น editor แล้วพบว่าเฟรมเป็น CPU-bound มี GC Alloc spike ก้อนใหญ่ที่ตามรอยไปเจอว่าสคริปต์ AI ของ enemy แต่ละตัวเรียก GetComponent ทุกเฟรมและสร้าง target list ใหม่ทุก Update ผมแคช component reference ไว้ครั้งเดียวใน Awake, แทนที่การสร้าง list ใหม่ทุกเฟรมด้วย list เดิมที่ใช้ซ้ำ และทำ pool ให้ particle system เอฟเฟกต์โดนตีของ enemy แทนที่จะ instantiate กับ destroy มันตลอดเวลา" Result: "frame time ตกลงจากประมาณ 50ms เหลือ 18ms บนแท็บเล็ตนั้น เกมรักษา 30fps ที่เสถียรได้พร้อมฝูง enemy สามสิบตัวเต็มๆ และผมได้นิสัยการ profile ก่อนเสมอติดตัวมา ซึ่งตอนนี้ผมใช้กับทุกปัญหา performance ไม่ใช่แค่อันนั้นอันเดียว"
สังเกตว่าคำตอบนี้ไม่ได้แต่งอะไรขึ้นมาใหม่เลย — มันไล่ตามวิธีการ debugging เป๊ะๆ จากเซคชัน 5 ประยุกต์กับโปรเจกต์เจาะจงจริงหนึ่งอัน พร้อมตัวเลขก่อน-หลังจริง ความซ้อนทับนี้ไม่ใช่เรื่องบังเอิญ: interviewer ที่ได้ยินคำตอบนี้ได้ทั้ง "คนนี้มีโปรเจกต์จริงไหม" และ "คนนี้รู้จัก debug performance จริงไหม" ยืนยันในหกสิบวินาทีเดียวกัน
การสัมภาษณ์กำลังประเมินสตูดิโอพอๆ กับที่สตูดิโอกำลังประเมินคุณ และคำถามที่คุณถามก็เป็นส่วนหนึ่งของสิ่งที่ถูกประเมินด้วยเช่นกัน — คำถามที่ดีแสดงความอยากรู้เชิง engineering จริงๆ ไม่ใช่แค่ความสนใจอยากได้ offer
สองเรื่องที่ควรเลี่ยง: เปิดด้วยค่าตอบแทนกับสวัสดิการในสเตจนี้ (นั่นคืองานของสเตจ recruiter กับสเตจ offer, สเตจ 1 กับ 6 ของ pipeline ไม่ใช่รอบเทคนิคหรือรอบทีม) และถามอะไรก็ตามที่ดูเว็บไซต์ของสตูดิโอสามสิบวินาทีก็ตอบได้แล้ว — มันส่งสัญญาณว่าคุณไม่ได้เตรียมตัวมา
คำถามคณิตศาสตร์หกข้อเดียวกันและวิธีการ debugging เดียวกันใช้ได้ทุกที่ แต่ตรงไหนที่ interviewer ใช้เวลาส่วนใหญ่ของชั่วโมงนั้นเปลี่ยนไปมากขึ้นอยู่กับว่าสตูดิโอแบบไหนกำลังรับสมัคร อ่านตรงนี้ให้ถูกก่อนเดินเข้าห้อง จะเปลี่ยนสิ่งที่คุณควรทบทวนคืนก่อนสัมภาษณ์
สตูดิโอ mobile live-service — รูปแบบสตูดิโอแบบ HoYoverse — เจาะลึกความรู้ Unity หนักมาก (ScriptableObject, addressables กับ asset streaming, UI performance, coroutine, พื้นที่ของบท 4.2 กับบท 12.1) และเจาะเรื่อง optimization กับ memory หนักด้วย เพราะเกมต้องรันได้โอเคบน hardware Android กับ iOS รุ่นล่างที่เก่าเป็นปีๆ ในขณะที่อัปเดตตลอดเวลาโดยไม่ทำให้ระบบ production ที่ live พังลง (checklist mobile-performance ในบท 8.5, เนื้อหา live-ops กับ gacha ในบท 14.4) คาดหวังได้ว่าทั้ง take-home และรอบ live debugging จะเอียงไปทาง "ทำให้ scene Unity นี้เร็วขึ้น" มากกว่า "implement algorithm นี้จากศูนย์"
สตูดิโอ console หรือ PC AAA — โดยเฉพาะสตูดิโอที่หนัก engine หรือ ship บนเอนจิ้นทำเอง — เจาะ C++ หนักมาก (ทั้งเซคชัน 3 ข้างบน บวก template กับ STL จากบท 1.3), engine internal (ไม่ว่าจะทำเองหรือ Unreal, บท 5.x), memory layout ที่ cache-aware (บท 3.1-3.2), และบางทีก็ความรู้ rendering-pipeline (บท 7.1) สำหรับอะไรก็ตามที่เกี่ยวกับกราฟิก คาดหวังคำถาม whiteboard ที่ให้ใช้เหตุผลเรื่อง memory กับ ownership มากกว่าคำถามที่ให้ต่อ gameplay ให้เร็วๆ
สตูดิโอ competitive multiplayer วาง networking ไว้ตรงกลางของการสัมภาษณ์: สถาปัตยกรรม client-server, UDP กับ TCP, tick rate (บท 14.1), client-side prediction, server reconciliation, และ lag compensation (บท 14.2) — บวกกับความเอียงหนักไปทางคำถามแบบ "อธิบาย tradeoff" มากกว่าคำถามแบบ "ให้คำตอบที่ถูกต้องหนึ่งเดียว" เพราะปัญหาอย่าง "ผมตายหลังกำแพง" (บท 14.2 เซคชัน 8) ไม่มีทางแก้ที่สะอาดจริงๆ มีแต่ tradeoff และ interviewer อยากเห็นว่าคุณรู้ว่า tradeoff นั้นมีอยู่จริง มากกว่าเสนอทางแก้มหัศจรรย์อย่างมั่นใจที่จริงๆ ไม่มีอยู่จริง
สัปดาห์ที่ 1: ทำคำถามในเซคชัน 2 กับเซคชัน 3 ทุกข้อใหม่จากความจำ ไม่มีโน้ต พูดออกเสียงจริงๆ — ไม่ใช่แค่จำคำตอบที่ถูกได้ตอนเห็นมัน แต่ผลิตมันออกมาแบบเย็นชา แบบที่การสัมภาษณ์จริงเรียกร้อง ซื่อสัตย์กับตัวเองว่าหัวข้อไหนรู้สึกไม่มั่นคง แล้วใช้เวลาที่เหลือของสัปดาห์เจาะจงตรงนั้น ไม่ใช่กระจายเท่าๆ กันทุกที่
สัปดาห์ที่ 2: เลือกโปรเจกต์ portfolio แค่หนึ่งอันแล้วขัดมันให้เงา แทนที่จะกระจายความพยายามไปหลายอัน ทำให้คนแปลกหน้าเล่นหรือ build ได้ในไม่ถึงสองนาที เขียน README ของมันด้วยโครงจากเซคชัน 6 และ — สำหรับการสมัครออนไลน์ที่ demo สดทำไม่ได้ — อัดวิดีโอ walkthrough 60-90 วินาทีไว้เป็นตัวสำรอง
สัปดาห์ที่ 3: ลอง mock technical interview อย่างน้อยสองหรือสามครั้ง กับเพื่อน, mentor, หรืออัดเสียงตัวเองตอบสถานการณ์ debugging ของเซคชัน 5 ออกเสียงจับเวลา ซ้อมเรื่อง STAR ของคุณจากเซคชัน 7 จนแต่ละเรื่องลงตัวได้ในไม่ถึงเก้าสิบวินาทีโดยไม่วกวน ทำแบบฝึกหัดสไตล์ take-home จับเวลาหนึ่งครั้งภายใต้เงื่อนไขจริง รวมถึงเขียน README ด้วย ไม่ใช่แค่โค้ด
สัปดาห์ที่ 4: ค้นคว้าสตูดิโอเป้าหมายห้าถึงสิบแห่งผ่านมุมมองของเซคชัน 9 — การสัมภาษณ์ของแต่ละที่เจาะอะไรหนักที่สุด แล้วการเตรียมตัวของคุณตรงกันจริงไหม จัดเรียง portfolio กับ resume ใหม่ให้เอาเรื่องที่เกี่ยวข้องที่สุดขึ้นก่อนตามแต่ละสตูดิโอ เตรียมคำถามสามหรือสี่ข้อจากเซคชัน 8 ปรับให้เข้ากับแต่ละที่ สมัครเป็นชุดแทนที่จะหยอดทีละใบตลอดหลายเดือน — การสมัครเป็นชุดให้ timeline ที่เทียบกันได้ และในที่สุดก็ offer ที่เอามาชั่งน้ำหนักเทียบกันได้
a.x*b.x + a.y*b.y + a.z*b.z; sign ของมันบอกว่าสองทิศห่างกันน้อยกว่า 90 องศา (บวก), มากกว่า 90 (ลบ), หรือตั้งฉากพอดี (ศูนย์)Type&& กับ std::moveyield แล้วกลับมาทำงานต่อทีหลัง จัดตารางแบบ cooperative บน main thread เดียวของ Unity; ไม่ใช่ thread แยกforward = (0, 0, 1) เป้าหมายอยู่ในทิศ toTarget = (-2, 0, 1) เทียบกับตัวละคร ด้วยมือ: (a) คำนวณ Dot(forward, toTarget) แล้วบอกว่าเป้าหมายอยู่ข้างหน้าหรือข้างหลังตัวละคร; (b) คำนวณ Cross(forward, toTarget) แล้วต่อด้วย Dot(Cross(forward, toTarget), up) โดย up = (0, 1, 0) แล้วบอกว่าเป้าหมายอยู่ทางซ้ายหรือขวาของตัวละคร(a) Dot(forward, toTarget) = 0*(-2) + 0*0 + 1*1 = 1
Positive -> the target is in FRONT of the character (less than 90 degrees off forward).
(b) Cross(forward, toTarget):
x = fy*tz - fz*ty = 0*1 - 1*0 = 0
y = fz*tx - fx*tz = 1*(-2) - 0*1 = -2
z = fx*ty - fy*tx = 0*0 - 0*(-2) = 0
Cross(forward, toTarget) = (0, -2, 0)
Dot((0, -2, 0), (0, 1, 0)) = 0*0 + (-2)*1 + 0*0 = -2
Negative -> the target is to the character's LEFT.
คำตอบทั้งสองข้อตรงกับตัวเลขโดยตรง: toTarget มีค่า z-component เป็นบวก (ส่วนใหญ่อยู่ข้างหน้า) และมีค่า x-component เป็นลบ (เยื้องไปทางซ้ายของตัวละครเอง เมื่อ +X คือขวาในระบบพิกัดนี้) — การเช็ค dot product ยืนยัน "ข้างหน้า" และการเช็ค cross-แล้ว-dot ยืนยัน "ซ้าย" ตรงกับที่เวกเตอร์ดิบๆ บอกใบ้อยู่แล้วแค่มองด้วยตา ซึ่งเป็นวิธีที่ดีในการ sanity-check คำตอบที่ worked out ของตัวเองภายใต้แรงกดดันในห้องสัมภาษณ์
#include <iostream>
class Base {
public:
Base() { std::cout << "Base ctor\n"; }
~Base() { std::cout << "Base dtor\n"; } // NOT virtual
};
class Derived : public Base {
public:
Derived() { data = new int[100]; std::cout << "Derived ctor\n"; }
~Derived() { delete[] data; std::cout << "Derived dtor\n"; }
private:
int* data;
};
int main() {
Base* b = new Derived();
delete b; // what actually runs here?
}
Output:
Base ctor
Derived ctor
Base dtor
Derived dtor ไม่ print ออกมาเลย เพราะ ~Base() ไม่ได้ถูกมาร์กเป็น virtual มันจึงไม่มีช่องใน vtable และ delete b resolve ตามชนิด static ที่ประกาศไว้ของ b (Base*) ล้วนๆ แทนที่จะตามชนิดจริงของ object — ตรงข้ามกับตัวอย่าง TakeDamage ในเซคชัน 3.2 เป๊ะๆ ที่การมาร์กฟังก์ชันเป็น virtual คือสิ่งที่ทำให้ runtime dispatch เกิดขึ้นพอดี มีแค่ ~Base() ที่รัน ~Derived() ถูกข้ามไปทั้งหมด และ int[100] ที่ allocate ไว้ใน constructor ของ Derived ไม่เคยถูก free เลย — memory leak จริงๆ อันหนึ่ง บวกกับการข้าม cleanup เฉพาะของ derived ที่อาจมีอยู่ด้วย
วิธีแก้คือคำเดียว: มาร์ก base destructor เป็น virtual ~Base() { ... } virtual destructor จะได้ช่องใน vtable เหมือนกับที่ TakeDamage ได้ในเซคชัน 3.2 ดังนั้น delete b ตอนนี้ dispatch ไปที่ destructor ของ Derived ก่อนได้ถูกต้อง ซึ่งรัน cleanup ของตัวเองแล้วค่อย chain ขึ้นไปที่ destructor ของ Base อัตโนมัติ output ที่แก้แล้ว: Base ctor, Derived ctor, Derived dtor, Base dtor กฎทั่วไปที่ควรพูดได้จากความจำ: class ไหนก็ตามที่ตั้งใจให้ delete ผ่าน base-class pointer ต้องมี virtual destructor จบ ไม่มีข้อยกเว้น — เนื้อหา Rule of 0/5 ในบท 1.3 เซคชัน 9 พูดถึงกฎเดียวกันนี้จากฝั่ง constructor
ตัวรูปแบบเองคือเบาะแส ก่อนจะเปิด profiler ด้วยซ้ำ: นี่เป็นช่วงๆ ไม่ใช่คงที่ — frame time เฉลี่ยโอเคดี แต่มีอะไรบางอย่างพุ่งสูงกว่ามันมากทุกๆ สองสามวินาที bottleneck แบบ CPU หรือ GPU ที่คงที่จะแสดงออกมาเป็น frame time ที่สูงตลอดเวลา ไม่ใช่การพุ่งเป็นช่วงๆ ที่แยกจากกันซ้อนอยู่บนค่าเฉลี่ยที่ปกติดี รูปแบบเฉพาะแบบนี้ — ช่วงยาวๆ ที่ปกติ คั่นด้วยการพุ่งสั้นๆ ที่รุนแรง — คือลายเซ็นคลาสสิกของ garbage collection pause: การ allocate สะสมกันเงียบๆ ข้ามหลายเฟรมโดยไม่มีต้นทุนมากนักทีละอัน จนกว่า threshold ของ collector จะถูกข้าม แล้วมันต้อง stop the world สั้นๆ เพื่อเก็บกวาด memory
ตามวิธีการของเซคชัน 5: เปิด track GC Alloc ของ Profiler ก่อน แล้วยืนยันว่ามันตรงกับเฟรมที่พุ่งขึ้นด้วยสายตา — ถ้าการพุ่งใน "GC Alloc" หรือ marker "GC.Collect" ปรากฏพอดีในเฟรมที่ frame time กระโดด นั่นยืนยันสมมติฐานก่อนแตะโค้ดเลยด้วยซ้ำ ต่อไป หาว่าอะไรกำลังสะสมอยู่จริงๆ — การจับภาพ Deep Profile รอบๆ การพุ่งครั้งหนึ่งมักจะชี้ไปที่อะไรบางอย่างที่สร้าง temporary object เล็กๆ ทุกเฟรม ที่ถูกเก็บกวาดก็ต่อเมื่อแรงกดดันของ memory ข้าม threshold เท่านั้น: list ที่สร้างใหม่ด้วย LINQ ข้างในการอัปเดตต่อเฟรมของ enemy AI, particle system ที่ allocate array ใหม่ต่อการ emit หนึ่งครั้ง, หรือ UI element ที่สร้าง string ใหม่ทุกเฟรมเหมือนตัวอย่างในเซคชัน 4.3 เป๊ะๆ วิธีแก้ตามเซคชัน 4.3 กับเซคชัน 4.5 ตรงๆ: pool วัตถุที่ถูกสร้างซ้ำๆ, แคชและใช้ collection ซ้ำแทนที่จะสร้างใหม่ และเอาการ allocate ต่อเฟรมที่ไม่จำเป็นออกที่ต้นตอ สุดท้าย วัดใหม่ในช่วงเวลาที่ยาวพอ (อย่างน้อยช่วง 5-10 วินาทีระหว่างการพุ่งเดิม) เพื่อยืนยันว่าการพุ่งหายไปจริงๆ ไม่ใช่แค่บ่อยน้อยลง — วิธีแก้ครึ่งเดียวที่ทำให้การพุ่งบางลงโดยไม่กำจัดมันออกไป ก็เป็นผลลัพธ์จริงที่ควรรายงาน ไม่ใช่ "แก้แล้ว" แบบเงียบๆ
ทุกคำถามในบทนี้ลดรูปลงมาเหลือแค่การทดสอบพื้นฐานเดียวกัน: คุณผลิตคำตอบที่ถูกต้องและบีบอัดสำหรับสิ่งที่คุณรู้อยู่แล้วได้ไหม ภายใต้แรงกดดันเรื่องเวลา และคุณใช้เหตุผลออกมาเป็นคำพูดผ่านสิ่งที่คุณยังไม่รู้ในทันทีได้ไหม ด้วยวิธีการที่ทำซ้ำได้แทนที่จะเป็นการเดา คณิตศาสตร์, C++, C#, และสถานการณ์ debugging ล้วนเป็นแค่พื้นผิวต่างกันสำหรับทดสอบทักษะคู่เดียวกันนี้ — แผนเตรียมตัว, portfolio, และเรื่อง STAR ที่ซ้อมมาแล้ว คือวิธีที่ทำให้แน่ใจว่าการสัมภาษณ์จะได้เห็นมันจริงๆ