0,6
рейтинг
4 декабря 2015 в 15:56

Разработка → Динамические свет и тени в моей 2d игре перевод

Я работаю над игрой в жанре стесл-экшн, где в геймплее большую роль будут играть тени. Поэтому я сделал динамическое освещение/затенение, используя WebGL шейдеры.


Часть первая: Динамическое освещение


На его создание меня вдохновил пост на реддите, где aionskull использовал карты нормалей в Unity для динамического освещения своих спрайтов. А пользователь с ником gpillow запостил в комментах что он сделал что-то похожее в Love2D. Вот тут 8-мб гифка с результатами. За неё спасибо jusksmit’у.

Итак, что такое динамическое освещение? Это техника в 3D графике, где источник света освещает объекты на сцене. Динамическое потому, что обновляется в реальном времени при движении источника. Довольно стандартная штука в 3D мире и легко применимая к 2D, если, конечно, вы можете использовать преимущества шейдеров.

Сделать динамическое освещение можно зная, что угол падения света на плоскость определяет её освещенность, а определить освещенность можно узнав вектор нормали, который показывает куда «смотрит» плоскость.



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

Ок, всё очень здорово, но как получить вектора нормали в 2d игре? Здесь, вообще-то, нет никаких объемных объектов… Однако, здесь нам могут помочь дополнительные текстуры (те самые карты нормалей), в которых будет записана необходимая информация. Я создал 2 таких карты для двух домов в видео повыше и использовал их чтобы посчитать освещение, вот пример:

image

В начале вы видите обычный спрайт домика без затенения. На второй части картинки расположена его карта нормалей, кодирующая вектора нормалей в цвет текстуры. У вектора есть (x,y,z) координаты, а у пикселя текстуры есть r,g и b компоненты, так что закодировать нормаль реально просто: Возьмем фасад дома, который направлен на юг. Его нормаль будет вектором с координатами [x:0, y:0.5, z:0]. (По хорошему, нормаль должна быть равна (0, 1, 0), но так как вектор мы определяем от -1 до +1, а кодировать надо в промежуток от 0 до 1, то, видимо, автор решил не запариваться и сразу считать нормали от -0.5 до +0.5. прим. перев.)

RGB значения не могут быть отрицательными, так что мы подвинем все значения на 0.5: [x:0.5, y:1, z:0.5]. Ну и ещё RGB обычно представляется в числе от 0 до 255, так что мы домножим на 255 и получим [x:128, y:255, z:128], или, другими словами, вектор «на юг» будет вот этим светло-зеленым на карте нормалей.

Теперь, когда у нас есть нормали, мы можем позволить графической карте сделать её магию.
Я использую ImpactJS, у него неплохая совместимость с WebGL2D. (Он платный, я рекомендую pixi.js или любая другая графическая библиотека с webgl рендерером. Если знаете ещё аналоги — пишите в комменты! прим. перев.) Используя WebGL2D мы можем легко добавить пиксельный шейдер для освещения:

#ifdef GL_ES
  precision highp float;
#endif

varying vec2 vTextureCoord;
uniform sampler2D uSampler;
uniform vec3 lightDirection;
uniform vec4 lightColor;

void main(void) {
  // Берем вектор нормали из текстуры
  vec4 rawNormal = texture2D(uSampler, vTextureCoord);

  // Если альфа-канал равен нулю, то ничего не делаем: 
  if(rawNormal.a == 0.0) {
    gl_FragColor = vec4(0, 0, 0, 0);
  } else {

    // Транслируем из RGB в вектор, а именно из 0..1 в -0.5..+0.5
    rawNormal -= 0.5;

    // Вычисляем уровень освещенности
    float lightWeight = 
      dot(normalize(rawNormal.xyz), normalize(lightDirection));

    lightWeight = max(lightWeight, 0.0);

    // И записываем в пиксель
    gl_FragColor = lightColor * lightWeight;
  }
}


Пара заметок: У нас получается попиксельное освещение, которое немного отличается от вершинного освещения (обычного в 3d). Выбора особого нет, так как вершины в 2d бессмысленны (их всего 4 штуки для отображения плоскости на сцене). Но, вообще-то, это не проблема, попиксельное освещение гораздо более точное. Также следует отметить, что шейдер рендерит только освещение, без основного спрайта. Придется признать, я немного жульничаю, ведь на самом деле я не освещаю свой спрайт, а, скорее, затеняю его и в lightColor я передаю темно-серый цвет. Настоящее освещение пикселей, а именно повышение яркости, выглядит хуже, пиксели кажутся «вытертыми». У этой проблемы есть решения, но сейчас это непринципиально.

image

Часть вторая: рисование теней.


Отбрасывание теней в 3D — хорошо изученная проблема с известными решениями, типа рейтрейсинга или shadow-mapping’а. Однако, я затруднился найти какое-нибудь приемлимое готовое решение для 2d, пришлось делать самому, думаю получилось нормально, хотя и у него есть пара недостатков.

Вкратце, будем рисовать линию от пикселя на сцене к солнцу и проверять, есть ли какое-нибудь препятствие. Если есть — то пиксель в тени, если нет — на солнце, так что, впринципе, ничего сложного.

Шейдер принимает xyAngle и zAngle, которые отвечают за то, где находится солнце. Так как оно очень далеко, то лучи света будут параллельны, и, соответственно, эти два угла будут одинаковы для всех пикселей. Также, шейдер будет принимать карту высот мира. Она будет показывать высоту всех объектов, зданий, деревьев и т.д. Если пиксель принадлежит зданию, то значение пикселя будет примерно 10, и означать, что высота здания в данной точке — 10 пикселей.

Итак, шейдер начнет в пикселе, который надо осветить и, используя вектор xyAngle, будет продвигаться по направлению к солнцу мелкими шажками. На каждом из них мы будем проверять, если в данном пикселе карты высот что-нибудь.
image
Как только мы найдем препятствие, мы определим его высоту, и насколько высоким оно должно быть в данной точке чтобы преградить солнце (используя zAngle).
image
Если значение в карте высот будет больше, то всё, пиксель в тени. Если нет — мы продолжим искать. Но рано или поздно мы сдадимся и объявим, что пиксель освещен солнцем. В примере я захардкодил 100 шагов, пока что работает отлично.

Вот код шейдера в упрощенной/псевдо форме:

void main(void) {
  float alpha = 0.0;

  if(isInShadow()) {
    alpha = 0.5;
  }
  gl_FragColor = vec4(0, 0, 0, alpha);
}

bool isInShadow() {
  float height = getHeight(currentPixel);
  float distance = 0;

  for(int i = 0; i < 100; ++i) {
    distance += moveALittle();

    vec2 otherPixel = getPixelAt(distance);
    float otherHeight = getHeight(otherPixel);

    if(otherHeight > height) {
      float traceHeight = getTraceHeightAt(distance);
      if(traceHeight <= otherHeight) {
        return true;
      }
    }
  }
  return false;
}


А вот и весь код:

#ifdef GL_ES
  precision highp float;
#endif

vec2 extrude(vec2 other, float angle, float length) {
  float x = length * cos(angle);
  float y = length * sin(angle);

  return vec2(other.x + x, other.y + y);
}

float getHeightAt(vec2 texCoord, float xyAngle, float distance,
    sampler2D heightMap) {

  vec2 newTexCoord = extrude(texCoord, xyAngle, distance);
  return texture2D(heightMap, newTexCoord).r;
}

float getTraceHeight(float height, float zAngle, float distance) {
  return distance * tan(zAngle) + height;
}

bool isInShadow(float xyAngle, float zAngle, sampler2D heightMap,
    vec2 texCoord, float step) {

  float distance;
  float height;
  float otherHeight;
  float traceHeight;

  height = texture2D(heightMap, texCoord).r;

  for(int i = 0; i < 100; ++i) {
    distance = step * float(i);
    otherHeight = getHeightAt(texCoord, xyAngle, distance, heightMap);

    if(otherHeight > height) {
      traceHeight = getTraceHeight(height, zAngle, distance);
      if(traceHeight <= otherHeight) {
        return true;
      }
    }
  }

  return false;
}

varying vec2 vTextureCoord;
uniform sampler2D uHeightMap;
uniform float uXYAngle;
uniform float uZAngle;
uniform int uMaxShadowSteps;
uniform float uTexStep;

void main(void) {
  float alpha = 0.0;

  if(isInShadow(uXYAngle, uZAngle, uHeightMap, uMaxShadowSteps,
     vTextureCoord, uTexStep)) {

    alpha = 0.5;
  }

  gl_FragColor = vec4(0, 0, 0, alpha);
}


В uTexStep записана длина шага для проверки пикселей. Обычно достаточно 1/heightMap.width или 1/heightMap.height, ибо в OpenGL координаты текстур имеют значения от 0 до 1, так что 1/разрешение как раз даст нам размер одного пикселя.

Заключение


По правде говоря, есть несколько мелких деталей, которые я опустил в коде выше, однако основная идея должна быть понятна. (Например, идея о том, что карта высот != карте нормалей дошла до меня только сейчас. Прим. перев.). В данном методе есть большой недостаток, связанный с тем, что каждый пиксель на сцене может иметь лишь одну высоту. Поэтому, например, возникают трудности с деревьями. Движок не сможет корректно отобразить тень от них в виде тонкого ствола и пышной кроны — будут либо толстые цилиндрические тени, либо тонкие палки от стволов, ведь пустота между листьями и землей не записана в карте высот.

image
Перевод: Matt Greer
@Fen1kz
карма
2,7
рейтинг 0,6
dev
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (27)

  • +1
    понимаю, что перевод. но тени, имхо, неестественные. тут уж либо 2d и без теней. или псевдо-3d, тогда сортируем объекты по глубине при отрисовке. ну и тени на объектах тоже придется сделать.
    • 0
      Cтатья не о том, как подобрать тип теней для конкретной игры, а о том, как реализовать динамические тени. Интереснее тот момент, что автор просто опускает откуда он берёт карту высот и как её формирует.
      • 0
        ну тогда тема не раскрыта. потому что то, что сделано — использовать нельзя. если автор на этом и остановился — то очень зря, потерял много благодарных читателей
        • +1
          Тема «Динамические свет и тени в моей 2d игре» раскрыта на 90%, не хватает только описания откуда взялась карта высот.
          • +1
            Думаю рисует сам для каждого спрайта.
            • 0
              Согласен. Учитывая то, что он даже карты нормалей рисует руками — карту высот ему нарисовать совсем не трудно.
        • 0
          Почему нельзя? Автору нужно было реализовать цикл дня/ночи с тенями и освещением. Он реализовал, какие проблемы?
  • –1
    45 секунд, потраченные на просмотр начального видео уже не вернуть…
    • +2
      Можно поставить будильник на 45 секунд раньше
  • +1
    А готовые решения применять не пробовали, например spriteilluminator?
    • 0
      Эта штука освещает сам спрайт по карте нормалей, она не умеет отбрасывать тени.
  • 0
    Я не понимаю зачем делать карты высот для теней. Почему просто не сделать 3Д модели именно для расчета теней? Тогда и проблем с деревьями не будет.
    • +1
      Я не понял вопроса. 3Д модели в 2Д игру не вставишь.
      Однако из 3Д моделей отлично получаются спрайты, карты нормлей и карты высот + это будет все равно проще чем полноценное 3Д/2.5Д
      • –2
        А чем карта высот отличается от 3Д модели?
        • +1
          Карта высот — это 2,5D-модель, так сказать. В одной точке с координатами x,y не может быть нескольких значений z.
          • –3
            Карта высот самая что ни наесть 3D.
            От того что там только 1Z может быть ничего не меняется.
            Спроецировать полноценную 3D модель на 2Д мир ничуть не сложнее чем спроецировать карту высот.
        • +2
          Карта высот — 2Д текстура. В примере в статье это может быть один канал одной текстуры.
          • 0
            Есть какие-то аппаратные ограничения, которые не позволяют передать в шейдер 3Д буффер?
            • 0
              Если и можно, то зачем передавать 3Д модель, когда можно ограничиться текстуркой с картой высот?
              • 0
                Чтобы сделать тень от деревьев, например.
                Да и в целом смысл в генерации карты высот для этого дела теряется, когда можно сразу использовать 3Д модель.
                • 0
                  Тени от деревьев здесь можно сделать просто отдельным фейком: отрисовкой отдельного спрайта с поворотом и масштабированием. Это 2D игра, мир дешевых иллюзий. Скорее всего у автора нет никаких 3D моделей и карту высот он рисует вручную, также как и карту нормалей. Для простых зданий из этой игры это вполне реально. Вы же предлагаете оторванное от контекста неоправданно дорогое решение.
                  • 0
                    Вы серьезно полагаете, что нарисовать карту высот проще, чем несколько кубиков грубо повторяющих геометрию объекта?
                    • 0
                      Проще кому? Вам, возможно. Это два решения, требующие разных навыков разработчика и разных архитектур игрового движка. Для автора статьи явно работать с пикселями проще, чем 3D моделями. У него 2D игра с пиксель-арт графикой.

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

                      Думаю, дело в том, что вы умеете в 3D и просто сделали бы другую игру. Искренне завидую, сам бы делал всё в 3D если бы умел.
                      • 0
                        Да нет, не мне.
                        Никто же не делает карты высот и карты нормалей в фотошопе.
                        Самый распространенный способ создать карты высот — это слепить 3Д модель и запечь геометрию в текстуру.
                        Нарисовать карту высот соответствующую геометрии руками — это сложная работа, требующая навыка. Слепить 3Д модель из кубиков сможет кто угодно, после 15 минут просмотра туториалов на ютьбе. А уж из 3Д модели получить карту нормалей и карту высот — плевое дело.

                        Я бы согласился с доводом о производительности, если бы речь шла о расчете тени на CPU. JavaScript с этим не справится нормально. Но у нас все равно шейдеры, а значит видеокарта. Даже древний Intel скушает модель из десятка полигонов без потери производительности.

                        А вот с доводом, что деревья можно сделать фейком — я согласен.
                        В принципе если код получается проще, а результат устраивает, то почему бы и не карту высот…
                        • +1
                          В своей игрухе я делаю именно так, как вы описали: генерирую карты нормалей из хайрез-модели, но…

                          image

                          Это же карта нормалей нарисованная вручную!
                          Предлагаю нам всем вместе порадоваться
                          за тонко отточенное кунг-фу автора. :)

  • +1
    Спасибо за перевод! Приятно прикапываться к автору статьи читая её на родном языке! :)

    Путь карта высот приходит из воздуха, а шейдер не самый эффективный на свете, статья компактная и прагматичная, она полезна. Побольше бы таких статей и их переводов.
  • 0
    Похожие динамические тени зависящие от освещения были в пиксельной 2D игре Dungeon of the Endless.

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