GameTech/URP 渲染实战

案例 26.8CRT 显示器进阶

目标:模拟老式显像管显示器:屏幕鼓起的弯曲、扫描线、荧光粉 RGB 栅格、四角暗、电子枪轻微错位、亮度闪烁。

原理:所有效果按“先几何、再采样、后逐像素调制”的顺序放在一个 Pass 里:

  1. 弯曲:把 UV 变到 [−1, 1],按到中心距离的平方向外推:p′=p(1+k|p|2/2),等价于一个简单的桶形畸变的反向映射。推到屏幕外的部分用圆角矩形 SDF(第 4 章)画成黑色边框。
  2. 错位采样:R、B 两个通道左右错开 1 个像素采样。
  3. 扫描线:用弯曲后的 v 坐标乘以行数做余弦波,扫描线跟着屏幕一起弯。
  4. 荧光粉栅格:按屏幕像素的 x 坐标每 3 列一组,分别偏红、偏绿、偏蓝,再整体乘 1.6 补偿亮度损失。
  5. 闪烁:亮度随 _Time 小幅波动;截图时 _Time 接近 0,所以图中看不到。
Assets/Cookbook/Ch26/Crt.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target
{
    UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
    float2 uv0 = input.texcoord;

    // 1) 弯曲:把 [-1,1] 坐标按到中心距离的平方向外推,模拟鼓起的玻璃屏
    float2 p = uv0 * 2.0 - 1.0;
    p *= 1.0 + _CrtParams0.y * dot(p, p) * 0.5;
    float2 uv = p * 0.5 + 0.5;

    // 屏幕外(被弯曲推出去的部分)是黑色边框;用 SDF 圆角矩形做柔和的边
    float2 q = abs(p) - (1.0 - 0.06);
    float box = length(max(q, 0.0)) - 0.06;
    half inside = 1.0 - smoothstep(-0.005, 0.005, box);

    // 2) 采样:三个通道错开约 1 像素,像电子枪没对准
    float2 px = float2(1.0 / _ScreenParams.x, 0);
    half3 c;
    c.r = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv + px).r;
    c.g = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv).g;
    c.b = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv - px).b;

    // 3) 扫描线:每行中间亮、行间暗;用弯曲后的 uv,扫描线也跟着弯
    float row = uv.y * _CrtParams0.z;
    half scan = 0.5 + 0.5 * cos(row * TWO_PI);
    c *= lerp(1.0, 0.35 + 0.65 * scan, _CrtParams0.w);

    // 4) 荧光粉栅格:屏幕像素 x 坐标每 3 列一组,分别偏 R、G、B
    int col = (int)fmod(input.texcoord.x * _ScreenParams.x, 3.0);
    half3 mask = col == 0 ? half3(1, 0.4, 0.4) : (col == 1 ? half3(0.4, 1, 0.4) : half3(0.4, 0.4, 1));
    c *= lerp(1.0, mask * 1.6, _CrtParams1.x);

    // 5) 边缘暗角 + 闪烁(_Time 驱动,截图时 _Time≈0)
    half vignette = saturate(1.0 - dot(p, p) * 0.25);
    half flick = 1.0 - _CrtParams1.y * (0.5 + 0.5 * sin(_Time.y * 110.0));
    c *= vignette * flick * inside;

    // 6) 总强度:和原图插值,方便做“开机/切换”过渡
    half3 original = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv0).rgb;
    return half4(lerp(original, c, _CrtParams0.x), 1.0);
}
vec4 Frag()
{
    vec2 uv0 = texcoord;

    // 1) 弯曲:把 [-1,1] 坐标按到中心距离的平方向外推,模拟鼓起的玻璃屏
    vec2 p = uv0 * 2.0 - 1.0;
    p *= 1.0 + _CrtParams0.y * dot(p, p) * 0.5;
    vec2 uv = p * 0.5 + 0.5;

    // 屏幕外(被弯曲推出去的部分)是黑色边框;用 SDF 圆角矩形做柔和的边
    vec2 q = abs(p) - (1.0 - 0.06);
    float box = length(max(q, 0.0)) - 0.06;
    float inside = 1.0 - smoothstep(-0.005, 0.005, box);

    // 2) 采样:三个通道错开约 1 像素,像电子枪没对准
    vec2 px = vec2(1.0 / _ScreenParams.x, 0.0);
    vec3 c;
    c.r = texture(_BlitTexture, uv + px).r;
    c.g = texture(_BlitTexture, uv).g;
    c.b = texture(_BlitTexture, uv - px).b;

    // 3) 扫描线:每行中间亮、行间暗;用弯曲后的 uv,扫描线也跟着弯
    float row = uv.y * _CrtParams0.z;
    float scan = 0.5 + 0.5 * cos(row * TWO_PI);
    c *= mix(1.0, 0.35 + 0.65 * scan, _CrtParams0.w);

    // 4) 荧光粉栅格:屏幕像素 x 坐标每 3 列一组,分别偏 R、G、B
    int col = int(mod(texcoord.x * _ScreenParams.x, 3.0));
    vec3 mask = col == 0 ? vec3(1, 0.4, 0.4) : (col == 1 ? vec3(0.4, 1, 0.4) : vec3(0.4, 0.4, 1));
    c *= mix(vec3(1.0), mask * 1.6, _CrtParams1.x);

    // 5) 边缘暗角 + 闪烁(_Time 驱动,截图时 _Time≈0)
    float vignette = saturate(1.0 - dot(p, p) * 0.25);
    float flick = 1.0 - _CrtParams1.y * (0.5 + 0.5 * sin(_Time.y * 110.0));
    c *= vignette * flick * inside;

    // 6) 总强度:和原图插值,方便做“开机/切换”过渡
    vec3 original = texture(_BlitTexture, uv0).rgb;
    return vec4(mix(original, c, _CrtParams0.x), 1.0);
}
图 26-15左:原图;右:curvature = 0.18、160 行扫描线、栅格强度 0.35。注意扫描线和画面内容一起被弯曲

扫描线和栅格都是“逐屏幕像素”的高频图案,分辨率变化时观感差别很大:在 4K 屏上 3 像素一组的栅格几乎看不见,在 720p 上又可能太粗。实际项目里可以把栅格周期和扫描线行数与屏幕高度挂钩(例如行数 = 屏幕高度 / 3),或者提供画质档位。这个效果适合放在 AfterRenderingPostProcessing:它模拟的是“显示设备”,应该作用在最终颜色上。