steps3D - Tutorials - Skin Rendering

Рендеринг кожи

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

Причина этого явления довольно проста и называется подповерхностным рассеиванием (subsurface scattering). В соответствии с принятым в PBR подходом мы считаем, что падающий на поверхность свет частично отражается и частично преломляется (рис 1).

Рис 1. Отражение и преломление падающего света

Отраженный свет отвечает за блики. Если материал не является металлом (а кожа им не является) , то блики имеют цвет падающего света (обычно белый). Коэффициент Френеля (Fresnel) F определяет долю отраженного света. Оставшаяся часть света (1-F) преломляется и уходит внутрь материала. Там этот свет претерпевает многочисленные переотражения внутри материала и в конце концов его часть выходит наружу.

Ранее мы считали, что в материале имеет место сильное поглощение света. В результате этого можно считать что свет выходит наружу там же, где и вошел. Этот выходящий наружу свет и формирует диффузную часть освещения. Однако в случае кожи (а также ряда других материалов) подобное допущение неверно - посмотрите на рис 2 - там хорошо видно насколько далеко свет может распространяться в теле человека.

Рис 2.Распространение света в теле человека.

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

Рис3. BRDF и BSSRDF

При этом на самом деле кожа устроена довольно сложно - принято считать что она состоит из трех отдельных слоев - тонкий слой жира, epidermis и dermis.

Рис 4. Строение кожи

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

Рис 5. Профиль поглощения света

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

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

Несколько позже было показано, что можно заменить довольно сложное двухмерное размытие в пространстве текстуры на похожее размытие в пространстве экрана (screen-space blur). Для того, чтобы учитывать реальную геометрию в этот размытие необходимо учитывать значения из буфера глубины. Строго говоря такое размытие не является сепарабельным, но было показано что можно можно приблизить двумя одномерными размытиями в пространстве экрана.

Были подобраны коэффициенты для этих одномерных размытий. В результате два прохода размытия (по x и по y)по очереди применяются к содержащему только диффузную освещенность каналу освещения (бликовая часть формируется отражением света и не подвержена подповерхностному рассеиванию). После этого к размытой диффузной части добавляется бликовая.

Ниже приводится фрагментный шейдер, выполняющий один проход этого размытия. Направление размытия (x или y) задается при помощи двухмерного вектора step( (1,0) для размытия по x и (0,1) для размытия по y). Обратите внимание, что на вход шейдер принимает сразу три текстуры - диффузную (colorMap, в альфа-канале хранится 0 или 1 в зависимости от того кожа это или нет), глубины (depthMap) и текстуру, содержащую бликовую компоненту освещения. Обратите внимание на гамма-преобразование после добавления бликовой части.

#version 330 core


in  vec2    tex;
out vec4    color;

uniform vec2        step;   
uniform float       blurScale;
uniform float       correction;
uniform int         mode;

uniform sampler2D   colorMap;
uniform sampler2D   depthMap;
uniform sampler2D   specMap;

const float zNear     = 0.1;
const float zFar      = 100.0;
const float SSSS_FOVY = 90.0;

float linearDepth ( float d )
{
    return zFar * zNear / (d * (zFar - zNear) - zFar );
}

void    main ()
{
    const float gamma = 2.2;
    
        // Gaussian weights for the six samples around current pixel
    const float w [6] = float [6] ( 0.006, 0.061, 0.242, 0.242, 0.061, 0.006 );
    const float o [6] = float [6] ( -1.0, -0.6667, -0.3333, 0.3333, 0.6667, 1.0 );

    const vec4 kernel [] = vec4 [] (
        vec4 ( 0.560479,   0.669086,    0.784728,      0    ),
        vec4 ( 0.00471691, 0.000184771, 5.07566e-005, -2    ),
        vec4 ( 0.0192831,  0.00282018,  0.00084214,   -1.28 ),
        vec4 ( 0.03639,    0.0130999,   0.00643685,   -0.72 ),
        vec4 ( 0.0821904,  0.0358608,   0.0209261,    -0.32 ),
        vec4 ( 0.0771802,  0.113491,    0.0793803,    -0.08 ),
        vec4 ( 0.0771802,  0.113491,    0.0793803,     0.08 ),
        vec4 ( 0.0821904,  0.0358608,   0.0209261,     0.32 ),
        vec4 ( 0.03639,    0.0130999,   0.00643685,    0.72 ),
        vec4 ( 0.0192831,  0.00282018,  0.00084214,    1.28 ),
        vec4 ( 0.00471691, 0.000184771, 5.07565e-005,  2    )
    );
        // fetch color and linear depth for current pixel
    vec4    colorM = texture ( colorMap, tex );
    float   depthM = linearDepth ( texture ( depthMap, tex ).r );
    vec2    size   = vec2 ( textureSize ( colorMap, 0 ) );

    if ( colorM.a < 0.5 )       // check whether it's skin
        discard;
    
        // accumulate center sample multiplying it with gaussian weight
    vec4    colorBlurred               = colorM;
    float   distanceToProjectionWindow = 1.0 / tan(0.5 * radians(SSSS_FOVY));
    float   scale                      = distanceToProjectionWindow / depthM;

        // Calculate the final step to fetch the surrounding pixels:
    vec2 finalStep = blurScale * scale * step / size;

    finalStep *= colorM.a;      // Modulate it using the alpha channel.
    finalStep *= 1.0 / 3.0;     // Divide by 3 as the kernels range from -3 to 3.

        // Accumulate the center sample:
    colorBlurred.rgb *= kernel[0].rgb;

    for ( int i = 1; i < 11; i++ )
    {
            // Fetch color and depth for current sample:
        vec2 offset = tex + kernel[i].a * finalStep;
        vec4 color  = texture ( colorMap, offset );

            // If the difference in depth is huge, we lerp color back to "colorM":
        float depth = linearDepth ( texture ( depthMap, offset).r );
        float s     = min ( 1.0, distanceToProjectionWindow * blurScale * abs ( depthM - depth) / correction );
        
        color.rgb = mix ( color.rgb, colorM.rgb, s );

            // Accumulate:
        colorBlurred.rgb += kernel[i].rgb * color.rgb;
    }

    color = colorBlurred;
    
    if ( step.y > step.x )      // for second pass step=(0,1)
    {
        if ( mode == 3 )        // combined mode
            color.rgb += texture ( specMap, tex*vec2 ( 1, -1 ) ).rgb;
            
        color.rgb = pow ( color.rgb, vec3 ( 1.0 / gamma ) );    
    }
}

С бликовой компонентой освещения тоже все не очень просто - было показано что лучше его моделировать суммой двух кривых с различными параметрами неровности. При этом используется распределение Фонга:

В качестве геометрической функции G используется модель Kelemen-Szirmag-Kalos:

Степень для формулы Фонга по значению неровности g находится следующим образом:

Степень для первой части блика берется из карты неровности и переводится по приведенной формуле. Степень для второй части вычисляются как квадрат степени первой части. Обе части складываются с весами 0.85 и 0.15.

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

#version 330 core

in  vec3 v;
in  vec3 l;
in  vec3 h;
in  vec2 tex;
//out vec4 color;
out vec4 color [2];


uniform sampler2D diffMap;
uniform sampler2D specMap;
uniform sampler2D scatterMap;
uniform sampler2D bumpMap;

const float gamma         = 2.2;
const float FDiel         = 0.04;       // Fresnel for dielectrics
const float pi            = 3.141592;

float phongExponent ( float glossiness )
{
    return (1.0/pow(1.0 - glossiness, 3.5) - 1.0);
}

vec3 Fresnel ( in vec3 f0, in float product )
{
    return mix ( f0, vec3 (1.0), pow(1.0 - product, 5.0) );
}

float Fresnel ( in float f0, in float product )
{
    return mix ( f0, 1.0, pow(1.0 - product, 5.0) );
}

    // Kelem-Szirmay-Kalos visibility
float   KelemVis ( in float nl, in float nv, in float a )
{
    return 1.0 / ((nl*(1.0-a) + a)*nv*(1.0-a) + a);
}

float   D_Phong ( in float nh, in float n )
{
    return (n+2.0) * pow ( max ( 0, nh ), n ) / (2.0 * pi);
}

void main(void)
{
    vec3    n        = 2.0 * (texture ( bumpMap, tex ).rgb - vec3 ( 0.5 ));
    vec3    n2       = normalize ( n );
    vec3    l2       = normalize ( l );
    vec3    h2       = normalize ( h );
    vec3    v2       = normalize ( v );
    vec4    clr      = pow ( texture ( diffMap, tex ), vec4 ( gamma ) );
    vec4    diff     = clr * max ( dot ( n2, l2 ), 0.1 );
    float   nh       = max ( dot ( n2, h2 ), 0.0 );
    float   nl       = max ( dot ( n2, l2 ), 0.0 );
    float   nv       = max ( dot ( n2, v2 ), 0.0 );
    float   f         = Fresnel ( FDiel, clamp( dot ( h2, v2 ), 0.0, 1.0 ) );
    float   a         = texture ( specMap, tex ).r;
    float   phongExp1 = pow ( 2.0, 14.0 * a );
    float   phongExp2 = phongExp1 * phongExp1;
    float   spec1    = D_Phong ( nh, phongExp1 ) * f * KelemVis ( nl, nv, a ) * nl;
    float   spec2    = D_Phong ( nh, phongExp2 ) * f * KelemVis ( nl, nv, a ) * nl;

    color [0] = vec4 ( diff.rgb, 1.0 );         // a=1 mark as skin
    color [1] = (0.85*spec1 + 0.15*spec2) * vec4 ( 1.0 );   // * texture ( specMap, tex );
}

Рис 6. Бликовая часть освещения

Рис 7. Диффузная часть освещения

Рис 8. Размытая диффузная часть освещения

Рис 9. Полное освещение

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