[Перевод] Динамические свет и тени в моей 2d игре

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

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


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

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

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

b921264697fb1d775d01b7baf8179317.gif

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

Ок, это всё очень здорово, но как получить вектора нормали в 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/разрешение как раз даст нам размер одного пикселя.

Заключение


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

shadow1.pngimage

© Habrahabr.ru