Я работаю над игрой в жанре стесл-экшн, где в геймплее большую роль будут играть тени. Поэтому я сделал динамическое освещение/затенение, используя 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

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


  1. GubkaBob
    04.12.2015 16:28
    +1

    понимаю, что перевод. но тени, имхо, неестественные. тут уж либо 2d и без теней. или псевдо-3d, тогда сортируем объекты по глубине при отрисовке. ну и тени на объектах тоже придется сделать.


    1. OlegKozlov
      05.12.2015 17:26

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


      1. GubkaBob
        05.12.2015 19:51

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


        1. OlegKozlov
          05.12.2015 20:14
          +1

          Тема «Динамические свет и тени в моей 2d игре» раскрыта на 90%, не хватает только описания откуда взялась карта высот.


          1. agentx001
            07.12.2015 19:38
            +1

            Думаю рисует сам для каждого спрайта.


            1. Fen1kz
              07.12.2015 19:44

              Согласен. Учитывая то, что он даже карты нормалей рисует руками — карту высот ему нарисовать совсем не трудно.


        1. Fen1kz
          07.12.2015 19:42

          Почему нельзя? Автору нужно было реализовать цикл дня/ночи с тенями и освещением. Он реализовал, какие проблемы?


  1. yar3333
    04.12.2015 20:31
    -1

    45 секунд, потраченные на просмотр начального видео уже не вернуть…


    1. NonGrate
      05.12.2015 15:22
      +2

      Можно поставить будильник на 45 секунд раньше


  1. IrixV
    04.12.2015 21:23
    +1

    А готовые решения применять не пробовали, например spriteilluminator?


    1. OlegKozlov
      05.12.2015 17:35

      Эта штука освещает сам спрайт по карте нормалей, она не умеет отбрасывать тени.


  1. AllexIn
    05.12.2015 07:53

    Я не понимаю зачем делать карты высот для теней. Почему просто не сделать 3Д модели именно для расчета теней? Тогда и проблем с деревьями не будет.


    1. Fen1kz
      05.12.2015 12:00
      +1

      Я не понял вопроса. 3Д модели в 2Д игру не вставишь.
      Однако из 3Д моделей отлично получаются спрайты, карты нормлей и карты высот + это будет все равно проще чем полноценное 3Д/2.5Д


      1. AllexIn
        05.12.2015 13:33
        -2

        А чем карта высот отличается от 3Д модели?


        1. PatientZero
          05.12.2015 15:08
          +1

          Карта высот — это 2,5D-модель, так сказать. В одной точке с координатами x,y не может быть нескольких значений z.


          1. AllexIn
            05.12.2015 15:12
            -3

            Карта высот самая что ни наесть 3D.
            От того что там только 1Z может быть ничего не меняется.
            Спроецировать полноценную 3D модель на 2Д мир ничуть не сложнее чем спроецировать карту высот.


        1. Fen1kz
          05.12.2015 17:27
          +2

          Карта высот — 2Д текстура. В примере в статье это может быть один канал одной текстуры.


          1. AllexIn
            05.12.2015 17:35

            Есть какие-то аппаратные ограничения, которые не позволяют передать в шейдер 3Д буффер?


            1. OlegKozlov
              05.12.2015 17:40

              Если и можно, то зачем передавать 3Д модель, когда можно ограничиться текстуркой с картой высот?


              1. AllexIn
                05.12.2015 17:41

                Чтобы сделать тень от деревьев, например.
                Да и в целом смысл в генерации карты высот для этого дела теряется, когда можно сразу использовать 3Д модель.


                1. OlegKozlov
                  05.12.2015 17:49

                  Тени от деревьев здесь можно сделать просто отдельным фейком: отрисовкой отдельного спрайта с поворотом и масштабированием. Это 2D игра, мир дешевых иллюзий. Скорее всего у автора нет никаких 3D моделей и карту высот он рисует вручную, также как и карту нормалей. Для простых зданий из этой игры это вполне реально. Вы же предлагаете оторванное от контекста неоправданно дорогое решение.


                  1. AllexIn
                    05.12.2015 17:53

                    Вы серьезно полагаете, что нарисовать карту высот проще, чем несколько кубиков грубо повторяющих геометрию объекта?


                    1. OlegKozlov
                      05.12.2015 18:07

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

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

                      Думаю, дело в том, что вы умеете в 3D и просто сделали бы другую игру. Искренне завидую, сам бы делал всё в 3D если бы умел.


                      1. AllexIn
                        05.12.2015 18:15

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

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

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


                        1. OlegKozlov
                          05.12.2015 18:27
                          +1

                          В своей игрухе я делаю именно так, как вы описали: генерирую карты нормалей из хайрез-модели, но…

                          image

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


  1. OlegKozlov
    05.12.2015 17:32
    +1

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

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


  1. Jony_B
    08.12.2015 11:44

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