Pull to refresh

2D тени на WebGL за 4 простых шага

Reading time12 min
Views12K
В этой статье я расскажу о том, как своими руками, имея только блокнот и любой веб-сервер, сделать шейдерные 2D-тени на WebGL. Все шаги лежат на гитхабе как ветки и переключаются git checkout stepN, так что добро пожаловать даже тем, кто не настроен кодить.

КДПВ:



Поиграться с готовым проектом можно здесь.

Итак, недавно мне захотелось написать что-нибудь с 2Д-тенями на javascript'е. С кучей источников света и любыми (читай: растровыми) препятствиями для них. Рейтрейсинг напугал сложными отношениями с геометрией (особенно когда она не умещается на экране), поэтому мой выбор пал на WebGL шейдеры.

ТЗ: Есть картинка, на которой нарисованы препятствия, есть координаты источников света, надо нарисовать тени.

Использовать будем pixi.js: фреймворк для рисования, имеет рендерер, отображающий некую сцену (экземпляр PIXI.Container) на страничке. Сцена-Контейнер может содержать другие контейнеры, или уже непосредственно спрайты/графику (PIXI.Sprite/PIXI.Graphics соответственно). PIXI.Graphics для рисования, Спрайты содержат текстуру, либо тоже нарисованную, либо загруженную извне. Также к Спрайтам можно применять Фильтры (шейдеры).

Про Фильтры: специальные объекты, заносятся в переменную filters
someGraphicObject.filters = [filter]

и производят какие-либо операции с текстурой этого объекта, в том числе могут наложить шейдер. У нас же будет особенный фильтр, состоящий из двух «подфильтров». Такая «сложная» структура нужна из-за метода рисования теней:

Генерируем карту теней (для каждого источника света), одномерную текстуру изображающую препятствия с точки зрения источника, где координата X будет углом, а значение альфа-канала — расстоянием до препятствия. Однако, так как такая текстура подходит только для одного источника света, для экономии места и ресурсов текстура будет все-таки двумерной, Y будет означать номер источника.

Вопрос для понимания: Текстура 256х256, пиксель с координатами (х = 64, у = 8) полупрозрачный, назовите номер источника света, и полярные координаты препятствия относительно источника.

Ответ
Номер не больше восьмого (зависимость между Y и номером может быть не прямая), расстояние — половина от некоего числа (опять же, зависимость может быть не прямая), угол — 90 градусов.

Потом, из исходного изображения и карты теней вторым шейдером рисуем сами тени и, заодно, смешиваем всё там же.

Иерархия с т.з. PIXI будет такой:

Сцена (PIXI.Container)
|- Лампочки (пара PIXI.Graphics)
|- Спрайт на котором будут нарисованы тени + освещенный фон (PIXI.Sprite)
|- |- Текстура (PIXI.RenderTexture)
|- |- Фильтр (PIXI.AbstractFilter)


И отдельно, вне сцены:

|- Фон (PIXI.Sprite)
|- |- Текстура фона
|- Контейнер для препятствий (PIXI.Container)
|- |- Спрайт с препятствиями (PIXI.Sprite)
|- |- |- Текстура спрайта


Заметьте, фон и контейнер мы передадим в фильтр который сам разберется что и как рисовать.
Вот и вся теория, теперь можно приступить к практике:

Шаг 0: Создать проект


Либо скачать его отсюда, ветка step0.

Нам понадобятся 3 файла:

  • index.html c

    простейшим содержанием
    <pre>
    <!DOCTYPE HTML>
    <html>
    <head>
    	<title>Deferred Example</title>
    	<meta charset="UTF-8">
    	<style></style>
    	<script src="js/pixi.min.js"></script>
    	<script src="js/script.js"></script>
    </head>
    <body>
    </body>
    </html>
    


  • Картинка с препятствиями — любой PNG с прозрачностью, я рекомендую вот такую.

    Скрытый текст


  • Библиотека pixi.js, скачать можно отсюда.

Ещё неплохо сразу завести любой веб-сервер с папкой проекта.

Шаг 1: Создать сцену


Создается всё в файле js/script.js. Код, я надеюсь, очевиден даже тем, кто не знаком с PIXI:

js/script.js
(function () {
   var WIDTH = 640;
   var HEIGHT = 480;
   var renderer = new PIXI.WebGLRenderer(WIDTH, HEIGHT); // Создаем PIXI рендерер

   document.addEventListener("DOMContentLoaded", function (event) { // Как только html загрузился
       document.body.appendChild(renderer.view);

       // Загружаем дополнительные файлы
       PIXI.loader
           .add('background', 'img/maze.png')
           .once('complete', setup)
           .load();
   });

   function setup() {
       // Все готово, можно рисовать.
       var lights = []; // Лампочки
       lights[0] = new PIXI.Graphics();
       lights[0].beginFill(0xFFFF00);
       lights[0].drawCircle(0, 0, 4); // x, y, radius
       lights[1] = new PIXI.Graphics();
       lights[1].beginFill(0xFFFF00);
       lights[1].drawCircle(0, 0, 4);
       lights[1].x = 50;
       lights[1].y = 50;
       var background = new PIXI.Graphics();
       background.beginFill(0x999999);
       background.drawRect(0, 0, WIDTH, HEIGHT); // x, y, width, height

       var shadowCastImage = PIXI.Sprite.fromImage('img/maze.png'); // Отбрасывающая тень картинка с объектами (черным)

       var shadowCasters = new PIXI.Container(); // Контейнер для всех, кто отбрасывает тень
       shadowCasters.addChild(shadowCastImage); // Добавляем туда нашу картинку.

       var stage = new PIXI.Container();
       stage.addChild(background);
       stage.addChild(shadowCasters);
       stage.addChild(lights[0]);
       stage.addChild(lights[1]);

       (function animate() {
           // lights[0] будет бегать за мышкой.
           var pointer = renderer.plugins.interaction.mouse.global;
           lights[0].x = pointer.x;
           lights[0].y = pointer.y;

           // Рендер
           renderer.render(stage);
          
           requestAnimationFrame(animate);
       })();
   }
})();


В результате получится уже что-то вразумительное, картинка, две лампочки, причем одна уже будет привязана к мышке:



Шаг 2: Добавить шейдер


Я начну с простого шейдера, он будет красить препятствия в красный, а все остальное — в серый цвет.

Файл glsl/smap-shadow-texture.frag:
precision mediump float; // Обязательно ставим точность

varying vec2 vTextureCoord; // Здесь будет относительная (от 0.0 до 1.0) координата пикселя
uniform sampler2D uSampler; // Текстура, к которой применен шейдер.

void main() {
    vec4 color = texture2D(uSampler, vTextureCoord); // получаем цвет пикселя

    if (color.a == 0.) { // если альфа-канал пуст
        gl_FragColor = vec4(.5, .5, .5, 1.); // красим в серый, пусто
    } else { // а если черный
        gl_FragColor = vec4(1., 0., 0., 1.); // красим в красный, препятствие
    }
}


Загружаем его в loader'e:

  PIXI.loader
    ...
    .add('glslShadowTexture', 'glsl/smap-shadow-texture.frag')
    ...

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

  var lightingRT = new PIXI.RenderTexture(renderer, WIDTH, HEIGHT);
  var lightingSprite = new PIXI.Sprite(lightingRT);
  var filter = createSMapFilter();
  lightingSprite.filters = [filter];
  ...
  stage.addChild(lightingSprite);

Ну и отрисовка, в функции animate():

  lightingRT.render(shadowCasters, null, true); // (shadowCasters как объект PIXI, null как матрица трансформации (опционально), true как флаг очистки текстуры)

Осталось только объявить функцию createSMapFilter:

  function createSMapFilter() {
    var SMapFilter = new PIXI.AbstractFilter(null, PIXI.loader.resources.glslShadowTexture.data, {}); // (стандартный вертексный шейдер, наш загруженный пиксельный шейдер, пустой объект с глобальными переменными (uniforms))
    return SMapFilter;
  }

Все препятствия покрасятся в красный цвет:



Шаг 3: Создание карты теней


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

  function createSMapFilter() {
   var CONST_LIGHTS_COUNT = 2;
   var SMapFilter = new PIXI.AbstractFilter(null, null, { // Заметьте, шейдера здесь больше нет, это фильтр "обертка"
       viewResolution: {type: '2fv', value: [WIDTH, HEIGHT]} // Передаем в фильтр размеры view
       , rtSize: {type: '2fv', value: [1024, CONST_LIGHTS_COUNT]} // Передаем размеры карты теней.
       , uAmbient: {type: '4fv', value: [.0, .0, .0, .0]} // И освещение "по дефолту" пускай будет ноль.
   });
   // Дополнительные глобалки для задания координат/цвета источников света:
   for (var i = 0; i < CONST_LIGHTS_COUNT; ++i) {
       SMapFilter.uniforms['uLightPosition[' + i + ']'] = {type: '4fv', value: [0, 0, 256, .3]}; // х, у, размер в пикселях и "falloff" уровень падения освещенности
       SMapFilter.uniforms['uLightColor[' + i + ']'] = {type: '4fv', value: [1, 1, 1, .3]}; // r, g, b, и эмбиент освещение для конкретного источника света.
   }

   // Создаем специальный PIXI объект, куда будет рендериться карта теней
   SMapFilter.renderTarget = new PIXI.RenderTarget(
       renderer.gl
       , SMapFilter.uniforms.rtSize.value[0]
       , SMapFilter.uniforms.rtSize.value[1]
       , PIXI.SCALE_MODES.LINEAR
       , 1);
   SMapFilter.renderTarget.transform = new PIXI.Matrix()
       .scale(SMapFilter.uniforms.rtSize.value[0] / WIDTH
           , SMapFilter.uniforms.rtSize.value[1] / HEIGHT);

   // Текстура в которую мы будем рендерить препятствия:
   SMapFilter.shadowCastersRT = new PIXI.RenderTexture(renderer, WIDTH, HEIGHT);
   SMapFilter.uniforms.uShadowCastersTexture = {
       type: 'sampler2D',
       value: SMapFilter.shadowCastersRT
   };
   // И метод для рендеринга:
   SMapFilter.render = function (group) {
       SMapFilter.shadowCastersRT.render(group, null, true);
   };

   // Тестовый шейдер, не делающий ничего;
   SMapFilter.testFilter = new PIXI.AbstractFilter(null, "precision highp float;"
       + "varying vec2 vTextureCoord;"
       + "uniform sampler2D uSampler;"
       + "void main(void) {gl_FragColor = texture2D(uSampler, vTextureCoord);}");

   // Шейдер, записывающий в renderTarget карту теней.
   var filterShadowTextureSource = PIXI.loader.resources.glslShadowTexture.data;
   // CONST_LIGHTS_COUNT должна быть известна на этапе компиляции и не может передаваться как uniform.
   filterShadowTextureSource = filterShadowTextureSource.replace(/CONST_LIGHTS_COUNT/g, CONST_LIGHTS_COUNT);

   // А также мы должны клонировать объект uniforms, этого требует WebGL (на самом деле пока что клонировать не надо, но мы же допишем второй шейдер)
   var filterShadowTextureUniforms = Object.keys(SMapFilter.uniforms).reduce(function (c, k) {
       c[k] = {
           type: SMapFilter.uniforms[k].type
           , value: SMapFilter.uniforms[k].value
       };
       return c;
   }, {});
   SMapFilter.filterShadowTexture = new PIXI.AbstractFilter(
       null
       , filterShadowTextureSource
       , filterShadowTextureUniforms
   );

   SMapFilter.applyFilter = function (renderer, input, output) {
       SMapFilter.filterShadowTexture.applyFilter(renderer, input, SMapFilter.renderTarget, true);
       SMapFilter.testFilter.applyFilter(renderer, SMapFilter.renderTarget, output); // будет заменен на второй шейдер.
   };
   return SMapFilter;
}

Следует отметить, что filterShadowTexture не использует input. Вместо этого он получает данные через uniform uShadowCastersTexture. Я сделал так, чтобы не заморачиваться с ещё одной renderTarget.

Дальше можно обновить интерактив в функции animate:

  // Обновляем uniforms в шейдере
  filter.uniforms['uLightPosition[0]'].value[0] = lights[0].x;
  filter.uniforms['uLightPosition[0]'].value[1] = lights[0].y;
  filter.uniforms['uLightPosition[1]'].value[0] = lights[1].x;
  filter.uniforms['uLightPosition[1]'].value[1] = lights[1].y;

  // Рендерим контейнер с препятствиями в renderTexture в фильтре.
  filter.render(shadowCasters);

И, наконец, приступить к написанию шейдера:

// Шейдер для создания карты теней:

precision mediump float;

//Объявляем переданные uniform'ы
varying vec2 vTextureCoord; // Координата
uniform sampler2D uSampler; // Текстура на которую наложен фильтр (не используется)

uniform vec2 viewResolution; // Разрешение вьюшки
uniform vec2 rtSize; // Размер renderTarget

uniform vec4 uLightPosition[CONST_LIGHTS_COUNT]; //x,y = координаты, z = размер
uniform vec4 uLightColor[CONST_LIGHTS_COUNT]; //На всякий случай

uniform sampler2D uShadowCastersTexture; // Отсюда мы будем брать данные -- есть препятствие или нет.

const float PI = 3.14159265358979;
const float STEPS = 256.0;
const float THRESHOLD = .01;

void main(void) {
    int lightnum = int(floor(vTextureCoord.y * float(CONST_LIGHTS_COUNT))); // Определяем номер источника света по Y
    vec2 lightPosition;
    float lightSize;
    for (int i = 0; i < CONST_LIGHTS_COUNT; i += 1) { // Определяем сам источник света по его номеру
        if (lightnum == i) {
            lightPosition = uLightPosition[i].xy / viewResolution;
            lightSize = uLightPosition[i].z / max(viewResolution.x, viewResolution.y);
            break;
        }
    }
    float dst = 1.0; // Считаем что препятствий нет
    for (float y = 0.0; y < STEPS; y += 1.0) { // И мелкими (с мелкостью (y / STEPS)) шагами идем во всех направлениях
        float distance = (y / STEPS); // Расстояния для теста
        float angle = vTextureCoord.x * (2.0 * PI); // Угол для теста
        // По полярным координатам вычисляем пиксель для теста
        vec2 coord = vec2(cos(angle) * distance, sin(angle) * distance);
        coord *= (max(viewResolution.x, viewResolution.y) / viewResolution);  // Пропорции
        coord += lightPosition; // Прибавляем координаты источника
        coord = clamp(coord, 0., 1.); // Не выходим за пределы текстуры
        vec4 data = texture2D(uShadowCastersTexture, coord); // Находим пиксель
        if (data.a > THRESHOLD) { // Если есть препятствие, записываем расстояние и прекращаем поиск.
            dst = min(dst, distance);
            break;
        }
    }
    // Дистанция получается в пикселях, сохраняем её в отрезке 0..1
    gl_FragColor = vec4(vec3(0.0), dst / lightSize);
}

Особо тут комментировать нечего, возможно я дополню чем-нибудь из вопросов в комментариях.

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



Шаг 4: По карте теней строим тени


Добавим новый файл в предзагрузку, теперь она будет выглядеть так:

  PIXI.loader
      .add('background', 'img/maze.png')
      .add('glslShadowTexture', 'glsl/smap-shadow-texture.frag')
      .add('glslShadowCast', 'glsl/smap-shadow-cast.frag')
      .once('complete', setup)
      .load();

И, в функции createSMapFilter, снова копируем uniforms, на этот раз уже для второго шейдера:

  var filterShadowCastUniforms = Object.keys(SMapFilter.uniforms).reduce(function (c, k) {
      c[k] = {
          type: SMapFilter.uniforms[k].type
          , value: SMapFilter.uniforms[k].value
      };
      return c;
  }, {});

Дальше нам потребуется передать в него карту теней (которая содержится в renderTarget). Я не нашел как этого сделать, поэтому использую хак, представив renderTarget как Texture:

  filterShadowCastUniforms.shadowMapChannel = {
      type: 'sampler2D',
      value: {
          baseTexture: {
              hasLoaded: true
              , _glTextures: [SMapFilter.renderTarget.texture]
          }
      }
  };

Создание шейдера:

  SMapFilter.filterShadowCast = new PIXI.AbstractFilter(
      null
      , PIXI.loader.resources.glslShadowCast.data.replace(/CONST_LIGHTS_COUNT/g, CONST_LIGHTS_COUNT)
      , filterShadowCastUniforms
  );

Изменяем applyFilter:

  SMapFilter.applyFilter = function (renderer, input, output) {
      SMapFilter.filterShadowTexture.applyFilter(renderer, input, SMapFilter.renderTarget, true);
      //SMapFilter.testFilter.applyFilter(renderer, SMapFilter.renderTarget, output); // будет заменен на второй шейдер.
      SMapFilter.filterShadowCast.applyFilter(renderer, input, output);
  };

Ещё раз, чтобы все понимали, что куда идет:

input — это текстура всей сцены, на которую будет наложено освещение;
output — она же;
SMapFilter.renderTarget — это карта теней.

Первый шейдер не использует input и пишет карту теней из uniform’а, второй шейдер использует и input и карту теней (в виде uniform’а).

Всё, осталось разобраться с glsl/smal-shadow-cast.frag:

precision mediump float;

uniform sampler2D uSampler;
varying vec2 vTextureCoord;

uniform vec2 viewResolution;

uniform sampler2D shadowMapChannel;

uniform vec4 uAmbient;
uniform vec4 uLightPosition[CONST_LIGHTS_COUNT];
uniform vec4 uLightColor[CONST_LIGHTS_COUNT];

const float PI = 3.14159265358979;

// Вспомогательная функция для (функции) чтения карты теней
vec4 takeSample(in sampler2D texture, in vec2 coord, in float light) {
   return step(light, texture2D(texture, coord));
}

// Сама функция чтения карты теней (со сглаживанием!)
vec4 blurFn(in sampler2D texture, in vec2 tc, in float light, in float iBlur) {
   float blur = iBlur / viewResolution.x;
   vec4 sum = vec4(0.0);
   sum += takeSample(texture, vec2(tc.x - 5.0*blur, tc.y), light) * 0.022657;
   sum += takeSample(texture, vec2(tc.x - 4.0*blur, tc.y), light) * 0.046108;
   sum += takeSample(texture, vec2(tc.x - 3.0*blur, tc.y), light) * 0.080127;
   sum += takeSample(texture, vec2(tc.x - 2.0*blur, tc.y), light) * 0.118904;
   sum += takeSample(texture, vec2(tc.x - 1.0*blur, tc.y), light) * 0.150677;

   sum += takeSample(texture, vec2(tc.x, tc.y), light) * 0.163053;

   sum += takeSample(texture, vec2(tc.x + 1.0*blur, tc.y), light) * 0.150677;
   sum += takeSample(texture, vec2(tc.x + 2.0*blur, tc.y), light) * 0.118904;
   sum += takeSample(texture, vec2(tc.x + 3.0*blur, tc.y), light) * 0.080127;
   sum += takeSample(texture, vec2(tc.x + 4.0*blur, tc.y), light) * 0.046108;
   sum += takeSample(texture, vec2(tc.x + 5.0*blur, tc.y), light) * 0.022657;
   return sum;
}

// Ок, теперь можно начать:
void main() {
   // Изначально освещение у нас черное
   vec4 color = vec4(0.0, 0.0, 0.0, 1.0);

   // Вспомогательная переменная для чтения источника света.
   float lightLookupHalfStep = (1.0 / float(CONST_LIGHTS_COUNT)) * .5;

   // В цикле перебираем все источники света
   for (int lightNumber = 0; lightNumber < CONST_LIGHTS_COUNT; lightNumber += 1) {
       float lightSize = uLightPosition[lightNumber].z / max(viewResolution.x, viewResolution.y);
       float lightFalloff = min(0.99, uLightPosition[lightNumber].a);
       if (lightSize == 0.) {
           // Если размер нулевой, то продолжать смысла нет.
           continue;
       }
       vec2 lightPosition = uLightPosition[lightNumber].xy / viewResolution;
       vec4 lightColor = uLightColor[lightNumber];
       // Результат для конкретного источника света
       vec3 lightLuminosity = vec3(0.0);

       // Координата Y этого источника на карте теней.
       float yCoord = float(lightNumber) / float(CONST_LIGHTS_COUNT) + lightLookupHalfStep;

       // Вектор от точки к источнику света
       vec2 toLight = vTextureCoord - lightPosition;

       // Пропорции
       toLight /= (max(viewResolution.x, viewResolution.y) / viewResolution);
       toLight /= lightSize;

       // Расстояние от точки до источника
       float light = length(toLight);
       // Угол от точки до источника (для координаты Х)
       float angleToPoint = atan(toLight.y, toLight.x);
       float angleCoordOnMap = angleToPoint / (2.0 * PI);

       vec2 samplePoint = vec2(angleCoordOnMap, yCoord);

       // Чем дальше от света -- тем больше размытия.
       float blur = smoothstep(0., 2., light);

       // Наконец смотрим, есть ли тень от этого источника и на сколько она размыта в данной точке
       float sum = blurFn(shadowMapChannel, samplePoint, light, blur).a;
       sum = max(sum, lightColor.a);

       lightLuminosity = lightColor.rgb * vec3(sum) * smoothstep(1.0, lightFalloff, light);

       // Прибавляем к общему освещению (в пикселе):
       color.rgb += lightLuminosity;
   }

   // Общее освещение
   color = max(color, uAmbient);

   // Пиксель который надо осветить
   vec4 base = texture2D(uSampler, vTextureCoord);

   // Освещаем умножением на корень из "освещенности" (по мне так красивее чем просто)
   gl_FragColor = vec4(base.rgb * sqrt(color.rgb), 1.0);
}

Сохраняем и вот, все работает:



Ну или можно переключится на ветку step4.

В заключение хочу сказать, что получил не то что хотел (хотелось крутые и простые тени, а получил Бесценный Опыт написания шейдеров), однако результат выглядит приемлимо и его можно где-нибудь использовать.
Tags:
Hubs:
Total votes 25: ↑24 and ↓1+23
Comments0

Articles