steps3D - Tutorials - Рендеринг полос дождя

Рендеринг полос дождя

Дождь является довольно часто встречающимся погодным явлением. Однако полосы дождя достаточно длинны и использование для них обычных систем частиц не всегда является оправданным и удобным. Здесь мы рассмотрим один из вариантов рендеринга полос/частиц дождя, работающий целиком на GPU, и имеющий минимальный CPU overhead.

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

Однако при этом сама эта окрестность не должна быть "жестко приклеена" к камере/игроку (т.е. дождь не должен двигаться вместе с камерой). Нам достаточно лишь выводить дождь в некоторой окрестности вокруг камеры. Однако при этом эта окрестность не должна быть жестко "приклеена" к камере.

Поэтому мы пойдем следующим путем - мы разобьем все 3D пространство на достаточно большие клетки фиксированного размера. При этом это разбиение привязано к ландшафту и не зависит от камеры. Тогда для каждого положения камеры (x,y,z) мы можем найти индекс клетки, в котором находится камера. Мы будем выводить дождь только в этой клетке, а также во всех смежных с ней клетках (рис).

Рис. Блок из 3х3х3 клеток, в середине - клетка, где находится камера

Сама система клеток жестко привязана к пространству и создаваемый таким образом дождь также будет привязан к пространству/ландшафту. Для того, чтобы минимизировать число вызовов glDraw* мы будем использовать instancing - команду glDrawArraysInstanced. За один ее вызов мы выведем сразу все 27 клеток с дождем сразу.

Вершинный шейдер по встроенной переменной gl_InstanceID сам определяет к какой именно клетке (из 27) относится та или иная вершина и произведет соответствующий сдвиг координат.

#version 330 core

#define PI  3.1415926

uniform mat4  mv;
uniform mat4  proj;
uniform vec3  eye;
uniform float t;
uniform int   nx, ny;
uniform int   gridSize;

uniform sampler3D noiseMap;

layout ( location = 0 ) in vec4 pos;
layout ( location = 1 ) in vec2 texCoord;

out vec4 tex;

    // remap instance id into box id (assume 3x3x3 boxes)
ivec3   getBoxIndex ()
{
    int ix = gl_InstanceID % 3;
    int ii = gl_InstanceID / 3;
    int iy = ii % 3;
    int iz = ii / 3;

    return ivec3 ( ix - 1, iy - 1, iz - 1 );
}

void main(void)
{
        // get grid box index with camera in it
    vec3  org   = vec3 ( gridSize ) * floor ( eye / vec3 ( gridSize ) );

        // get box origin
    ivec3   boxIndex = getBoxIndex ();
    vec3    offset   = vec3 ( gridSize ) * vec3 ( boxIndex );
    vec3    pt       = gridSize * pos.xyz + org + offset;

        // pass text coords, random value and distance to camera
    tex         = vec4 ( texCoord, pos.w, length ( pt ) );
    gl_Position = vec4 ( pt, 1.0 );
}

Для каждой частицы дождя мы будем передавать всего одну вершину - в ее x и y координатах мы будем передавать 2D-координаты частицы (без учета положения по вертикали), а в z и w будут находиться заранее рассчитанные случайные величины, нужные для придания дождю нерегулярного (случайного) вида. Задача вершинного шейдера - пересчитать координаты с учетом номера клетки и передать дальше в геометрический шейдер.

Именно геометрический шейдер превращает одну вершину в два пересекающихся прямоугольника (рис 2), обеспечивающий хорошую видимость с любой боковой стороны. Кроме того, именно геометрический шейдер отвечает за анимацию движения отдельный частиц и придание каждой частице случайно изменяющегося оттенка.

Рис. 2. Полоса дождя, хорошо видимая с любой боковой стороны

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

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

В геометрическом шейдере мы будем использовать две вспомогательных функции - addVertex и addQuad. Первая из них (addVertex) строит новую вершину прямоугольника и добавляет к выводимому примитиву. Вторая функция (addQuad) при помощи addVertex выводит готовый прямоугольник.

#version 330 core

layout ( points ) in;
layout ( triangle_strip, max_vertices = 8 ) out;

uniform float       threshold = 0.5;
uniform float       time;
uniform sampler3D   noiseMap;
uniform mat4        mv;
uniform mat4        proj;

in  vec4 tex [];
out vec4 texOut;

void    addVertex ( vec4 vertex, vec2 uv, float colorVariation, float opacity ) 
{
    texOut.xy   = uv;
    texOut.z    = opacity;
    texOut.w    = colorVariation;
    gl_Position = proj * mv * vertex;

    EmitVertex ();
}

void    createQuad  ( vec4 bottomMiddle, vec4 topMiddle, vec4 perpDir,
                      float colorVariation, float opacity ) 
{
    addVertex ( bottomMiddle - perpDir, vec2 ( 0, 0 ), colorVariation, opacity );
    addVertex ( bottomMiddle + perpDir, vec2 ( 1, 0 ), colorVariation, opacity );
    addVertex ( topMiddle    - perpDir, vec2 ( 0, 1 ), colorVariation, opacity );
    addVertex ( topMiddle    + perpDir, vec2 ( 1, 1 ), colorVariation, opacity );

    EndPrimitive ();
}
                       
const float fallSpeed         = 3;
const float maxTravelDistance = 4;
const float maxDist           = 6;      // distance attenuation

void    main ()
{
        // randomized discard points
    if ( tex [0].z > threshold )
        return;

    vec4    pos            = gl_in [0].gl_Position;
    vec2    uv             = tex   [0].xy;
    vec4    ns1            = texture ( noiseMap, pos.xyz * vec3 ( 0.123,  0.97643, 0.35991 ) );
    vec4    ns2            = texture ( noiseMap, pos.xyz * vec3 ( 0.2123, 0.643,   0.9914  ) );

        // start movement
    pos.y -= (time + 10000) * fallSpeed * ( 1 + ns1.x );
    pos.y  = mod ( pos.y, maxTravelDistance + ns1.y );
    
    float   opacity        = 1.0;

    opacity *= 1.0 - (maxDist - tex [0].w) / maxDist;

    if ( opacity <= 0 )
        return;

    float   colorVariation =  (sin(ns2.x * (pos.x + pos.y * ns2.y + pos.z + time * 2))*0.5+0.5)*1;    
    vec2    quadSize       = vec2 ( mix ( 0.03, 0.05, ns2.z ) );
    vec4    quadUpDir      = vec4 ( 0, 1, 0, 0 );
    vec4    topMiddle      = pos + quadUpDir * ( 0.3 + 0.4 * ns1.z );
    vec4    sideDir1       = vec4 ( 1, 0, 0, 0 );
    vec4    sideDir2       = vec4 ( 0, 0, 1, 0 );

    createQuad ( pos, topMiddle, 0.08 * quadSize.x * sideDir1, colorVariation, opacity );
    createQuad ( pos, topMiddle, 0.08 * quadSize.y * sideDir2, colorVariation, opacity );
}

Фрагментный шейдер крайне прост - он использует специальную текстуру и переданное случайное значение для определения оттенка текущего фрагмента.

#version 330 core

uniform sampler2D precipitationMap;

in vec4 texOut;

out vec4  color;

const vec3 colorVariation = vec3 ( 1.0 );

vec3 saturate ( in vec3 c )
{
    return clamp ( c, 0, 1 );
}

void main(void)
{
    color = texture ( precipitationMap, texOut.xy ) * texOut.z;

        // add hue variation
    float   amount       = texOut.w;
    vec3    shiftedColor = mix ( color.rgb, colorVariation, amount );
    float   maxBase      = max ( color.r, max(color.g, color.b) );
    float   newMaxBase   = max ( shiftedColor.r, max(shiftedColor.g, shiftedColor.b) );

        // preserve vibrance
    color.rgb = saturate ( shiftedColor * ((maxBase/newMaxBase) * 0.5 + 0.5 ) );

            // apply opacity
        color.a *= texOut.z;
}

Рис. Получившийся дождь

По этой ссылке можно скачать весь исходный код к этой статье.