URP 渲染实战 / 第 26 章 自定义后处理 / 案例 26.7
案例 26.7像素化与调色板抖动入门
目标:复古像素风:画面由大块的“像素”组成,颜色只有少数几级,并用有序抖动(Ordered Dithering)模拟中间色。
原理:把 UV 吸附到“大像素”格子的中心,用点采样取色——同一格里所有屏幕像素读到同一个颜色。色阶量化在 sRGB 空间进行,因为人眼对暗部更敏感,在线性空间均匀分级会让暗部台阶过粗。抖动用 4×4 Bayer 矩阵:给每个大像素一个 [−0.5, 0.5) 的阈值偏移再四舍五入,相邻格子在两个色阶之间交替,远看就是中间色:
Assets/Cookbook/Ch26/Pixelate.shader(节选:frag)
half4 Frag(Varyings input) : SV_Target { UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input); float2 screen = _ScreenParams.xy; float size = _PixelateParams.x; // 1) 当前像素属于哪个“大像素”,取它中心的颜色(点采样,避免边缘被双线性混色) float2 block = floor(input.texcoord * screen / size); float2 uv = (block + 0.5) * size / screen; half3 c = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_PointClamp, uv).rgb; // 2) 色阶量化:在 sRGB 空间均匀分级(线性空间分级会让暗部台阶过粗) float levels = _PixelateParams.y; if (levels > 0.5) { half3 s = LinearToSRGB(saturate(c)); float threshold = (Bayer4((uint2)block) - 0.5) * _PixelateParams.z; // 以大像素为单位抖动 s = floor(s * (levels - 1.0) + 0.5 + threshold) / (levels - 1.0); c = SRGBToLinear(saturate(s)); } return half4(c, 1.0); }
vec4 Frag() { vec2 screen = _ScreenParams.xy; float size = _PixelateParams.x; // 1) 当前像素属于哪个“大像素”,取它中心的颜色(点采样,避免边缘被双线性混色) vec2 block = floor(texcoord * screen / size); vec2 uv = (block + 0.5) * size / screen; // GLSL 不能按调用指定 sampler_PointClamp:用 texelFetch 按整数纹素坐标取 ivec2 texSize = textureSize(_BlitTexture, 0); vec3 c = texelFetch(_BlitTexture, clamp(ivec2(uv * vec2(texSize)), ivec2(0), texSize - 1), 0).rgb; // 2) 色阶量化:在 sRGB 空间均匀分级(线性空间分级会让暗部台阶过粗) float levels = _PixelateParams.y; if (levels > 0.5) { vec3 s = LinearToSRGB(saturate(c)); float threshold = (Bayer4(uvec2(block)) - 0.5) * _PixelateParams.z; // 以大像素为单位抖动 s = floor(s * (levels - 1.0) + 0.5 + threshold) / (levels - 1.0); c = SRGBToLinear(saturate(s)); } return vec4(c, 1.0); }

提示抖动的坐标用的是“大像素”坐标
block,而不是屏幕像素坐标——否则一个大像素内部会出现细碎的网点,破坏像素风。另一个常见需求是“像素不随相机平移而游动”:屏幕空间像素化在相机移动时,格子相对于场景在滑动,会显得闪烁。真正的像素风游戏通常是让相机以低分辨率渲染到 RT、并把相机位置吸附到像素网格上,再整数倍放大显示,那属于相机层面的方案。