文章

URP中实现 VHS Retro Effect

效果演示

VHS风格可以简单概括为上个世纪80年代和90年代初期录像带播放的视觉效果,通常包括颗粒感、色彩失真、扫描线等特征等较为明显的特征,下面是一些符合VHS风格的图片:

本篇博客会尝试实现其中的某些特征效果


Color Bleed

Color Bleed是指颜色信息由于VHS系统中的处理或信号传输问题而渗透到不该出现颜色的区域,进而导致画面中颜色的扩散与边界的模糊。在水平方向上,Color Bleed的问题更为显著。

实现过程

Color Bleed的核心效果是向物体边缘渗透出的模糊边缘色,在这里我们可以使用dual blur框架以减少性能开销,关于dual blur可以查看我的另一篇博客

在降采样过程中,使用在水平与竖直方向上具有不同的偏移值的采样点:

1
2
3
4
5
6
7
float left = -1 - _BlurBias;
float right = 1 - _BlurBias;
float2 blurOffset = _MainTex_TexelSize.xy * float2(1, 0.5);
output.uvs[0] = input.uv + float2(blurOffset.x * left,  -blurOffset.y);
output.uvs[1] = input.uv + float2(blurOffset.x * right, -blurOffset.y);
output.uvs[2] = input.uv + float2(blurOffset.x * left,   blurOffset.y);
output.uvs[3] = input.uv + float2(blurOffset.x * right,  blurOffset.y);

在升采样时,无需做进一步模糊,只需要根据一定权重做常规的alpha混合即可:

1
2
3
4
5
6
half4 BlurUpSampleFragment(Varyings input) : SV_Target
{
    half4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
    color.a = _UpsampleFactor;
    return color;
}

当我们获取到模糊纹理后,就可以根据模糊强度在模糊纹理与场景色之间进行混合。但是,在VHS效果的前提下,我们需要考虑到VHS系统的工作原理。VHS基于亮度与色度分离的信号传输方式,而color bleed现象是色度通道之间的扩散导致的结果。所以我们应该对纹理进行空间转换,然后只混合色度信息,从而得到还原的效果。在这里我们可以使用YCbCr色彩空间,并将其理解为 VHS 系统色彩表示的一种数字化版本。

实现代码

1
2
3
4
5
6
7
sceneColor = RGBToYCbCr(sceneColor);

half3 blurredColor = SAMPLE_TEXTURE2D(_BlurredTexture, sampler_BlurredTexture, input.uv).rgb;
blurredColor = RGBToYCbCr(blurredColor);

sceneColor.rgb = lerp(sceneColor.rgb, blurredColor.rgb, _BleedIntensity);
sceneColor = YCbCrToRGB(sceneColor);

色彩转换的函数为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
half3 RGBToYCbCr(half3 rgb)
{
    return half3(0.0625 + 0.257 * rgb.r + 0.50412 * rgb.g + 0.0979 * rgb.b,
        0.5 - 0.14822 * rgb.r - 0.290 * rgb.g + 0.43921 * rgb.b,
        0.5 + 0.43921 * rgb.r - 0.3678 * rgb.g - 0.07142 * rgb.b);
}

half3 YCbCrToRGB(half3 ycbcr)
{
    ycbcr -= half3(0.0625, 0.5, 0.5);
    return half3(1.164 * ycbcr.x + 1.596 * ycbcr.z,
        1.164 * ycbcr.x - 0.392 * ycbcr.y - 0.813 * ycbcr.z,
        1.164 * ycbcr.x + 2.017 * ycbcr.y);
}

Smear

在VHS系统中,由于模拟信号的延迟,会出现颜色向右“涂抹”的现象

实现思路

在Shader中,我们通过多次采样邻近像素的颜色值,每次采样时使用更大的偏移量,并按指数衰减权重叠加到当前像素上,模拟视觉上的“拖尾”效果。同时为了避免生成的拖影导致图像整体过亮,还需要将累加的颜色值除以累加的总能量。

实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
half4 SmearFragment(Varyings input) : SV_Target
{
    half4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, input.uv);
    float energy = 1;
    const uint SMEAR_LENGTH = 4;
    [unroll]
    for (uint i = 1; i <= SMEAR_LENGTH; i++)
    {
        float falloff = exp(-_Falloff * i);
        energy += falloff;
        float u = input.uv.x - _SmearTextureTexelSize * _Offset * i;
        color += SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, float2(u, input.uv.y)) * falloff;
    }
    return color / energy;
}

Edge Sharpening

VHS系统中,画面中的边缘区域容易亮边与暗边的失真。

实现思路

使用偏移的UV对场景色进行一次采样,这样我们可以得到采样结果相对于原场景色的差值,我们就以此差值作为画面中的亮边与暗边,叠加到原场景色上即可。

实现代码

1
2
3
4
5
float2 offsetUV = input.uv - float2(_EdgeDistance, 0);
half3 offsetColor = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, offsetUV).rgb;

half3 edge = sharpColor - offsetColor;
color += edge * _EdgeIntensity;

Film Grain

颗粒感能够为画面增强“纹理感”与“真实感”,是由胶片的物理化学特性决定的。VHS本身并不依赖胶片,所以film grain在这里指的是画面中的噪点。

实现思路

  • 采样噪声图,与场景色相乘后叠加在场景色上
  • 为噪声添加与亮度相关的遮罩,噪声在亮度大的地方影响较小

亮度响应的效果如下图所示:

实现代码

Film Grain是Unity内置的后处理效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
half3 ApplyGrain(half3 input, float2 uv, TEXTURE2D_PARAM(GrainTexture, GrainSampler), float intensity, float response, float2 scale, float2 offset, float oneOverPaperWhite)
{
    // Grain in range [0;1] with neutral at 0.5
    half grain = SAMPLE_TEXTURE2D(GrainTexture, GrainSampler, uv * scale + offset).w;

    // Remap [-1;1]
    grain = (grain - 0.5) * 2.0;

    // Noisiness response curve based on scene luminance
    float lum = Luminance(input);
    #ifdef HDR_INPUT
    lum *= oneOverPaperWhite;
    #endif
    lum = 1.0 - sqrt(lum);
    lum = lerp(1.0, lum, response);

    return input + input * grain * intensity * lum;
}

Tape Noise

Tape noise指的是画面中出现的噪声条纹,这些条纹通常呈现水平分布,并且会在竖直方向上运动。

实现思路

参考了Vladimir Storm的实现,详见VHS tape noise

tape noise是呈水平分布的,所以我们需要根据UV的Y值来构建噪声,从而得到水平分布的噪声线。在此基础上,为水平噪声线再次引入噪点,让噪声线呈现出不均匀的效果。

实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
half TapeNoise(half2 uv)
{
    half t = _Time.y * _TapeNoiseSpeed;

    // generate line noises
    // --------------------
    uv.y = 1 - uv.y;
    half y = uv.y * _ScreenParams.y;
    half lineNoise = NoiseFromIQ( half3(y * 0.012 +   0 + t, 1, 1) ) *
        NoiseFromIQ( half3(y * 0.091 + 200 + t, 1, 1) ) *
        NoiseFromIQ( half3(y * 0.917 + 421 + t, 1, 1) );

    // generate noise mask
    // -------------------
    half noiseMask =  Hash12(frac(uv + t * half2(0.234, 0.637)));
    noiseMask = noiseMask * noiseMask * noiseMask + 0.3;

    // generate tape noise
    // -------------------
    half tapeNoise = lineNoise * noiseMask;

    // saturate tape noise
    // -------------------
    tapeNoise = step(1 - _TapeNoiseAmount, tapeNoise);

    return tapeNoise * _TapeNoiseAlpha;
}

Vertical Wrapping

在VHS系统中,出于种种原因,有可能会出现图像整体向下偏移,并在屏幕另一端“包裹”显示。

实现思路

思路相对简单,屏幕UV的范围在$[0, 1]$之间,我们只需要将Y轴上的UV进行一定程度偏移,再确保范围限制在$[0, 1]$之间即可。在具体的实现中,有一些细节可以控制:

  • UV偏移的程度
    • 当我们确定了UV偏移的最大范围$t$后,可以在$[0, t]$中根据一个随机数做插值
  • UV发生偏移的频率
    • 使用step函数,当该函数返回1时才会进行插值,进而产生偏移效果

实现代码

1
2
3
4
5
6
7
8
9
10
11
half4 Frag(Varyings input) : SV_Target
{
    float2 uv = input.texcoord;
	
    float offset = lerp(0.0, _MaxOffset, frac(_Time.z) * step(1 - _Frequency, Hash(floor(_Time.z * 3)));
    uv.y = frac(uv.y + offset);
    
    half4 color = SAMPLE_TEXTURE2D(_BlitTexture, sampler_LinearClamp, uv);

    return color;
}

Interlacing

VHS系统可能导致画面边缘出现梳齿状的伪影。

实现思路

思路比较简单,对于UV,我们每隔几行(通过fmod实现)对UV添加水平偏移,从而模拟VHS中隔行扫描的效果。在具体实现中,我们需要进行两次关于屏幕尺寸的映射,以确保UV效果的正确。

实现代码

1
2
3
4
5
6
7
8
9
10
11
12
13
half2 InterlacingOffset(half2 uv)
{
    half offset = uv.y + _Time.y;
    offset *= _ScreenParams.y;
    
    offset = floor(offset);
    offset = fmod(offset, 2.75);
    
    offset *= _InterlacingAmount;
    offset *= rcp(_ScreenParams.x);
    
    return half2(offset, 0);
}

Scanlines

在VHS中,可能出现水平分布的暗色条纹。

实现思路

利用UV的Y轴与三角函数,生成一个符合扫描线特征的遮罩,通过遮罩影响场景色,最后与原场景色做插值即可

实现代码

1
2
3
4
5
half Scanlines(half2 uv)
{
    half scroll = _Time.y * _ScanlineSpeed;
    return sin((uv.y + scroll) * _ScanlineFrequency * 2.0 * 3.1416);
}

代码

项目地址在这里

本文由作者按照 CC BY 4.0 进行授权