Що таке стандартний цикл гри C # / Windows Forms?


32

Коли ви пишете гру в C #, яка використовує звичайні старі форми Windows Forms та деякі графічні обгортки API, такі як SlimDX або OpenTK , як слід структурувати основний цикл гри?

Канонічна програма Windows Forms має вхідну точку вигляду

public static void Main () {
  Application.Run(new MainForm());
}

і хоча можна виконати щось необхідне, підключивши різні події Formкласу , ці події не дають очевидного місця для розміщення бітів коду для постійних періодичних оновлень логічних ігрових об'єктів або для початку та закінчення візуалізації каркас.

Яку техніку повинна використовувати така гра, щоб досягти чогось схожого на канонічне

while(!done) {
  update();
  render();
}

ігровий цикл, і що робити з мінімальними показниками продуктивності та GC?

Відповіді:


45

Application.RunВиклик жене своє повідомлення насоса Windows, який, в кінцевому рахунку , які сили всіх подій , які ви можете підключити на Formкласі (і інших). Щоб створити ігровий цикл у цій екосистемі, ви хочете слухати, коли насос повідомлення додатка порожній, і поки він залишається порожнім, виконайте типові "вхідні процеси процесу, оновіть логіку гри, візьміть сцену" кроки прототипічного циклу ігор .

У Application.Idleподію спрацьовує один раз кожен раз , коли черга повідомлень додатка буде очищена , і додаток переходить в початковий стан. Ви можете підключити подію до конструктора основної форми:

class MainForm : Form {
  public MainForm () {
    Application.Idle += HandleApplicationIdle;
  }

  void HandleApplicationIdle (object sender, EventArgs e) {
    //TODO: Implement me.
  }
}

Далі потрібно мати змогу визначити, чи програма ще не працює. IdleПодія спрацьовує тільки один раз, коли додаток стає простоює. Він знову не звільняється, поки повідомлення не потрапить у чергу, а потім черга знову не зникне. Windows Forms не розкриває спосіб запиту про стан черги повідомлень, але ви можете використовувати служби виклику платформи для делегування запиту в нативної функції Win32, яка може відповісти на це питання . Декларація про імпорт PeekMessageта його допоміжні типи виглядає так:

[StructLayout(LayoutKind.Sequential)]
public struct NativeMessage
{
    public IntPtr Handle;
    public uint Message;
    public IntPtr WParameter;
    public IntPtr LParameter;
    public uint Time;
    public Point Location;
}

[DllImport("user32.dll")]
public static extern int PeekMessage(out NativeMessage message, IntPtr window, uint filterMin, uint filterMax, uint remove);

PeekMessageв основному дозволяє переглядати наступне повідомлення у черзі; вона повертає істину, якщо така існує, а інша помилкова. Для цілей цієї проблеми жоден з параметрів не є особливо актуальним: значення має лише повернене значення. Це дозволяє записати функцію, яка повідомляє, чи програма ще не працює (тобто досі в черзі немає повідомлень):

bool IsApplicationIdle () {
    NativeMessage result;
    return PeekMessage(out result, IntPtr.Zero, (uint)0, (uint)0, (uint)0) == 0;
}

Тепер у вас є все необхідне, щоб написати повний цикл гри:

class MainForm : Form {
  public MainForm () {
    Application.Idle += HandleApplicationIdle;
  }

  void HandleApplicationIdle (object sender, EventArgs e) {
    while(IsApplicationIdle()) {
      Update();
      Render();
    }
  }

  void Update () {
    // ...
  }

  void Render () {
    // ...
  }

  [StructLayout(LayoutKind.Sequential)]
  public struct NativeMessage
  {
      public IntPtr Handle;
      public uint Message;
      public IntPtr WParameter;
      public IntPtr LParameter;
      public uint Time;
      public Point Location;
  }

  [DllImport("user32.dll")]
  public static extern int PeekMessage(out NativeMessage message, IntPtr window, uint filterMin, uint filterMax, uint remove);
}

Крім того, цей підхід максимально відповідає (з мінімальною залежністю від P / Invoke) до канонічного нативного циклу ігор Windows, який виглядає так:

while (!done) {
    if (PeekMessage(&message, window, 0, 0, PM_REMOVE)){
        TranslateMessage(&message);
        DispatchMessage(&message);
    }
    else {
        Update();
        Render();
    }
}

Яка потреба в роботі з такими функціями apis? Зробити деякий час блоком, керованим точним секундоміром (для керування в fps), не буде достатньо?
Емір Ліма

3
Вам потрібно повернутися з обробника Application.Idle в якийсь момент, інакше ваша програма замерзне (оскільки вона ніколи не дозволяє повторювати повідомлення Win32). Ви можете замість цього спробувати створити цикл на основі WM_TIMER повідомлень, але WM_TIMER не є настільки точним, як ви хотіли б, і навіть якщо це було б змусити все до найнижчої швидкості оновлення найменшого загального знаменника. Багато ігор потребують або хочуть мати незалежні показники оновлення візуалізації та логіки, деякі з яких (як фізика) залишаються фіксованими, а інші - ні.
Джош

Рідні ігрові цикли Windows використовують ту саму методику (я змінив свою відповідь, щоб включити просту для порівняння. Тимери, щоб примусити фіксовану швидкість оновлення, є менш гнучкими, і ви завжди можете реалізувати фіксовану швидкість оновлення в більш широкому контексті PeekMessage цикл стилю (використання таймерів з кращою точністю та впливом на GC, ніж WM_TIMERті, що базуються)
Josh

@JoshPetrie Щоб зрозуміти, вищевказана перевірка простою використовує функцію для SlimDX. Чи було б ідеально включити це у відповідь? Або ви випадково відредагували код, щоб прочитати "IsApplicationIdle", який є аналогом SlimDX?
Вон Хілтс

** Будь ласка, проігноруйте мене, я щойно зрозумів, що ви визначите це далі ... :)
Вуан Хілтс

2

Погодившись з відповіддю Джоша, просто хочу додати мої 5 копійок. Цикл повідомлень за замовчуванням WinForms (Application.Run) можна замінити наступним (без p / call):

[STAThread]
static void Main()
{
    using (Form1 f = new Form1())
    {
        f.Show();
        while (true) // here should be some nice exit condition
        {
            Application.DoEvents(); // default message pump
        }
    }
}

Також якщо ви хочете ввести якийсь код у насос повідомлення, тоді використовуйте це:

public partial class Form1 : Form
{
    protected override void WndProc(ref Message m)
    {
        // this code is invoked inside default message pump
        base.WndProc(ref m);
    }
}

2
Однак ви повинні бути обізнані про витрати на генерацію сміття DoEvents (), якщо ви виберете цей підхід.
Джош

0

Я розумію, що це стара тема, але я хотів би надати дві альтернативи запропонованим вище методам. Перш ніж я перейти до них, ось декілька підводних каменів із зробленими пропозиціями:

  1. PeekMessage несе значні витрати, як і методи бібліотеки, які називають його (SlimDX IsApplicationIdle).

  2. Якщо ви хочете використовувати буферизований RawInput, вам потрібно буде опитати насос повідомлень за допомогою PeekMessage на іншій потоці, відмінній від потоку інтерфейсу користувача, тому ви не хочете дзвонити йому двічі.

  3. Application.DoEvents не призначений для виклику в тісному циклі, проблеми GC швидко з'являться.

  4. Під час використання Application.Idle або PeekMessage, оскільки ви працюєте лише тоді, коли в режимі очікування, ваша гра чи додаток не працюватимуть під час переміщення чи зміни розміру вікна, без запаху коду.

Щоб обійти їх (крім 2, якщо ви їдете по дорозі RawInput), ви можете:

  1. Створіть Threading.Thread і запустіть свій ігровий цикл там.

  2. Створіть Threading.Tasks.Task за допомогою прапора IsLongRunning і запустіть його там. Корпорація Майкрософт рекомендує використовувати Завдання замість ниток сьогодні, і не важко зрозуміти, чому.

Обидві ці методи ізолюють ваш графічний API від потоку інтерфейсу та потоку повідомлень, як це рекомендується. Управління руйнуванням ресурсів / стану та рекреацією під час зміни розміру вікон також спрощене та естетично набагато професійніше, коли це зроблено із зробленого (проявляючи належну обережність, щоб уникнути тупиків із насосом повідомлення) поза потоком користувальницького інтерфейсу.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.