Программируем графику на Direct3D 11 в среде .NET (часть 2)

в 14:56, , рубрики: .net, direct3d 11, DirectX, game development, Gamedev, метки: , , , ,

План снова немного поменялся, я решил поменять части 2 и 3 местами.

  1. Основы программирования 3D графики и отличия фиксированного и программируемого конвейеров
  2. Game loop, различные подходы к организации цикла рендера и обработки логики в играх
  3. Трансформации, проекции и передача параметров в шейдеры
  4. ???

Сначала я хотел ограничиться самым распространенным случаем, но наткнувшись на статью Koen Witters, решил что описание всех распространенных вариантов точно не будет лишним. Получился, скорее всего не перевод, а вольный пересказ, и примеры взяты в несколько измененном виде (в оригинале, как мне показалось, имена переменных и констант были совершенно неадекватными и только усложняли понимание).


Любая игра должна выполнять в цикле следующие задачи: обрабатывать устройства ввода, управлять искусственным интеллектом, обновлять состояние игры (включая не только координаты визуальных объектов, но и саму игровую логику), проигрывать звуки и музыку, отправлять данные по сети, обсчитывать физику и отображать что-то на экране.

Не все из этого есть в любой игре, но как минимум обновление состояния и отображение на экране присутствуют в каждой, поэтому мы пока упростим рассуждение и будем рассматривать только эти две стадии. Вы увидите, что уже в этом случае все становится очень непросто.

В данном случае цикл у нас будет выглядеть так:

while(isGameRunning)
{
	updateGameState();
	render();
}

Очевидно, проблема данного алгоритма в том, что он не учитывает время. То есть игра у нас будет выполняться с той скоростью, на какую способно наше железо. Собственно, лет 20 назад над этим мало кто заморачивался, т.к. скорость железа была вполне себе определенной. Вспомните популярную в те времена игру «Поле чудес», которая нормально работала на 80386, и «летала» как ошпаренная на пентиуме. Понятно, что сейчас так делать нельзя.

Решение «в лоб», которое напрашивается само собой: ограничить скорость до какого-либо фиксированного значения:

// количество итераций игрового цикла в секунду
const int FPS = 25;

// максимально допустимая длительность одной итерации
const int FrameDuration = 1000 / FPS;

// пусть функция time() возвращает время в миллисекундах с момента запуска игры
long nextFrameTime = time();

while( isGameRunning )
{
	updateGameState();
	render();

	// определим время, когда надо выполнить следующую итерацию цикла
	nextFrameTime += FrameDuration;

	// определим продолжительность требуемой задержки, чтобы следующая итерация началась вовремя
	long sleepDuration = nextFrameTime - time();

	if( sleepDuration >= 0 )
	{
		sleep( sleepDuration );
	}
	else
	{
		// ой
	}
}

Какие преимущества у такого подхода? Он предельно прост. Так как вы знаете, что обновление состояния происходит ровно 25 раз в секунду, писать код игры становится очень просто. Но у этого кода есть и недостатки. Пока ваше железо может уложиться с просчетом логики в максимальную длительность кадра, все хорошо. Но как только вы запустите игру на более медленном железе, которое не укладывается в заданное время, игра начнет «тормозить». И чем больше он будет не укладываться в это время — тем больше будут «тормоза». Причем в некоторых не особо нагруженных местах игра будет идти с нормальной скоростью. Такая переменная длительность игрового времени может сделать игру совершенно «неиграбельной». На существенно более быстром железе, чем требуется для вашей игровой логики, проблем с таким циклом не будет. Но вы теряете огромное количество процессорного времени впустую: игра будет показывать всего 25 кадров в секунду, когда железо, например, способно на 100. При быстрых движениях объектов на экране разница даже между 60 и 100 будет весьма существенна (если, конечно, ваш монитор способен на 100гц), не говоря уж про 25. Впрочем, такой цикл может иметь право на жизнь: например на мобильных платформах, где пользователь скорее предпочтет менее плавную графику в пользу существенной экономии заряда аккумулятора, особенно если игра не отличается динамичностью анимации.

Таким образом, жесткая привязка FPS к фиксированному игровому времени — это быстро реализуемый алгоритм, способствующий сохранению простоты кода игровой логики, но ставящий разработчику условие: lowest common denominator. Говоря проще: вам придется пожертвовать либо минимальными системными требованиями, либо сложностью игровой логики. Кроме того, ваша игра на мощном железе будет выглядеть не так привлекательно, как те, в которых используются более сложные алгоритмы.

Другой подход, весьма популярный среди новичков (и тех, кто не очень-то понимает, что делает) — сделать так, чтобы рендер происходил максимально быстро, и игровое время зависело бы от FPS. Все функции обновления состояния при этом используют некую «дельту» — разницу во времени между началом прошлой итерации и текущей. Это выглядит примерно так:

long currentFrameTime = time();

while( isGameRunning )
{
	long prevFrameTime = currentFrameTime;
	currentFrameTime = time();
	
	// сколько миллискунд прошло между прошлой и текущей итерацией:
	long dt = currentFrameTime - prevFrameTime;

	// поделим на 1000 т.к. с секундами мысленно работать проще,
	// чем с миллисекундами.
	float k = dt/1000;
	
	updateGameState( k );
	render();
}

Код игровой логики становится чуть сложнее: теперь нужно учитывать «дельту». Например, если мы хотим чтобы игрок перемещался на 10 пикселей в секунду, мы напишем так:

x = x + 10 * k;

И если наша игра выполняется со скоростью 100 кадров в секунду (т.е. dt=10), очевидно, что игрок должен двигаться на 1 пиксель каждые 10 кадров, что у нас и получается: 10*k = 10*(dt/1000) = 10*(10/1000) == 0.01 т.е. игрок двигается на 0.1 пикселя за кадр.

Как поначалу может показаться, это отличное решение. Теперь, например, наш шарик летит со скоростью 10 пикселей в секунду, независимо от того, на какую скорость способно железо. Но если подумать, внезапно оказывается, что этот код имеет большие проблемы как на слишком медленном так и на слишком быстром (sic!) железе.

Медленное железо может притормаживать на некоторых тяжелых участках игры, например в 3D-сценах на обширных открытых пространствах с большим количеством объектов, одновременно попадающих в кадр. «Проседание» FPS будет сказываться и на обработке пользовательского ввода, и как следствие на времени реакции игрока. Игрок будет чувствовать задержку от нажатия на клавиши до реагирования игры, не говоря уж про задержку AI и всего прочего. Например, препятствие, которое легко преодолеть при «рассчетном» FPS = 60, становится невозможно преодолеть при FPS = 15, т.к. скорость игры остается прежней, но вы просто не можете угадать момент и вовремя нажать клавишу, так как в это время процессор занят отрисовкой и не реагирует на нажатия. Более серьзной проблемой при этом цикле на медленном железе является обработка физики. Ваша сцена может попросту «взорваться», из-за недостатка точности рассчета.

Что с этим циклом не так на быстром железе? Для этого надо вспомнить, что не все десятичные дробные числа могут быть представлены в бинарном виде. Как следствие, если у нас объект имеет скорость, к примеру, 0.1, то после большого количества итераций (и чем быстрее у нас железо, тем раньше это произойдет) накопление ошибки округления (округление — до ближайшего числа, которое можно представить в двоичном виде) станет существенным настолько, что может сломать всю логику игры. Если вы тестируете игру на своем средненьком PC при FPS=60 и все замечательно, то через каких-то пару лет на более свежем и топовом железе, которое вытянет вашу игру на все 300, игра может повести себя непредсказуемо. Например, шарик в арканоиде начнет по непонятным причинам набирать бешеную скорость и пролетать сквозь блоки, снаряды будут пролетать насквозь игроков и т.п. Кроме того, если объекты в игре перемещаются на значительные расстояния, они внезапно могут «не долететь» или существенно перелететь рассчетную конечную точку. А еще хуже может стать, если значение с накопленной ошибкой испольузется для дальнейших вычислений. Если вы используете алгоритм данного вида — советую вам прекратить немедленно! Когда вы столкнетесь с такими эффектами — вы обнаружите, что надо переписать очень очень много кода, чтобы вычистить везде эту зависимость от времени обсчета.

Что же делать?

Попробуем усовершенствовать первый подход, делая обновление логики с фиксированным интервалом, но замедляя рендер графики в случае высокой нагрузки.

const int FPS = 25;
const int FrameDuration = 1000 / FPS;
const int MaxFrameSkip = 10;
long nextFrameTime = time();

// счетчик итераций игрового цикла, произведенных до первого рендера
int loops;

while( isGameRunning )
{
	loops = 0;
	while( time() > nextFrameTime && loops < MaxFrameSkip )
	{
		updateGameState();
		
		nextFrameTime += FrameDuration;
		
		loops++;
	}
	
	render();
}

В данном случае игра будет обновлять состояние строго 25 раз в секунду, а рендер будет выполняться так быстро, насколько это возможно. Правда, если он будет выполняться быстрее 25 раз в секунду — многие соседние кадры будут одинаковыми. А если железо «не тянет» 25 кадров в секунду, игровая логика будет продолжать обновляться без рендера. События в игре все равно могут замедлиться, но толькое если производительность не позволяет вытянуть даже (FPS/MaxFrameSkip) = 2.5 кадра в секунду, что в любом случае было бы неиграбельно, так что этим можно пренебречь. Недостаток у данного подхода есть один — и он очевиден, на мощном железе мы тратим впустую процессорное время на отрисовку совершенно идентичных кадров, в то время как при таком высоком достижимом FPS можно было бы сделать анимацию куда более плавной.

Существует ли подход, лишенный недостатков, при котором на медленном железе не было никаких других негативных эффектов, кроме падения FPS, а на быстром — анимация была максимально плавной, с отрисовкой максимально возможного количества отличных друг от друга кадров? Существует, и вон он:

const int FPS = 25;
const int FrameDuration = 1000 / FPS;
const int MaxFrameSkip = 5;
long nextFrameTime = time();
int loops;

// а вот это самое интересное!
float interpolation; 

while( isGameRunning )
{
	loops = 0;
	while( time() > nextFrameTime && loops < MaxFrameSkip )
	{
		updateGameState();
		
		nextFrameTime += FrameDuration;
		loops++;
	}
	
	interpolation = (float) (time() + FrameDuration - nextFrameTime) / (float) FrameDuration;
	
	render( interpolation );
}

Обновление состояния игры у нас в данном случае ничем не отличается от того что было раньше. А вот рендер усложняется. Нам нужно придумать функцию предсказания, принимающую в качестве аргумента значение интерполяции (которое можно понять как 0 — это состояние на момент времени текущего кадра, 0.5 — на момент времени посередине между текущим и следущим кадром, 1 — следующего, 2 — через кадр и т.п.). Для простоты представим, что состояние игры обновляется всего 10 раз в секунду (константа FPS), а железо способно показать нам 100 кадров в секунду. Также допустим, что игрок двигается со скоростью 100 пикселей в секунду. Таким образом, если перед первым обновлением состояния координата игрока равнялась 0, то после первого обновления она станет равняться 10, после второго 20, и так далее. Но у нас есть возможность отрисовать целых 10 кадров между этими обновлениями! Посчитаем: значение интерполяции будет равняться 0.1 в момент времени 1/10 интервала между обновлениями, и мы можем использовать его для предсказания координаты игрока в следующем обновлении. В нашем простейшем случае, используя линейную интерполяцию, мы для отрисовки игрока просто возьмем его последнюю известную координату, и прибавим к ней текущую скорость, умноженную на значение интерполяции и период между обновлениями (т.к. скорость у нас в секундах): 10 + (100 * 10/1000) * 0.1 = 11; таким образом, несмотря на то, что мы получили только 1 обновление игровой логики в момент t = 0.1сек и координата игрока равняется 10, применяя интерполяцию в момент времени t = 0.11сек мы предсказывыем координату 11 для текущего кадра, делая допущение, что скорость не поменяется в момент t=0.2, когда будет производится следующее обновление. Если она вдруг резко поменяется — например при столкновении объектов, мы можем в течение одного или нескольких (в случае особо радикального изменения) кадров получить неверное изображение — например объекты слегка пройдут один в другой, но это вряд ли можно будет успеть заметить.

Конечно, мы не всегда можем иметь в распоряжении такую величину, как текущая скорость игрока (да и анимация может быть сколь угодно сложной, а не просто движение точки по прямой), поэтому гораздо проще сделать универсальный механизм интерполяции, который будет работать независимо от типа анимации. Кроме того, линейная интерполяция дает не самые лучшие результаты. В следующей части я расскажу, что такое матрицы трансформации и как реализовать более красивую и универсальную интерполяцию.

Продолжение следует.

Автор: lucas_iv

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js