URP 渲染实战 / 第 26 章 自定义后处理 / 案例 26.8
案例 26.8CRT 显示器进阶
目标:模拟老式显像管显示器:屏幕鼓起的弯曲、扫描线、荧光粉 RGB 栅格、四角暗、电子枪轻微错位、亮度闪烁。
原理:所有效果按“先几何、再采样、后逐像素调制”的顺序放在一个 Pass 里:
- 弯曲:把 UV 变到 [−1, 1],按到中心距离的平方向外推:,等价于一个简单的桶形畸变的反向映射。推到屏幕外的部分用圆角矩形 SDF(第 4 章)画成黑色边框。
- 错位采样:R、B 两个通道左右错开 1 个像素采样。
- 扫描线:用弯曲后的 v 坐标乘以行数做余弦波,扫描线跟着屏幕一起弯。
- 荧光粉栅格:按屏幕像素的 x 坐标每 3 列一组,分别偏红、偏绿、偏蓝,再整体乘 1.6 补偿亮度损失。
- 闪烁:亮度随
_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); }

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