Pull to refresh

Шейдеры 3D-игр для начинающих: эффекты

Reading time18 min
Views14K
Original author: lettier
[Первая часть]

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

Контуры



Создание контуров вокруг геометрии сцены придаёт игре уникальный внешний вид, напоминающий комиксы или мультфильмы.

Diffuse материала


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

uniform struct
  { vec4 diffuse
  ;
  } p3d_Material;

out vec4 fragColor;

void main() {
  vec3 diffuseColor = p3d_Material.diffuse.rgb;
  fragColor = vec4(diffuseColor, 1);
}

Здесь показан небольшой фрагментный шейдер, который рендерит диффузный цвет материала геометрии в текстуру буфера кадров. Эта текстура диффузного цвета из буфера кадров будет входящей текстурой для шейдера контуров.


Это текстура диффузного цвета материала из буфера кадров, на которой отображаются цвета, заданные нами в Blender. Шейдер контуров будет распознавать рёбра в сцене и раскрашивать их.

Следует учесть, что диффузный цвет материалов не будет работать, если у отдельных частей сцены нет собственного диффузного цвета материала.

Создание краёв



Создание краёв похоже на использование фильтров распознавания краёв в GIMP.

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

// ...

uniform sampler2D materialDiffuseTexture;

  // ...

  vec2 texSize  = textureSize(materialDiffuseTexture, 0).xy;
  vec2 texCoord = gl_FragCoord.xy;

  // ...

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

  // ...

  int separation = 1;

  // ...

Параметр separation можно настраивать в соответствии со своим вкусом. Чем больше разделение, тем толще края или линии.

  // ...

  float threshold = 0;

  // ...

  vec4 mx = vec4(0);
  vec4 mn = vec4(1);

  int x = -1;
  int y = -1;

  for (int i = 0; i < 9; ++i) {
    vec4 color =
      texture
        ( materialDiffuseTexture
        , (texCoord + (vec2(x, y) * separation)) / texSize
        );
    mx = max(color, mx);
    mn = min(color, mn);
    x += 1;
    if (x >= 2) {
      x  = -1;
      y +=  1;
    }
  }

  float alpha = ((mx.r + mx.g + mx.b) / 3) - ((mn.r + mn.g + mn.b) / 3);
  if (alpha > threshold) { alpha = 1; }

  // ...


Техника распознавания краёв находит изменения в цветах входящей текстуры. Центрировавшись на текущем фрагменте, она использует окно фрагментов 3x3 для нахождения самого яркого и самого тёмного цветов из девяти сэмплов. Затем она вычитает из яркости одного цвета яркость другого, получая их разность.

  // ...

  vec3 lineRgb    = vec3(0.012, 0.014, 0.022);

  // ...

  vec4 lineColor = vec4(lineRgb, alpha);

      // ...

      fragColor = lineColor;

      // ...

Эта разность используется в альфа-канале выводимого цвета. Если разности нет, то край или линия не отрисовываются. Если разность есть, то край отрисовывается.

  // ...

  float threshold = 0;

  // ...

  if (alpha > threshold) { alpha = 1; }

  // ...

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

Исходники



Туман



Туман (или дымка (mist), как он называется в Blender) добавляет сцене атмосферной дымки, создавая загадочные смягчённые выступающие части. Выступающие части появляются, когда какая-нибудь геометрия внезапно попадает в пирамиду видимости камеры.

// ...

uniform struct p3d_FogParameters
  { vec4 color
  ; float start
  ; float end
  ;
  } p3d_Fog;

// ...

В Panda3D есть удобная структура данных, содержащая все параметры тумана, но вы можете передавать их в свой шейдер и вручную.

    // ...

    float fogIntensity =
      clamp
        (   ( p3d_Fog.end - vertexPosition.y)
          / ( p3d_Fog.end - p3d_Fog.start)
        , 0
        , 1
        );
    fogIntensity = 1 - fogIntensity;


    // ...

В примере кода для вычисления яркости тумана при отдалении от камеры используется линейная модель. Вместо неё можно использовать и экспоненциальную модель. Яркость тумана равна нулю до или в начале тумана. Когда позиция вершины приближается к концу тумана, fogIntensity приближается к единице. Для всех вершин после конца тумана fogIntensity ограничивается сверху значением 1.

    // ...

    fragColor =
      mix
        ( outputColor
        , p3d_Fog.color
        , fogIntensity
        );

    // ...

Основываясь на яркости тумана, мы смешиваем цвет тумана с выходным цветом. При приближении fogIntensity к единице будет всё меньше outputColor и всё больше цвета тумана. Когда fogIntensity достигает единицы, останется только цвет тумана.

Туман на контурах




// ...

uniform sampler2D positionTexture;

      // ...

      vec4 position = texture(positionTexture, texCoord / texSize);

      float fogIntensity =
        clamp
          (   ( p3d_Fog.end - position.y)
            / ( p3d_Fog.end - p3d_Fog.start)
          , 0
          , 1
          );
      fogIntensity = 1 - fogIntensity;

      vec4 lineWithFogColor =
        mix
          ( lineColor
          , p3d_Fog.color
          , fogIntensity
          );

      fragColor = vec4(lineWithFogColor.rgb, alpha);

      // ...

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

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

Исходники



Bloom



Добавление в сцену bloom может создать убедительную иллюзию модели освещения. Испускающие свет объекты становятся более убедительными, а засветы отражений получают дополнительную величину сияния.

  //...

  float separation = 3;
  int   samples    = 15;
  float threshold  = 0.5;
  float amount     = 1;

  // ...

Можете настроить эти параметры по своему вкусу. Separation увеличивает размер размытия. Samples определяет силу размытия. Threshold определяет, что подвергнется и не подвергнется влиянию этого эффекта. Amount управляет величиной выводимого bloom.

  // ...

  int size  = samples;
  int size2 = size * size;

  int x = 0;
  int y = 0;

  // ...

  float value = 0;

  vec4 result = vec4(0);
  vec4 color = vec4(0);

  // ...

  for (int i = 0; i < size2; ++i) {
    // ...
  }

  // ...

Эта техника начинает работу с прохождения окном размером samples на samples, центрированному относительно текущего фрагмента. Оно похоже на окно, использованное для создания контуров.

    // ...

    color =
      texture
        ( bloomTexture
        ,   ( gl_FragCoord.xy
            + vec2(x * separation, y * separation)
            )
          / texSize
        );

    value = ((0.3 * color.r) + (0.59 * color.g) + (0.11 * color.b));
    if (value < threshold) { color = vec4(0); }

    result += color;

    // ...

Этот код получает цвет из входящей текстуры и превращает значения красного, зелёного и синего в значение в градациях серого. Если значение в градациях серого меньше threshold, то он отбрасывает этот цвет, делая его чёрным.

Проходя в цикле все сэмплы в пределах окна, он накапливает все их значения в result.

  // ...

  result = result / size2;

  // ...

Завершив сбор сэмплов, он делит сумму сэмплов цветов на количество взятых сэмплов. Результат является средним цветом самого фрагмента и его соседей. Сделав так для каждого фрагмента, мы получим размытое изображение. Такой вид размытия называется box blur.


Здесь вы видите процесс выполнения алгоритма bloom.

Исходники



Screen Space Ambient Occlusion (SSAO)



SSAO — это один из тех эффектов, о существовании которых вы не знаете, но как только узнали, больше не можете без них жить. Он может превратить посредственную сцену в потрясающую! В статичных сценах ambient occlusion можно запечь в текстуру, но для более динамичных сцен нам понадобится шейдер. SSAO — одна из более сложных техник шейдинга, но разобравшись с ней, вы станете мастером по шейдерам.

Учтите, что термин «screen space» в названии не совсем верен, потому что не все вычисления производятся в экранном пространстве.

Входящие данные


Шейдеру SSAO потребуются следующие входящие данные.

  • Векторы позиций вершин в пространстве обзора.
  • Векторы нормалей к вершинам в пространстве обзора.
  • Векторы сэмплов в касательном пространстве.
  • Векторы шума в касательном пространстве.
  • Матрица проецирования на объектив камеры.

Позиция



Хранить позиции вершин в текстуре буфера кадров не обязательно. Мы можем воссоздать их из буфера глубин камеры. Я пишу руководство для новичков, поэтому мы не будем использовать эту оптимизацию, и приступим сразу к делу. В своей реализации вы можете без проблем использовать буфер глубин.

PT(Texture) depthTexture = new Texture("depthTexture");
depthTexture->set_format(Texture::Format::F_depth_component32);

PT(GraphicsOutput) depthBuffer = graphicsOutput->make_texture_buffer("depthBuffer", 0, 0, depthTexture);
depthBuffer->set_clear_color(LVecBase4f(0, 0, 0, 0));

NodePath depthCameraNP = window->make_camera();
DCAST(Camera, depthCameraNP.node())->set_lens(window->get_camera(0)->get_lens());
PT(DisplayRegion) depthBufferRegion = depthBuffer->make_display_region(0, 1, 0, 1);
depthBufferRegion->set_camera(depthCameraNP);

Если вы решите использовать буфер глубин, то вот как это можно настроить в Panda3D.

in vec4 vertexPosition;

out vec4 fragColor;

void main() {
  fragColor = vertexPosition;
}

Вот простой шейдер для рендеринга позиций вершин в пространстве обзора в текстуру буфера кадров. Более сложная задача заключается в настройке текстуры буфера кадров, чтобы получаемые им компоненты вектора фрагмента не ограничивались интервалом [0, 1], и чтобы у каждого была достаточно большая точность (достаточно большое количество бит). Например, если какая-то интерполированная позиция вершины равна <-139.444444566, 0.00000034343, 2.5>, то нельзя сохранять её в текстуру как <0.0, 0.0, 1.0>.

  // ...

  FrameBufferProperties fbp = FrameBufferProperties::get_default();

  // ...

  fbp.set_rgba_bits(32, 32, 32, 32);
  fbp.set_rgb_color(true);
  fbp.set_float_color(true);

  // ...

Вот как пример кода подготавливает текстуру буфера кадров для хранения позиций вершин. Ему нужно по 32 бита для красного, зелёного, синего и альфы, поэтому он отключает ограничение значений интервалом [0, 1]. Вызов set_rgba_bits(32, 32, 32, 32) задаёт битовый объём и отключает ограничение.

  glTexImage2D
    ( GL_TEXTURE_2D
    , 0
    , GL_RGB32F
    , 1200
    , 900
    , 0
    , GL_RGB
    , GL_FLOAT
    , nullptr
    );

Вот аналогичный вызов на OpenGL. GL_RGB32F задаёт биты и отключает ограничение.

Если буфер цветов имеет фиксированную запятую, то компоненты исходных и конечных значений, а также показатели смешения перед вычислением уравнения смешивания ограничиваются значениями [0, 1] или [−1, 1], соответственно для беззнакового нормализованного и знакового нормализованного буфера цветов. Если буфер цветов имеет плавающую запятую, то ограничение не выполняется.

Источник


Здесь вы видите позиции вершин; ось y направлена вверх.

Помните, что Panda3D задаёт ось z как вектор, направленный вверх, а в OpenGL вверх смотрит ось y. Шейдер позиций выводит позиции вершин с направленной вверх z, потому что в Panda3D
настроен параметр gl-coordinate-system default.

Нормали



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

in vec3 vertexNormal;

out vec4 fragColor;

void main() {
  vec3 normal = normalize(vertexNormal);

  fragColor = vec4(normal, 1);
}

Как и шейдер позиции шейдер нормалей очень прост. Не забудьте нормализовать нормали к вершинам и помните, что они находятся в пространстве обзора.


Здесь показаны нормали к вершинам; ось y направлена вверх.

Вспомним, что Panda3D считает направленным вверх вектором ось z, а OpenGL — ось y. Шейдер нормалей выводит позиции вершин с направленной вверх осью z, потому что в Panda3D настроен параметр gl-coordinate-system default.

Сэмплы


Чтобы определить величину ambient occlusion для любого отдельного фрагмента, нам нужно сэмплировать окружающую область.

  // ...

  for (int i = 0; i < numberOfSamples; ++i) {
    LVecBase3f sample =
      LVecBase3f
        ( randomFloats(generator) * 2.0 - 1.0
        , randomFloats(generator) * 2.0 - 1.0
        , randomFloats(generator)
        ).normalized();

    float rand = randomFloats(generator);
    sample[0] *= rand;
    sample[1] *= rand;
    sample[2] *= rand;

    float scale = (float) i / (float) numberOfSamples;
    scale = lerp(0.1, 1.0, scale * scale);
    sample[0] *= scale;
    sample[1] *= scale;
    sample[2] *= scale;

    ssaoSamples.push_back(sample);
  }

  // ...

Пример кода генерирует 64 случайных сэмплов, распределённых по полусфере. Эти ssaoSamples будут переданы в шейдер SSAO.

    LVecBase3f sample =
      LVecBase3f
        ( randomFloats(generator) * 2.0 - 1.0
        , randomFloats(generator) * 2.0 - 1.0
        , randomFloats(generator) * 2.0 - 1.0
        ).normalized();

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

Шум


  // ...

  for (int i = 0; i < 16; ++i) {
    LVecBase3f noise =
      LVecBase3f
        ( randomFloats(generator) * 2.0 - 1.0
        , randomFloats(generator) * 2.0 - 1.0
        , 0.0
        );

    ssaoNoise.push_back(noise);
  }

  // ...

Чтобы хорошо покрыть сэмплируемую область, нам нужно сгенерировать векторы шума. Эти векторы шума могут поворачивать сэмплы вокруг верхней части поверхности.

Ambient Occlusion



SSAO выполняет свою задачу, сэмплируя пространство обзора вокруг фрагмента. Чем больше сэмплов находится под поверхностью, тем темнее цвет фрагмента. Эти сэмплы располагаются во фрагменте и указывают в общем направлении нормали к вершине. Каждый сэмпл используется для поиска позиции в текстуре позиции буфера кадров. Возвращаемая позиция сравнивается с сэмплом. Если сэмпл дальше от камеры, чем позиция, то сэмпл по направлению к фрагменту перекрыт (occluded).


Здесь вы видите пространство над поверхностью, сэмплируемое на наличие occlusion.

  // ...

  float radius     = 1.1;
  float bias       = 0.026;
  float lowerRange = -2;
  float upperRange =  2;

  // ...

Как и у некоторых других техник, у шейдера SSAO есть несколько параметров управления, которые можно изменять для получения необходимого внешнего вида. bias прибавляется к расстоянию от сэмпла до камеры. Этот параметр можно использовать для борьбы с пятнами. radius увеличивает или уменьшает область покрытия пространства сэмплов. lowerRange и upperRange изменяют стандартный диапазон показателя factor с [0, 1] на любое выбранное вами значение. Увеличивая диапазон, можно повысить контрастность.

  // ...

  vec4 position = texture(positionTexture, texCoord);
  vec3 normal   = texture(normalTexture, texCoord).xyz;

  int  noiseX = int(gl_FragCoord.x - 0.5) % 4;
  int  noiseY = int(gl_FragCoord.y - 0.5) % 4;
  vec3 random = noise[noiseX + (noiseY * 4)];

  // ...

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

  // ...

  vec3 tangent  = normalize(random - normal * dot(random, normal));
  vec3 binormal = cross(normal, tangent);
  mat3 tbn      = mat3(tangent, binormal, normal);

  // ...

С помощью случайного вектора и вектора нормали соберём матрицу из касательной, бинормали и нормали. Нам понадобится эта матрица для преобразования векторов сэмплов из касательного пространства в пространство обзора.

  // ...

  float occlusion = NUM_SAMPLES;

  for (int i = 0; i < NUM_SAMPLES; ++i) {
    // ...
  }

  // ...

Имея матрицу, шейдер может обойти в цикле все сэмплы, вычитая количество неперекрытых.

    // ...

    vec3 sample = tbn * samples[i];
         sample = position.xyz + sample * radius;

    // ...

С помощью матрицы расположим сэмпл рядом с позицией вершины/фрагмента и отмасштабируем на величину радиуса.

    // ...

    vec4 offset      = vec4(sample, 1.0);
         offset      = lensProjection * offset;
         offset.xyz /= offset.w;
         offset.xyz  = offset.xyz * 0.5 + 0.5;

    // ...

С помощью позиции сэмпла в пространстве обзора преобразуем её из пространства обзора в пространство отсечения, а затем в UV-пространство.

-1 * 0.5 + 0.5 = 0
 1 * 0.5 + 0.5 = 1

Не забудем, что компоненты пространства отсечения находятся в интервале от минус единицы до единицы, а UV-координаты — в интервале от нуля до единицы. Чтобы преобразовать координаты пространства отсечения в UV-координаты, умножим их на одну вторую и прибавим одну вторую.

    // ...

    vec4 offsetPosition = texture(positionTexture, offset.xy);

    float occluded = 0;
    if (sample.y + bias <= offsetPosition.y) { occluded = 0; } else { occluded = 1; }

    // ...

С помощью UV-координат смещения, полученных проецированием 3D-сэмпла на 2D-текстуру позиции, найдём соответствующий вектор позиции. Это переносит нас из пространства обзора в пространство отсечения в UV-пространство а затем обратно в пространство обзора. Шейдер выполняет этот цикл, чтобы определить есть ли какая-то геометрия за сэмплом, в месте сэмпла или перед сэмплом. Если сэмпл находится перед какой-то геометрией или в ней, то этот сэмпл просто не учитывается относительно перекрываемого фрагмента. Если сэмпл находится за какой-то геометрией, то этот сэмпл учитывается относительно перекрываемого фрагмента.

    // ...

    float intensity =
      smoothstep
        ( 0.0
        , 1.0
        ,   radius
          / abs(position.y - offsetPosition.y)
        );
    occluded *= intensity;

    occlusion -= occluded;

    // ...

Теперь добавим этой сэмплированной позиции вес на основании того, насколько далеко он внутри или снаружи радиуса. Затем вычтем этот сэмпл из показателя occlusion, потому что он предполагает, что перед циклом все сэмплы были перекрыты.

    // ...

    occlusion /= NUM_SAMPLES;

    // ...

    fragColor = vec4(vec3(occlusion), position.a);

    // ...

Разделим количество перекрытых на количество сэмплов, чтобы перевести показатель occlusion из интервала [0, NUM_SAMPLES] в интервал [0, 1]. Ноль означает полную occlusion, единицы — отсутствие occlusion. Теперь назначим показатель occlusion цвету фрагмента, и на этом всё.

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

Размытие (Blur)



Текстура SSAO буфера кадров немного шумная, поэтому для сглаживания стоит её размыть.

  // ...

  for (int i = 0; i < size2; ++i) {
    x = size - xCount;
    y = yCount - size;

    result +=
      texture
        ( ssaoTexture
        ,   texCoord
          + vec2(x * parameters.x, y * parameters.x)
        ).rgb;

    xCount -= 1;
    if (xCount < countMin) { xCount = countMax; yCount -= 1; }
  }

  result = result / size2;

  // ...

Шейдер размытия SSAO — это обычный box blur. Как и шейдер bloom, он проводит окно над входящей текстурой и усредняет каждый фрагмент со значениями его соседей.

Учтите, что parameters.x — это параметр разделения.

Ambient Color


  // ...

  vec2 ssaoBlurTexSize  = textureSize(ssaoBlurTexture, 0).xy;
  vec2 ssaoBlurTexCoord = gl_FragCoord.xy / ssaoBlurTexSize;
  float ssao            = texture(ssaoBlurTexture, ssaoBlurTexCoord).r;

  vec4 ambient = p3d_Material.ambient * p3d_LightModel.ambient * diffuseTex * ssao;

  // ...

Последняя задача для SSAO снова находится в вычислении освещения. Здесь мы видим, как показатель occlusion находится в текстуре SSAO буфера текстуры и включается в вычисление окружающего освещения.

Исходники



Глубина резкости



Глубина резкости — это тоже такой эффект, узнав о котором, вы не сможете без него жить. С художественной точки зрения можно использовать его, чтобы привлечь внимание зрителя к определённому объекту. Но в общем случае глубина резкости (depth of field) ценой небольших усилий добавляет большую долю реализма.

В фокусе


Первый этап — рендеринг сцены полностью в фокусе. Отрендерите её в текстуру буфера кадров. Она будет одним из входящих значений для буфера глубины резкости.

Не в фокусе


  // ...

  vec4 result = vec4(0);

  for (int i = 0; i < size2; ++i) {
    x = size - xCount;
    y = yCount - size;

    result +=
      texture
        ( blurTexture
        ,   texCoord
          + vec2(x * parameters.x, y * parameters.x)
        );

    xCount -= 1;
    if (xCount < countMin) { xCount = countMax; yCount -= 1; }
  }

  result = result / size2;

  // ...

Второй этап — размытие сцены как будто она полностью не в фокусе. Как и в случае bloom и SSAO, можно использовать box blur. Отрендерите эту расфокусированную сцену в текстуру буфера кадров. Она будет ещё одним входящим значением шейдера глубины резкости.

Учтите, что parameters.x — это параметр разделения.

Смешение




  // ...

  float focalLengthSharpness = 100;
  float blurRate             = 6;

  // ...

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

  // ...

  vec4 focusColor      = texture(focusTexture, texCoord);
  vec4 outOfFocusColor = texture(outOfFocusTexture, texCoord);

  // ...

Нам понадобятся цвета в фокусе и в расфокусированном изображении.

  // ...

  vec4 position = texture(positionTexture, texCoord);

  // ...

Также нам может понадобиться позиция вершины в пространстве обзора. Можно повторно применить текстуру позиций из буфера кадров, которую использовали для SSAO.

  // ...

  float blur =
    clamp
      (   pow
            ( blurRate
            , abs(position.y - focalLength.x)
            )
        / focalLengthSharpness
      , 0
      , 1
      );

  // ...

    fragColor = mix(focusColor, outOfFocusColor, blur);

    // ...

А здесь происходит само смешение. Чем ближе blur к единице, тем больше оно будет использовать outOfFocusColor. Нулевое значение blur означает, что этот фрагмент целиком находится в фокусе. При blur >= 1 этот фрагмент полностью расфокусирован.

Исходники



Постеризация



Постеризация, или дискретизация цвета — это процесс снижения количества уникальных цветов в изображении. Можно использовать этот шейдер, чтобы придать игре вид комикса или ретро-изображения. Если скомбинировать его с контуром, то получится настоящий мультяшный стиль.

  // ...

  float levels = 8;

  // ...

Можете поэкспериментировать с этим параметром. Чем он больше, тем больше цветов в результате останется.

  // ...

  vec4 texColor = texture(posterizeTexture, texCoord);

  // ...

Нам понадобится входящий цвет.

    // ...

    vec3 grey  = vec3((texColor.r + texColor.g + texColor.b) / 3.0);
    vec3 grey1 = grey;

    grey = floor(grey * levels) / levels;

    texColor.rgb += (grey - grey1);

    // ...

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

  // ...

  fragColor = texColor;

  // ...

Не забудем присвоить значение входящего цвета цвету фрагмента.

Cel Shading



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

Исходники



Пикселизация



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

  // ...

  int pixelSize = 5;

  // ...

Можете сами настроить размер пикселя. Чем он больше, тем более грубым будет изображение.



  // ...

  float x = int(gl_FragCoord.x) % pixelSize;
  float y = int(gl_FragCoord.y) % pixelSize;

  x = floor(pixelSize / 2.0) - x;
  y = floor(pixelSize / 2.0) - y;

  x = gl_FragCoord.x + x;
  y = gl_FragCoord.y + y;

  // ...

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

    // ...

    fragColor = texture(pixelizeTexture, vec2(x, y) / texSize);

    // ...

После того, как мы определили координату нужного используемого фрагмента, берём его цвет из входящей текстуры и присваиваем его цвету фрагмента.

Исходники



Sharpen



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

  // ...

  float amount = 0.8;

  // ...

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

  // ...

  float neighbor = amount * -1;
  float center   = amount * 4 + 1;

  // ...

Соседние фрагменты умножаются на amount * -1. Текущий фрагмент умножается на amount * 4 + 1.

  // ...

  vec3 color =
        texture(sharpenTexture, vec2(gl_FragCoord.x + 0, gl_FragCoord.y + 1) / texSize).rgb
      * neighbor

      + texture(sharpenTexture, vec2(gl_FragCoord.x - 1, gl_FragCoord.y + 0) / texSize).rgb
      * neighbor
      + texture(sharpenTexture, vec2(gl_FragCoord.x + 0, gl_FragCoord.y + 0) / texSize).rgb
      * center
      + texture(sharpenTexture, vec2(gl_FragCoord.x + 1, gl_FragCoord.y + 0) / texSize).rgb
      * neighbor

      + texture(sharpenTexture, vec2(gl_FragCoord.x + 0, gl_FragCoord.y - 1) / texSize).rgb
      * neighbor
      ;

  // ...

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

    // ...

    fragColor = vec4(color, texture(sharpenTexture, texCoord).a);

    // ...

Эта сумма является окончательным цветом фрагмента.

Исходники



Зернистость плёнки



Зернистость плёнки (в малых дозах, а не как в примере) может добавить реализма, который незаметен, пока этот эффект не убрать. Обычно это несовершенства, которые делают цифровое сгенерированное изображение убедительнее.

Учтите, что зернистость плёнки (film grain) обычно является последним эффектом, который применяется к кадру перед выводом на экран.

Величина


  // ...

  float amount = 0.1;

  // ...

amount управляет заметностью зернистости плёнки. Чем значение выше, тем больше «снега» на картинке.

Случайная яркость


// ...

uniform float osg_FrameTime;

  //...

  float toRadians = 3.14 / 180;

    //...

    float randomIntensity =
      fract
        ( 10000
        * sin
            (
              ( gl_FragCoord.x
              + gl_FragCoord.y
              * osg_FrameTime
              )
            * toRadians
            )
        );

    // ...

Этот фрагмент кода вычисляет случайную яркость, необходимую для настройки величины.

Time Since F1 = 00 01 02 03 04 05 06 07 08 09 10
Frame Number  = F1    F3    F4       F5 F6
osg_FrameTime = 00    02    04       07 08

Значение osg_FrameTime предоставляется Panda3D. Время кадра — это временная метка с информацией о том, сколько секунд прошло после первого кадра. Пример кода использует его для анимирования зернистости плёнки, котому что osg_FrameTime в каждом кадре будет разным.

              // ...

              ( gl_FragCoord.x
              + gl_FragCoord.y
              * 8009 // Large number here.

              // ...

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



        // ...

        * sin
            (
              ( gl_FragCoord.x
              + gl_FragCoord.y
              * someNumber

              // ...

Для создания точек, или пятен зерна плёнки используются обе координаты, и x, и y. Если использовать x, то будут отображаться только вертикальные линии, если использовать y — то только горизонтальные.

В коде одна координата умножается на другую, чтобы разрушить диагональную симметрию.


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

Учтите, что для анимирования эффекта дождя нужно умножить вывод sin на osg_FrameTime.

Поэкспериментируйте с координатами x и y, чтобы изменять направление дождя. Для падающего вниз ливня оставьте только координату x.

input = (gl_FragCoord.x + gl_FragCoord.y * osg_FrameTime) * toRadians
  frame(10000 * sin(input)) =
    fract(10000 * sin(6.977777777777778)) =
      fract(10000 * 0.6400723818964882) =

sin используется как функция хеширования. Координаты фрагмента хешируются выходными значениями sin. Благодаря этого появляется удобное свойство — какими бы ни были входные данные (большими или малыми), выходной интервал будет находиться в интервале от минус единицы до единицы.

fract(10000 * sin(6.977777777777778)) =
  fract(10000 * 0.6400723818964882) =
    fract(6400.723818964882) =
      0.723818964882

sin в сочетании с fract также используется как генератор псевдослучайных чисел.

>>> [floor(fract(4     * sin(x * toRadians)) * 10) for x in range(0, 10)]
[0, 0, 1, 2, 2, 3, 4, 4, 5, 6]

>>> [floor(fract(10000 * sin(x * toRadians)) * 10) for x in range(0, 10)]
[0, 4, 8, 0, 2, 1, 7, 0, 0, 5]

Взгляните сначала на первый ряд чисел, а потом на второй. Каждый ряд детерминирован, но во втором меньше заметен паттерн, чем во втором. Поэтому несмотря на то, что выходные данные fract(10000 * sin(...)) детерминированы, паттерн узнаётся гораздо слабее.


Здесь мы видим, как множитель sin равен сначала 1, потом 10, потом 100, а затем 1000.

При увеличении множителя выходных значений sin паттерн становится всё меньше заметен. По этой причине в коде sin умножается на 10 000.

Цвет фрагмента


  // ...

  vec2 texSize  = textureSize(filmGrainTexture, 0).xy;
  vec2 texCoord = gl_FragCoord.xy / texSize;

  vec4 color = texture(filmGrainTexture, texCoord);

  // ...

Преобразуем координаты фрагмента в UV-координаты. Воспользовавшись этими UV-координатами, ищем цвет текстуры для текущего фрагмента.

    // ...

    amount *= randomIntensity;

    color.rgb += amount;

    // ...

Изменим величину на случайную яркость и прибавим её к цвету.

  // ...

  fragColor = color;

  // ...

Задаём цвет фрагмента, и на этом всё.

Исходники



Благодарности


Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 43: ↑41 and ↓2+39
Comments5

Articles