Processing the game objects in multithread manner.

วันนี้พูดถึงเรื่องการเขียนเกมสักหน่อย พอดีว่าผมเคยเขียนเกมมาก่อนตอนสมัยทำงานใหม่ ๆ และตอนนี้ผมเริ่มอยากกลับมาเขียนเกมอีกแล้วก็เลยต้องรื้อฟื้นสักหน่อยครับ

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 ไปเลยก็มีข้อดีของมันนะ)

ใส่ความเห็น

อีเมลของคุณจะไม่แสดงให้คนอื่นเห็น ช่องข้อมูลจำเป็นถูกทำเครื่องหมาย *

This site uses Akismet to reduce spam. Learn how your comment data is processed.