Тайны разработки клона Silent Hill

Георгий Мошкин
tmtlib@narod.ru

Файлы к статье:
silenthill.zip (382kb) - exe с ихсодниками заготовки движка на Dephi, в директории script есть Python-овский скрипт для экспорта из Blender (версия 1.0a).

В этой статье с достаточно громким названием я покажу как быстро решить много проблем, появляющихся на начальном этапе разработки игр типа Silent Hill. Стоит отметить, что хотя полученный "за пять минут" результат и нельзя назвать окончательным, но это весьма ощутимый вклад в развитие проекта: вы сможете поиграться с движком, поэкспериментировать, получить вдохновление от полученного результата и продолжить его совершенствование.

Для начала экспериментов нам понадобится тестовый уровень. Чтобы долго не останавливаться на этом вопросе, поступим следующим образом: возьмём модель сцены с двумя стульями из примера к статье "Экспорт сложныхсцен из Blender в свой движок на Delphi". А так как я обещал сцену с лестницей, то быстренько доработаем её в редакторе Blender. Делается это достаточно просто: через меню File > Open открываем файл kafe.blend. Убираем выделение со всех объектов двойным или тройным нажатием клавиши A. Помещаем 3D-курсор с помощью клика левой кнопки мыши поближе к полу загруженной сцены. В течении нескольких секунд зажимаем правую кнопку мыши и в появившемся меню выбираем Add > Mesh > Cube. После этого врубится режим редактирования, но из него нужно выйти нажав TAB на клавиатуре. Выделенный куб с помощью нажатия "просто S", "S затем Y" или "S затем X" и последующих движений мышки масштабируем куб по осям, чтобы он стал похож на первую ступеньку (сплющиваем куб). Далее переходим в режим редактирования куба, переключаемся на редактирование граней и с помощью вытягивания граней (см. статью "Создание простой модели") делаем лесенку. Для разнообразия после лесенки можете сделать горку. Для этого после выделения грани нажмите клавишу G и довытяните "мостик" в нужное место.

Осталось только записать тестовый уровень с помощью нашего экспортера из предыдущей статьи. Когда вы загрузите эту сцену в Delphi, то обнаружится, что всё перевёрнуто, а лестница находится не справа, а слева. Чтобы это исправить, поменяем соответствующие координаты и знаки в загрузчике. Новая модификация будет выглядеть следующим образом:

     for i:=0 to Poligoni[j-1].vertnum-1 do 
begin
readln(f,s);
s:=trim(s);
Poligoni[j-1].vert[i].x:=-StrToFloat(StringWordGet(s,' ',1));
Poligoni[j-1].vert[i].z:=StrToFloat(StringWordGet(s,' ',2));
Poligoni[j-1].vert[i].y:=StrToFloat(StringWordGet(s,' ',3));
end;

Изменения показаны красным цветом. Мы поменяли знак у X, а также поменяли Z и Y местами. Вот теперь модель грузится корректно. До сего момента я дописывал новый код по загрузке в файл nehegl.pas. Но это неудобно, так как в нём полно всякого лишнего кода по инициализации OpenGL. Чтобы в дельнейшем было удобнее редактировать исходники, я вынес весь код, связанный с загрузкой модели Blender в файл blender.pas.

Итак, у нас есть загруженный в Delphi уровень. Это хорошо: мы делаем уровни в Blender, а потом грузим их в Delphi. Но пока у нас не отрбражается персонаж игры. На данном этапе не стоит уделять этому много времени. Поэтому вместо персонажа мы выведем какую-нибудь закорючку с помощью команд OpenGL. С персонажем мы разобрались - это закорючка из линий, выведенная в определённых координатах X,Y,Z.

У нас есть главный персонаж (закорючка). Но как обеспечить передвижение данного персонажа по уровню? Ведь в нашем тестовом уровне есть и стулья, и лестница. Как сделать Collision Detection, да ещё и с возможностью взобраться по лестнице и пройтись вниз по горке? Я сейчас всё объясню, но начнём с простого.

Для начала сделаем просто передвижение персонажа. Пусть он будет проходить сквозь стулья, лестницы и т.д. То есть пока что не будем делать проверку столкновений. Как я уже писал во многих своих статьях, для персонажа следует хранить его положение и угол поворота. Создадим соответствующий тип:

 type TPerson=record
x,y:single; - координаты игрока
depth:single; - расстояние до земли (по вертикали)
phi:single; - угол поворота
end;

Вот, мы создали тип TPerson. Координаты персонажа хранятся в переменных x, y, depth, а угол поворота - в переменной phi. Причём обратите внимание на такую особенность: x,y - это координаты перемещения в плоскости пола, а координата depth - это что-то вроде высоты над уровнем земли.

Теперь попробуем сделаем depth=0, а координаты x,y и угол phi будем менять с клавиатуры. Рассмотрим самую простую реализацию, которая уже заложена в фреймворке по работе с OpenGL от NeHe. В этом фреймворке (заготовка для разработчиков) есть возможность проверять нажатие клавишь в массиве keyDown. Например, если вы нажмёте клавишу влево (VK_LEFT), то нам нужно увеличивать угол phi:

  if g_keys^.keyDown[VK_LEFT] then
with Person do
phi:=phi+15;

Да, вот и всё. Если клавижа влево будет в зажатом состоянии, то каждый кадр будет происходить приращение угла phi на 15 градусов. Как вы помните, "углы отсчитываются против часовой стрелки ". А так как поворот влево - это и есть против часовой стрелки, то значит приращение должно быть положительным. Что мы здесь и наблюдаем: phi:=phi+15.

Для клавиши "вправо" есть аналогичная проверка - вы можете в этом убедиться (смотрите процедуру Draw в файле NeHe.pas). Разумеется, что для VK_RIGHT приращение будет отрицательным, т.е. phi:=phi-15. Об оптимизации приращения под разную скорость компьютеров поговорим в другой статье, или смотрите в моих старых статьях.

После того, как мы реализовали изменение угла поворота персонажа, хотелось бы увидеть его передвижение в выбранном направлении. Ну а как вы знаете, это очень просто. Там совершенно простейшее выражение с SIN и COS-инусом. О глубоком смысле использования SIN и COS вы можете прочитать в статье "Создание летающих пулек".

Выше мы разобрались с обработкой нажатых клавишь влево (VK_LEFT) и вправо (VK_RIGHT). Теперь напишем код для клавишь вперёд/назад (VK_UP / VK_DOWN). Покажу на примере клавиши вверх (что означает вперёд):

  if g_keys^.keyDown[VK_UP] then
begin
Person.x:=Person.x+1*sin(Person.phi*3.14/180);
Person.y:=Person.y+1*cos(Person.phi*3.14/180);
end;

Дааааа... потрясающе, потрясающе! Всего несколько строк, и координаты нашего главного героя уже меняются как в настоящих играх! Но сдесь нужно сделать небольшую оговорку. Цифра 1, выделенная красным цветом - это ничто иное, как скорость передвижения. Я задал её постоянной. Сейчас скорость перемещения равна еденице. Тут, как и в случае с углом phi (+15), не учитывается скорость работы компьютера. Это нужно решать с помощью GetTickCount или мультимедиа-таймера. Первый вариант нежелателен, так как GetTickCount дрыганный. А вот мультимедиа-таймер - отличная вещь. Но об этом в следующей статье (хотя вы можете порыться по этому вопросу в поисковых системах, да что там говоить - у меня в исходниках HLMV под TMT Pascal насколько я помню используется этот самый мультимедиа-таймер).

Для перемещения назад (keyDown[VK_DOWN] всё будет абсолютно так же, но вместо +1 надо поставить -1. Сделаю небольшое замечание по поводу загадочного множителя 3.14/180. Дело в том, что функции SIN и COS в Delphi требуют, чтобы им на вход подавались значения углов в радианах. А у нас угол Phi измеряется в градусах. Так вот, этот множитель (3.14/180) - просто перевод из градус в радианы. Это из области пропорций и тому подобного. Смотрите: мы знаем, что число Пи примерно равно 3.14. Но в тоже время мы знаем, что числу Пи соответствует 180 градусов. Разделив число Пи на 180 градусов мы узнаём, сколько РАДИАН торчит в одном ГРАДУСЕ.

Ну что же, вырисовывается весьма хорошая картина: у нас есть главный герой, параметры которого хранятся в переменной Person типа TPerson. У нас есть его координаты в плоскости пола (x,y). Эти координаты управляются с клавиатуры (клавишами влево/вправо/вперёд/назад). Но, конечно, для отладки хотелось бы увидеть обещанную выше закорючку. Итак, нам нужно нарисовать на экране закорючку, которая будет обозначать главного героя. Это очень легко. Смотрите:

   glDisable(GL_TEXTURE_2D);
glBegin(GL_LINE_LOOP);
for i:=0 to 5 do
begin
glVertex3f(Person.x+3*cos(60*i*3.14/180),
0,
Person.y+3*sin(60*i*3.14/180));
end;
glEnd;

Мы просто взяли координаты героя игры (x,y) и нарисовали на расстоянии 3 от неё шесть точек (0, 1, 2, 3, 4, 5, 6). Эти шесть точек образовались следующим образом: сначала в цикле задавалась переменная i. Потом эта переменная домножалась на 60 и переводилась в радианы. Полученный угол подставлялся в функцию sin и cos, чтобы получить координаты конца вектора. Вектор имеет длину 3. Цикл идёт от 0 до 5. Обратите внимание, что 5*60 = 300 градусов. Ну а 6*60 было бы 360 градусов. Но так как я сделал GL_LINE_LOOP, то последнюю линию OpenGL дорисует сам (ведь 360 градусов эквивалентно нулю градусов). Получаем следующую картину:

Но это совсем не похоже на закорючку. С таким плоским объектом неудобно проводить эксперименты. Чтобы нам было удобнее, проведём из координат игрока линию, указывающую направление движения. Кстати, это будет очень похоже на исходный код для клавиши VK_UP (движение вперёд). Из текущих координат игрока (X,Y) мы проведём отрезок, совпадающий с вектором скорости. Чтобы закорючка была ещё более реалистичной, проведём также отрезок ввверх. Все описанные действия производит следующий код:

   glBegin(GL_LINES);
отрезок, показывающий направление:
glVertex3f(Person.x,
0,
Person.y);
glVertex3f(Person.x+3*sin(Person.phi*3.14/180),
0,
Person.y+3*cos(Person.phi*3.14/180));

отрезок, показывающий высоту (высота равна 0+10).
glVertex3f(Person.x,
0,
Person.y);
glVertex3f(Person.x,
0+10,
Person.y);
glEnd;

Отрезок, показывающий направление, начинается в координатах игрока (Person.X, Person.Y). Вторая точка этого отрезка смещена в направлении возможного перемещения. Отрезок, показывающий высоту - это просто две точки, у одной из которых координата высоты больше уровня пола на 10. Проделав описанные выше действия, мы получим следующую картину:

Закорючка получилась идеальная, строгая, но всё-таки очень хорошая. Для тестов такая закорючка просто необходима! Без неё все ощущения перемещения по уровню были бы совсем не те. А сейчас мы можем спокойно смотреть: куда мы идём, и всё ли правильно у нас в движке работает. Про то, что движение происходит в плоскости - не беспокойтесь. Ниже вы увидите, как мы реализуем не тольо Collision Detection (определение столкновений), но и перемещение в 3D пространстве, а также ответную реакцию на столкновения: скольжение по стенам, обтекание стульев и т.п.. Кстати, всякие вот эти обтекания стульев и скольжение по стенам называют Collision Responce (коллижн респонсом).

Теперь я остановлюсь на объяснении весьма важного момента, который потребуется нам в дальнейшем. Речь пойдёт о Z-Buffer. Z-Buffer - это буфер глубины. В OpenGL-е этот буфер обычно называют Depth Buffer-ом. Как вы знаете, с помощью Z-Buffer-а обеспечивается правильное отображение картинки на экране: мы видим полигоны, которые находятся ближе к нам. Так что же такое Z-Buffer, или как его ещё называют - Depth Buffer. Мы уже знаем, что его русское название - это буфер глубины, но мне больше нравится называть его Z-Buffer-ом. Итак, поговорим о Z-Buffer-е.

Что Depth Buffer, что Z-Buffer, что буфер глубины - всё это одно и то же. Z-Buffer - это невидимая картинка. Каждый пиксель этой картинки содержит число, равное расстоянию от этого пикселя до глаза наблюдателя (камеры). Например, если вы нарисуете стену близко к наблюдателю, а затем маленький треугольничек за стеной, то на экране он не появится. А почему? Всё дело в том, что при выводе точек на экран идёт постоянное сравнение с Z-Buffer-ом. При выводе маленького треугольничка сработает проверка: ЕГО ПИКСЕЛИ НАХОДЯТСЯ ОТ НАС ДАЛЬШЕ, ЧЕМ ПИКСЕЛИ СТЕНЫ. Да, вот поэтому и не важен порядок вывода на экран: если вы сначаланарисуете маленький треугольничек, а потом близко-близко стену, его закрывающую, то стена затрёт его, так как стене "разрешат" рисовать свои пиксели - они же ближе. То есть каждый раз смотрится: если пиксель находится ближе, то его рисуют.

Обычно в OpenGL используют следующие настройки z-buffer-а:

  glClearDepth(1.0); - при стирании буфера использовать цифру 1
glDepthFunc(GL_LEQUAL); - рисовать то, что меньше или равно

Итак, что значит при стирании буфера использовать цифру 1? А это означает, что при вызове функции glClear будет произведено заполнение буфера глубины цифрой 1. Например, вот здесь:

 glClear(GL_COLOR_BUFFER_BIT or GL_DEPTH_BUFFER_BIT);

Эта команда служит для очищения буферов. В скобках указываются константы, соответствующие буферам, которые нужно очистить. В данном случае будет стёрт буфер с картинкой (COLOR BUFFER) и буфер глубины (DEPTH BUFFER). Но вы должны помнить: буфер глубины невидимый. Он используется для сортировки пикселей - чтобы при отображении на экран объекты, находящиеся за стеной, не вылезли на первый план.

Но не смотря на то, что буфер глубины невидим, мы всё-таки можем вытащить его содержимое с помощью команд OpenGL и посмотреть. На деле буфер глубины представляет собой чёрно-белую картинку. Посмотрим как будет выглядеть какой-нибудь треугольник, нарисованый вплотную к наблюдателю (к экрану):

Поймите, о чём я говорю. Это очень важно для наших дальнейших разработок. Смотрите: яркость, расстояние. Чувствуете связь? Подвинули поближе-подальше - яркость изменилась. Яркость связана с расстоянием до экрана. Поэтому и не удивительно, что наш треугольник имеет яркие и тёмные места. Дело в том, что я показал треугольник, который немного наклонён к плоскости экрана. То есть не параллелен экрану. Никто нам не мешает перевести цвет в число и обратно. По сути здесь что цвет, что число, что яркость - это всё одно и то же. Нет разницы. А это значит, что на основе этих цветов (чисел в разных точках z-buffer) мы можем определить расстояние до камеры (до наблюдателя).

А теперь я покажу вам как мы сделаем определение столкновений со стульями и перемещение в пространстве (в том числе и по лестницам). Для начала мы перейдём в режим параллельного проецирования и взглянем на наш тестовый уровень с высоты птичьего полёта. Для чего смотреть на уровень с высоты птичьего полёта? А чтобы сделать фотографию. Но не простую! Я хочу сделать фотографию Z-Buffer-а. То есть узнать как выглядит наш тестовый уровень внутри Z-Buffer-а, если на него посмотреть сверху. Подробности реализации того, как это сделано, есть в самих исходниках, а здесь я остановлюсь на главном:

  glOrtho(-SSS, SSS, -SSS, SSS , 1, 1000);

Это функция перехода в режим параллельного проецирования. Здесь SSS - это константа, которую я подставил в качестве ограничительных переменных по вертикали и горизонтали. В своём примере я сделал SSS=50. Это означает, что если посмотреть на пол, то мы увидим кусок размерами 100 на 100. Откуда взялось 100? Смотрите: в скобках у glOrtho сначала идёт координата левой границы, потом правой, потом нижней, потом верхней. И в конце ещё два числа - 1 и 1000. Так вот, координата левой границы у нас -50, координата правой - плюс 50. Между -50 и плюс 50 как раз и получится 100. Короче говоря, мы можем сейчас увидеть кусок размером 100 на 100.

Теперь про два числа перед закрывающей скобкой: 1 - это расстояние до ближайшей точки, которую мы сможем увидеть. 1000 - это расстояние до самой далёкой точки, которую мы можем увидеть. Выше я писал про кусок размерами 100 на 100. Что это за размеры? Это внутренние размеры OpenGL. Например, если я нарисую точку с координатой (51,0,51), то мы её уже не увидим. Вышли за рамки. А вто что-нибудь вроде (25,1,-50) будет видно отлично.

Это всё хорошо. Но ведь мы говорили о внутренних размерах OpenGL. А как нам сделать реальную фотографию содержимого Z-Buffer,а? Например, я хочу фотографию размером 256x256. Очень просто. Для этого вызывается процедура glViewPort:

  glViewport(0, 0, SX,SY); - делаем вьюпорт нужного размера


gluLookAt(0,HIGH,0, - смотрим "сверху"
0,0,0,
0,0,-1);

RenderModel; - рендерим уровень

Как я уже говорил выше, размеры фотографии Z-Buffer-а будут составлять 256 на 256, т.е. SX = SY = 256. Итак, мы сначала перешли в режим параллельного проецирования с помощью glOrtho. Затем сделали нужный вьюпорт (glViewport) - рендеринг будет происходить в квадрат размером (256x256) в левом нижнем углу экрана (0,0). Установили камеру на высоту HIGH относительно нуля. И после всего этого вызвали стандартную процедуру RenderModel, которая отрисовывает загруженный тестовый уровень. В результате всего этого мы получим "вид сверху" на наш уровень в левом нижнем углу экрана:

На этой картинке можно различить два коричневых стула и белую лестницу. Это обычная цветная картинка. Но как вы помните, существует ещё одна "картинка" - это невидимый Z-Buffer. Если, например, взять из этого Z-Buffer-а какую-нибудь точку на поверхности стула, а потом на поверхности пола - в них будут разные яркости. То есть у пикселей пола яркость одинаковая, а вот у стульев она уже другая. У ступенек яркость вообще меняется от ступеньки к ступеньке (в пределах ступеньки яркость одна и та же). А вот у горки яркость меняется плавно от светлого к тёмному. Ещё раз повторюсь, речь идёт о НЕВИДИМОЙ КАРТИНКЕ, которую называют Depth Buffer-ом, а иногда и Z-buffer-ом. То есть о буфере глубины. А выше показана обычная картинка после рендеринга. Буфер глубины похож на показанную выше картинку, но в отличие от неё, в нём цвет пикселей зависит не от накладываемых текстур и источников света, а от растояния до вас! До вашего экрана!

Сейчас всё объяснение будет крутиться вокруг Z-Buffer-а. Сначала выделим переменную (массив), в которую можно сбросить содержимое Z-Buffer-а:

  var  zbuffer:packed array[0..sx-1,0..sy-1] of glFloat;

Вот и всё. Обычный двухмерный массив. Слово packed даёт компилятору Delphi понять, что нам нужен "стандартный" массив без всяких оптимизированных выравниваний в памяти. В каждом элементе этого массива будет храниться число от нуля до единицы. Например, там может встретиться какое-нибудь 0.322372 или, к примеру 0,988412. Здесь стоит ещё раз упомянуть два последних параметра в скобках у glOrtho:

  glOrtho(-SSS, SSS, -SSS, SSS , 1, 1000);

Это были числа 1 и 1000. Читайте внимательно: чем ближе будет к нам объект, тем темнее будут его пиксели в Z-Buffer-е. Чем дальше будет от нас объект, тем светлее будут его пиксели в Z-Buffer-е. Яркость колеблется в пределах от 0 до 1. Объект, до которого расстояние равно 1000, будет иметь максимальную яркость пикселей (zbuffer[...,...]=1). Объект, который к нам вплотную - будет иметь минимальную яркость пикселя (zbuffer[...,...]=0). Понятное дело, что объект на расстоянии 500 будет иметь яркость 0.5. Но всё это зависит от двух последних параметров в glOrtho. Если там поставить вместо 1000 какое-нибудь 1234, то, в соответствии с пропорцией, распределение изменится. Единица тогда будет по прежнему соответствовать нулевой яркости, расстояние 1234 - максимальной яркости. И расстояние 500 вовсе не 0,5. А что-то вроде 1234/500=0,405186.

Воспользуемся функцией OpenGL, которая позволяет скопировать содержимое невидимого буфера глубины (z-buffer) в нашу переменную zbuffer:

  glReadPixels(0, 0, sx, sy, GL_DEPTH_COMPONENT, GL_FLOAT, @Zbuffer);

Всё! Копирование выполнино. Мы скопировали невидимый буфер в нашу переменную-2d-массив Zbuffer. Теперь мы можем даже посмотреть на него. Так посмотрим, как же всё-таки выглядит содержимое буфера глубины:

А где стулья? - спросите вы. Дело всё в том, что визуально здесь мало что видно. Понятно, что пустота вокруг - это как бы бесконечность, имеет максимальную яркость (всё закрашено единицами). То, что поближе - пол, стулья, лестница - имеют малнеькую яркость. Если же посмотреть сами числа внутри массива, то станет понятно, что стулья всё-таки немного темнее пола. Потому что они ближе к камере (наблюдателю). Мы ведь смотрим сверху. А всё, что ближе - оно, условно говоря, "темнее".

Теперь о том, как использовать эту картинку. С помощью двух последних параметров glOrtho мы установили границы "глубины". Это были числа 1 и 1000. Это означает, что мы можем смотреть "вглубь" от 1 до 1000. То есть в нашем распоряжении длина 1000 - 1 = 999. Посмотрите на приведённую выше картинку с содержимым z-buffer-а. Самое яркое место на ней - это "бесконечность", в которой яркость = 1. Достаточно домножить эту яркость на "глубину" нашего видения - и мы получим абсолютно правильный результат: 1*999=999. А так как смотрим мы, начиная с 1, то 1+1*999=1000. Но это не так уж и важно: сейчас мы перейдём к практике, и сразу всё станет понятно!

Выше мы реализовали передвижение игрока в плоскости пола: он у нас движется в плоскости, игнорируя любые препятствия, и проходит их насквозь. Сделаем так, чтобы наша закорючка взбиралась на любые препятствия. Для этого достаточно "приподнять" нашу закорючку в соответствии с данными z-buffera (буфера глубины). У нас есть двухмерный массив ZBuffer. А также есть координаты закорючки в плоскости пола. Нужно каким-то образом по координатам закорючки определить соответствующие им индексы элемента массива ZBuffer. Делается это следующим образом:

Как вы помните, перед рендерингом картинки для использования с Z-Buffer, мы установили камеру на высоту HIGH (просто константа):

  gluLookAt(0,HIGH,0, - смотрим "сверху"
0,0,0,
0,0,-1);

Учитывая описанные выше замечания по получению индексов в массиве ZBuffer по "плоским" координатам игрока, запишем формулу по определению расстояния при виде сверху до точки, в которой находится главный герой:

with Person do
depth:=zbuffer[sy-1-round((y/SSS+1)*(SY-1)/2),
round((x/SSS+1)*(SX-1)/2)];

Где SSS=50, SX=SY=256. Описание формулы смотрите чуть выше. Итак, мы знаем расстояние от камеры (при виде сверху) до той точки, где сейчас находится персонаж. Также мы помним, что смотрим мы из точки высотой HIGH. На основе этих исходных данных легко определить нужное смещение главного героя по вертикали на основе переменной Person.depth:

   glBegin(GL_LINE_LOOP);
for i:=0 to 5 do
begin
glVertex3f(Person.x+3*cos(60*i*3.14/180),
HIGH-Person.depth*(1000-1),
Person.y+3*sin(60*i*3.14/180));
end;
glEnd;

Вот видите, как всё просто: шестигранник в основании закорючки теперь взбирается на стулья и лестницы. Осталось наложить ограничения на его перемещение в пространстве и реализовать проверку столкновений. Для проверки столкновений введём понятие диаметра описанной окружности в плоскости пола и соответствующую константу. Также введём двухмерный массив, в который можно "уместить" эту окружность:

   const cdiametr=16;
type TCollisionCircle=array[0..cdiametr{-1+1},
0..cdiametr{-1+1}] of TCircleElement;

Да, как видите, массив имеет нечётную размерность. Я сделал это с той целью, чтобы в массиве TCollisionCircle реально существовал "центральный" элемент:

Как вы понимаете, слева и справа от перекрестия стоит по восемь элементов. Само перекрестие как бы дополняет массив до 17 элементов (массив-то у нас от 0..16, а не 1..16. Поэтому и вышло 17x17). Центр массива будет соответствовать центру окружности, описанной вокруг главного героя.

Теперь посмотрим, что я вложил в элементы этого массива:

 type TCircleElement=record
Radius:single;
AntiVector:TVector2D;
Valid:boolean;
end;

Итак, Radius - это длина радиус-вектора, проведённого из центра массива в элемент (i,j). AntiVector - это вектор, который нужно прибавить к текущей позиции игрока, чтобы "вытолкнуть" окружность TCollisionCircle из препятствия. Valid - это признак того, что элемент 2D массива является частью круга.

Перед тем, как продолжить объяснения, я сделаю небольшой обзор проделанной работы для лучшего понимаия. Сначала у нас был просто загружен уровень, и сквозь него происходило 2D-передвижение главного героя. То есть было только две координаты (третяя всё время была ноль, движение происходило вдоль пола сквозь стулья). Затем по данным из Z-Buffer-а мы определили третюю координату - на какой высоте должен находится персонаж. Сразу после этого при "наезде" на стул вспомогательная закорючка благополучно на него взбиралась.

Теперь я хочу анализировать пиксели Z-Buffer-а (при виде сверху) в определённом радиусе от главного героя. Если внутри радиуса cdiametr / 2 появится слишком большой перепад высоты, то будет задействован алгоритм Collision Responce для "выдавливания" сферы из препятствия. Если же перепад высот будет небольшой - мы позволим двигаться координате героя дальше.

Рассмотрим подробнее, что означает конструкция CollisionCircle[i,j].Valid:=true. Данное выражение означает, что элемент с индексами i,j является частью круга. Ниже дан поясняющий рисунок:

У всех зелёных элементов Valid:=true, так как они находятся в пределах окружности. Рассмотрим теперь, что означает переменная CollisionCircle[i,j].Radius:

Это просто длина радиус-вектора проведённого из центра круга в некоторую его точку. И, наконец, что же такое AntiVector. Представим себе ситуацию, когда наш круг наехал на стул (стул показан чёрным цветом):

Каждый элемент массива с кругом содержит антивектор (AntiVector). Данный вектор просчитывается заранее. А уже потом, при проверке столкновений, срабатывает следующий принцип: сначала мы ищем элемент массива с "инородным вторжением", который находится ближе всего к центру круга. Этот элемент содержит такой антивектор (AntiVector), который даёт 100% гарантию "выдавливания" окружности в такую точку, что инородный элемент оказывается за пределами круга. Антивектор показан маленькой зелёной стрелочкой (это антивектор элемента, ближайшего к центру окружности).

Рассмотрим, как определить переменные Valid, Radius и AntiVector для всех элементов массива CollisionCircle на практике:

procedure MakeCollisionCircle;
var i,j:integer;
tmpx,tmpy,radius:single;
begin


for i:=0 to cdiametr do
for j:=0 to cdiametr do
begin

tmpx:=i-cdiametr/2;
tmpy:=j-cdiametr/2;
radius:=sqrt(tmpx*tmpx+tmpy*tmpy); - узнали длину радиус-вектора

ColCircle[i,j].Valid:=false;

if radius = 0 then continue;
if radius>cdiametr/2 then continue;

ColCircle[i,j].Radius:=radius;

ColCircle[i,j].Valid:=true;

создаём единичный вектор (нормализуем)
tmpx:=tmpx/radius;
tmpy:=tmpy/radius;

ColCircle[i,j].AntiVector.x:=(tmpx*(cdiametr/2-radius))
*(1/(SX-1)) * (SSS*2);
ColCircle[i,j].AntiVector.y:=(tmpy*(cdiametr/2-radius))
*(1/(SY-1)) * (SSS*2);

end;

end;

Здесь в комментариях нуждается только расчёт координат антивектора. Здесь мы переводим координаты из "массивных" в OpenGL-евские (обратно тому, что делалось выше для массива Z-Buffer). Последовательность действий следующая:

Для реализации проверки столкновений осталось реализовать следующий алгоритм: маленький массив CollisionCircle[i,j] "гуляет" по большому массиву Zbuffer[k,l]. Малнеький массив содержит кучу рассчитанных заранее "антивекторов". Стоит нам "наехать" на какой-нибудь стул, мы сразу же прибавим к координатам игрока соответствующий "антивектор". Сначала узнаем координаты игрока внутри массива ZBuffer:

   with Person do
begin
PlayerDepthX:=sy-1-round((y/SSS+1)*(SY-1)/2);
PlayerDepthY:=round((x/SSS+1)*(SX-1)/2);
CenterDepth:=zbuffer[PlayerDepthX, PlayerDepthY];
end;

Заодно мы сохранили значение буфера глубины CenterDepth в центре игрока при виде сверху. Теперь начинается цикл, в котором мы как бы накладываем массив CollisionCircle[i,j] поверх массива Zbuffer[k,l] и производим анализ:

  for i:=0 to cdiametr do
for j:=0 to cdiametr do
begin
...
end;

Во-первых, мы сместим координаты таким образом, чтобы они шли не от 0 до cdiametr, а от -cdiametr/2 до +cdiametr/2:

     tmpx:=i-cdiametr div 2;
tmpy:=j-cdiametr div 2;

Сейчас мы перебираем элементы массива CollisionCircle[i,j], и "накладываем" их поверх массива ZBuffer[k,l]:

tmpx:=PlayerDepthX+tmpx;
tmpy:=PlayerDepthY+tmpy;

if tmpx<0 then continue;
if tmpy<0 then continue;
if tmpx>sx-1 then continue;
if tmpy>sy-1 then continue;

tmpDepth:=zbuffer[tmpx,tmpy];


if abs(tmpDepth-CenterDepth)>MaxDepthDiff then
if (ColCircle[i,j].Radius<BestIndexRadius) and ColCircle[i,j].Valid then
 begin
  BestIndexRadius:=ColCircle[i,j].Radius;
  BestIndexI:=cdiametr-j;
  BestIndexJ:=i;
 end;

Где MaxDepthDiff - это максимальный допустимый для дальнейшего перемещения перепад высот (рассчитывается как MaxDepthDiff=5/(1000-1), что означает перевод 5 OpenGL-евских пространственных единиц в диапазон 0..1 буфера глубины). Перед самым началом цикла я специально сделал BestIndexRadius=1000000, чтобы было какое-то начальное значение для сравнения. Ну и, наконец, применение антивектора в случае нахожления инородного элемента:

if BestIndexRadius<1000000 then
 begin
  Person.x:=Person.x+ColCircle[BestIndexI,BestIndexJ].AntiVector.x;
  Person.y:=Person.y+ColCircle[BestIndexI,BestIndexJ].AntiVector.y;
 end;

Если же инородных элементов нет (мы не наезжали на стулья и т.п.), то BestIndexRadius останется очень большим, и AntiVector-ы не будут корректировать положение игрока.

Итак, что мы имеем: алгоритм проверки столкновений, работающий на основе выталкивания по кратчайшему пути. Зная положение игрока, мы двигаем массив с "антивекторами" поверх массива с "глубинами". Далее идёт проверка - если глубина в центре кружка существенно отличается от глубины какого-нибудь элемента внутри кружка, то к координатам игрока прибавляется антивектор из этого элемента.

Так как в данной статье рассматривается разработка клона игры Silent Hill, то логичным завершением будет добавление летающей камеры, следящей за игроком. Я покажу вам простую и оригинальную реализацию, выполненную без особых наворотов:

procedure SetThirdPersonCamera;
var need_x,need_y,need_z:single;
begin

need_x:=Person.x-25*sin(Person.phi*3.14/180);
need_z:=Person.y-25*cos(Person.phi*3.14/180);

need_y:=HIGH-Person.depth*(1000-1)+10;


with Camera do
begin
eye_x:=eye_x+(need_x-eye_x)/10;
eye_y:=eye_y+(need_y-eye_y)/10;
eye_z:=eye_z+(need_z-eye_z)/10;
end;


gluLookAt(Camera.eye_x,Camera.eye_y,Camera.eye_z,
Person.x, need_y-3 ,Person.y,
0,1,0);

end;

Эта простая процедура реализует симпатичное перемещение камеры. В переменных need_x, need_y и need_z записаны желаемое положение камеры. Здесь всё просто: по игрику мы поднимаем камеру на +10, а по иксу и по зеду делаем отступ -25 в сторону, противоположную вектору скорости. Вызывая эту процедуру каждый кадр, мы постоянно будем приближать действительные координаты камеры (eye_x,eye_y,eye_z) к желаемым (need_x,need_y,need_z). Установка камеры по описанным параметрам осуществляется функцией gluLookAt - в неё мы подаём координаты камеры (координаты глаза наблюдателя), координаты точки, в которую смотрим (смотрим на координаты игрока) и вектор, соответствующий "верху" наблюдателя (игрик=1, что значит обычное направление вверх без перекосов).

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

Тут мы взобрались на лестницу и смотрим на стулья - весьмя оригинально и даже чем-то напоминает Silent Hill. Попробуйте походить по нашему тестовому уровню - около стульев, по лестнице, а также по горке (возможно вам это покажется удивительным, но работают и наклонные поверхности)

В заключении скажу несколько важных слов. Не все секреты ещё раскрыты. У кого-то может возникнуть сомнения в том, что это 3D. Уверяю вас, это полное 3D, и в будущих статьях я покажу вам реализацию многоэтажных многолестничных многокомнатных сооружений на основе данного алгоритма. Также могут возникнуть возгласы типа "да у вас же glReadPixels используется - это такие тормоза!". Здесь тоже нет никаких проблем. glReadPixels вызывается один раз на этапе загрузки уровней, поэтому на FPS это никак не отражается. Также хочу обратить ваше внимание, что возможна реализация и с частыми вызовами glReadPixels - мои тесты показывают, что с этим нет абсолютно никаких проблем. Поэтому возможно создание очень интересных алгоритмов, базирующихся на описанной мною технологии. Про скорость определения столкновений хочу сказать следующей: она просто огромна. Это супер-производительный метод. Смотрите сами: грубо говоря делается всего лишь около 256 проверок типа "больше-меньше". Все остальные данные рассчитаны заранее (антивектора, радиус-векторы, z-buffer). Это ничтожная цифра. Далее - этот метод надёжен. Супер-высокая надёжность с возможностью реализации проверки z-buffer на глючность (тестовое заполнение при загрузке).

На этом статью заканчиваю и желаю вам огромнейших успехов в создании собственных игр!