วันนี้พูดถึงเรื่องการเขียนเกมสักหน่อย พอดีว่าผมเคยเขียนเกมมาก่อนตอนสมัยทำงานใหม่ ๆ และตอนนี้ผมเริ่มอยากกลับมาเขียนเกมอีกแล้วก็เลยต้องรื้อฟื้นสักหน่อยครับ
Game Object ก็คือวัตถุที่อยู่ในเกม เป็นส่วนของลอจิคของเกม ตรงนี้จะรวมตั้งแต่ ตัวละครที่ผู้เล่นบังคับ ตัวละครที่ผู้เล่นไม่ได้บังคับ (NPC ทั้งหลาย รวมทั้งพวกศัตรู) วัตถุพวกกระสุนปืน หรือแม้กระทั่งฉากและอื่น ๆ เราจะมองว่าโปรแกรมเกมคือชุดของวัตถุในเกมและการปฎิสัมพันธ์กันระหว่างวัตถุก็ได้ครับ
โดยทั่วไป โปรแกรมประเภทเกมจะมีลักษณะแบบนี้ครับ
while(true) {
Input input = ReadInput();
ProcessGameObjects(input);
DrawGameObjects();
}
ผมละรายละเอียดเกี่ยวกับการออกจากลูปนะครับ
ในอดีตขณะที่เรามี CPU แกนเดียว การใช้ Thread เดียวร่วมกันทั้งโปรแกรมเป็นวิธีที่ดีที่สุด เพราะว่าต่อให้เราแยก Thread ไปมันก็ต้องสลับกันทำงานไปมา Performance ก็แย่ลง และไม่มีข้อดีอะไรจากการแยก Thread ดังนั้นเขียนโปรแกรมเป็นอนุกรมจะง่ายที่สุด
ดังนั้นฟังก์ชั่น ProcessGameObjects()
ก็จะมีหน้าตาประมาณนี้ครับ
void ProcessGameObjects(const Input& input) {
for(auto& obj : GetGameObjects()) {obj.Process(input);}
}
วิธีนี้จริง ๆ ไม่ได้มีข้อเสียอะไรนะ เพียงแต่ว่าโปรแกรมจะทำงานอยู่บนแกนเดียวของ CPU ซึ่งนั่นคือการไม่ได้ใช้งานอีก 75% ที่เหลือของระบบที่มี 4 แกน มันก็ดูน่าเสียดายนิดหน่อยล่ะครับ
ผมเคยคิดว่า ถ้าเราจะแยกให้แต่ละ object ทำงานอยู่บน thread ของตัวเองไปเลยจะดีมั้ย แล้วก็แยกออกมาจาก Render Loop ไปเลยด้วย คราวนี้เกมของเราก็จะมี thread เต็มไปหมด เกิดผมมีวัตถุในเกมประมาณสัก 100 ชิ้น ก็จะมี 100 thread ทำงานพร้อมๆ กัน
ตัว Game Object ก็อาจจะมีสภาพเป็น … แบบนี้
void GameObject::Process() {
while(true) {
Input input = ReadInput();
switch(input.type) {
//calculating things.
}
}
}
CreateThread([](gameobject.Process());).run();
while(true) {
DrawGameObjects();
}
ปัญหาของวิธีนี้คือคราวนี้เราจะมี thread เยอะมากจนควบคุมได้ยาก มีปัญหาเรื่อง synchronization ระหว่าง thread ที่วาดวัตถุขึ้นจอ กับ thread ของการประมวลผล และความจริงคือการสลับ thread ไปมาก็มี penalty เช่นกัน คราวนี้กลายเป็นว่ามี thread เยอะเกินไปจนทำให้ตัวโปรแกรมช้าลง
วิธีข้างบนผมไม่แนะนำให้ใช้นะครับ ปวดหัว 555
วิธีต่อไปจะเป็นการเปลี่ยน Game Loop แบบธรรมดาให้เป็น multi-thread แทน เป็นวิธีง่าย ๆ ครับ
while(true) {
Input input = ReadInput();
ProcessGameObjects(input);
DrawGameObjects();
}
void ProcessGameObjects(const Input& input) {
ThreadPool threadPool;
for(auto& obj : GetGameObjects()) {
threadPool.CreateThread([&input](){obj.Process(input);});
}
threadPool.WaitForAll();
}
วิธีนี้เป็นการแยกการคำนวนแต่ละวัตถุไปเป็นแต่ละ thread สั่งให้มันทำงาน แล้วรอจนกว่ามันจะทำงานเสร็จ จากนั้นก็ค่อยวาดแต่ละวัตถุขึ้นจอ วิธีนี้ดีตรงที่ว่ามันควบคุมได้ง่ายกว่ามาก ถ้าเราจะหยุดไม่ให้การคำนวนวัตถุทำงาน ก็แค่ไม่เรียก Process() ให้ทำงาน แค่นั้นเอง (เทียบกับวิธีบนที่ต้องสร้าง flag ขึ้นมาใหม่แล้วปวดหัวกว่ามาก) แต่ปัญหาอื่น ๆ ก็ยังมีมาให้ปวดหัวอยู่บ้างเหมือนกัน แต่ผมคิดว่าอันนี้ทำงานกับมันง่ายกว่าครับ
มันก็ยังมีประเด็นเรื่องของจำนวน thread มากเกินไป และการสลับไปมานั้นมันก็ค่อนข้างแพง ผมคิดว่าการบังคับให้มีจำนนวน thread น้อย ๆ นั้นจะให้ผลที่ดีกว่าเพราะว่าเรามีจำนวนแกนของซีพียูจำกัดนั่นแหละ
void ProcessGameObjects(const Input& input) {
constexpr int maxThreadCount = 4;
ThreadList threadPool;
auto gameObjects = GetGameObjects();
auto iter = gameObjects.first();
while(iter != gameObjects.last()) {
if(threadList.GetActiveThreadCount() < maxThreadCount && iter != gameObjects.end() {
auto &object = *iter;
threadPool.CreateThread([&input](){object.Process(input);});
++iter;
}
}
threadPool.WaitForAll();
}
อันนี้คือการจำกัดไม่ให้มี thread เกินกว่า 4 thread ทำงานพร้อม ๆ กันในเวลาเดียว ซึ่งจะทำงานกับระบบที่มี 4 แกนได้ดีกว่าการทำงานทีเดียวพร้อม ๆ กันเป็น 100 ครับ 🙂
ข้อเสียคือมันก็ยังมีเรื่องของ Synchronization ให้ปวดหัวอยู่ดีนั่นแหละ แต่ว่ามันจะง่ายกว่าการแยกออกจาก draw thread ไปเลยมากครับ (แต่การแยกออกจาก draw thread ไปเลยก็มีข้อดีของมันนะ)