1. 为什么3D像素画总是一团糊
1.1 从两个视觉事故说起
先聊两个我踩过的坑。第一个是给一个低多边形角色做像素化渲染,模型面数不高,贴图也是精心画的16x16小图,结果在Unity里一跑,整个角色像蒙了一层雾,像素边缘全是灰蒙蒙的过渡色,完全没有那种硬朗的颗粒感。第二个是做一个复古风格的场景,地面用了像素草地贴图,摄像机稍微拉远一点,草地就变成了一片糊在一起的绿色色块,像素格子完全消失。
这两个问题的根源其实是同一个:纹理采样方式选错了。默认情况下,Unity的纹理导入设置是双线性过滤(Bilinear Filtering),它会自动在相邻像素之间做插值,让画面看起来更平滑。这个设计本来是为了让3D模型表面的贴图看起来更自然,但对于像素画来说,这恰恰是灾难——像素画的美感就来自于每一个像素格子的清晰边界,插值一上来,边界就没了。
1.2 像素画渲染的核心矛盾
3D像素画渲染的本质矛盾在于:3D空间的连续性与像素画的离散性之间的冲突。
传统3D渲染追求的是平滑、真实、无锯齿,所以有了各种抗锯齿技术、各向异性过滤、Mipmap等等。但像素画追求的是颗粒感、硬边缘、低分辨率的复古美学。这两套逻辑放在一起,如果不做特殊处理,默认的渲染管线会按照“平滑优先”的逻辑走,结果就是像素画被“磨皮”了。
具体来说,有三个层面的问题需要解决:
- 纹理采样层面:过滤模式必须改成点采样(Point Sampling),关掉Mipmap或者手动控制Mipmap的偏移。
- 屏幕分辨率层面:渲染分辨率需要降低到像素画的目标分辨率,然后再放大到屏幕尺寸,而不是直接在高分辨率下渲染。
- 着色器层面:需要自定义着色器来处理光照、阴影、边缘检测等,确保光照不会破坏像素的硬边缘。
这三个层面缺一不可。只改纹理过滤,光照一打上去像素边缘还是会被柔化;只降分辨率,纹理采样还是双线性的,像素格子依然模糊。
1.3 本文能帮你解决什么
这篇文章会从纹理导入设置开始,一步步讲到自定义着色器的编写,再到后处理的分辨率控制,把3D像素画渲染的完整链路拆开揉碎讲清楚。不管你是刚接触Unity的新手,还是已经做过几个项目但总觉得像素效果“差一口气”的老手,都能从里面找到可以直接抄作业的配置和代码。
我用的Unity版本是2022.3 LTS,URP管线。如果你用的是内置管线或者HDRP,核心思路是一样的,但具体的API和节点会有差异,我会在关键地方标注出来。
2. 纹理导入与采样:像素清晰的第一道关卡
2.1 纹理导入设置的正确姿势
很多人拿到像素贴图,直接拖进Unity就不管了,这是最大的坑。Unity的默认导入设置是给普通3D模型用的,对像素画完全不友好。你需要手动改这几个地方:
选中你的像素贴图,在Inspector面板里找到以下设置:
- Filter Mode:改成Point (no filter)。这是最关键的一步。双线性过滤会在采样时取周围4个像素做加权平均,点采样则直接取最近的像素,不做任何插值。像素画的硬边缘就靠这个。
- Compression:改成None。压缩算法(尤其是DXT系列)会在像素块之间做有损压缩,像素画本来颜色就少、边缘就硬,一压缩就会出现色块和噪点。虽然不压缩会占更多内存,但像素画贴图通常很小,这点开销完全可以接受。
- Mip Maps:关掉,或者至少把Mip Map Filtering改成Point。Mipmap是为了解决远处纹理闪烁问题的,它会生成一系列逐级缩小的纹理副本。但像素画在远处应该保持像素格子的清晰度,Mipmap一开,远处就糊了。如果你确实需要Mipmap来解决闪烁,那就把过滤模式改成Point,并且手动控制Mipmap的偏移。
- Max Size:根据你的像素画实际尺寸设置,不要让它自动放大。比如你的贴图是64x64,就把Max Size设成64,避免Unity把它拉伸到更大的尺寸再压缩。
- Non-Power of 2:改成None。像素画经常不是2的幂次尺寸,比如48x48、96x96,强制缩放会破坏像素的精确对齐。
注意:如果你用的是Sprite Renderer而不是Mesh Renderer,纹理设置里的Filter Mode和Compression同样适用,但Sprite的Pixels Per Unit参数也需要调整,确保一个像素对应屏幕上的整数个像素。
2.2 点采样在着色器里的实现
纹理导入设置改好了,但如果你的着色器里用的是tex2D,在某些平台上它仍然可能走双线性过滤。为了确保万无一失,最好在着色器里显式声明采样状态。
在ShaderLab里,你可以这样写:
SamplerState point_clamp_sampler { Filter = MIN_MAG_MIP_POINT; AddressU = Clamp; AddressV = Clamp; }; Texture2D _MainTex; SamplerState sampler_MainTex; // 在片元着色器里 half4 color = _MainTex.Sample(point_clamp_sampler, uv);如果你用的是Shader Graph,那就更简单了:在Sample Texture 2D节点里,把Filter改成Point,Wrap改成Clamp。Shader Graph会自动生成对应的采样状态。
这里解释一下为什么用Clamp而不是Repeat。像素画的UV通常是在0到1之间的,但如果因为浮点误差导致UV稍微超出这个范围,Repeat模式会让它从另一边绕回来,产生一条错误的像素线。Clamp模式则会把超出部分钳制在边缘像素上,不会出现这种问题。
2.3 像素对齐与UV精度问题
即使采样方式对了,还有一个隐蔽的坑:UV精度。在3D模型上,UV坐标是浮点数,而像素画是离散的。如果UV坐标没有精确对齐到像素中心,采样时就会取到相邻像素的混合值,导致边缘出现杂色。
解决方法是:在片元着色器里,把UV坐标量化到像素网格上。假设你的贴图是64x64,那么每个像素的UV宽度是1/64。你可以这样量化:
float2 pixelUV = floor(uv * _TexSize) / _TexSize + 0.5 / _TexSize;这行代码的意思是:先把UV乘以贴图尺寸,取整,再除以贴图尺寸,最后加上半个像素的偏移,确保采样点落在像素中心而不是边界上。_TexSize是贴图的宽高,可以通过脚本传入,也可以在着色器里硬编码。
这个技巧在模型面数较低、UV展开不够精确的时候特别有用。我做过一个测试:同一个模型,不做UV量化的时候,像素边缘有大约30%的采样点会取到混合色;做了量化之后,这个比例降到了接近0。
2.4 纹理图集的特殊处理
如果你的像素画用了图集(Atlas),比如把多个角色的贴图打包到一张大图上,那还需要注意图集边缘的padding问题。因为点采样在UV边界处可能会取到相邻图块的像素,所以图集里每个图块之间至少要留1个像素的透明边距。
另外,图集的Filter Mode和Compression设置要统一。如果图集里混了不同过滤模式的贴图,Unity会以最宽松的设置为准,导致部分贴图被意外平滑。
3. 分辨率控制:让像素格子真正“大”起来
3.1 为什么不能直接在高分辨率下渲染
很多人以为只要纹理采样改成点采样,像素画就清晰了。但实际上,如果你的渲染分辨率是1920x1080,而像素画的目标分辨率是320x180,那么每个像素格子只占屏幕上的几个物理像素,看起来还是很小很密,没有那种“大颗粒”的复古感。
真正的像素画渲染,需要把整个画面渲染到一个低分辨率的Render Texture上,然后再用点采样放大到屏幕尺寸。这样每个像素格子就会占据屏幕上的多个物理像素,颗粒感就出来了。
这个思路和很多复古游戏的做法是一致的。比如《铲子骑士》的原生分辨率是400x240,然后放大到1080p;《星露谷物语》是像素画风格,但它的渲染分辨率会根据缩放级别动态调整。
3.2 在URP里配置低分辨率渲染
在URP里,你可以通过以下步骤实现低分辨率渲染:
- 创建一个Render Texture资产,尺寸设成你的目标分辨率,比如320x180。Filter Mode改成Point,Color Format用R8G8B8A8_UNorm(不需要HDR,像素画通常不需要高动态范围)。
- 在URP Asset里,找到Render Scale参数,把它设成目标分辨率与屏幕分辨率的比值。比如屏幕是1920x1080,目标是320x180,那Render Scale就是320/1920 ≈ 0.1667。但URP的Render Scale是作用于整个渲染管线的,包括UI和后期处理,所以更推荐用Render Texture的方式。
- 创建一个Camera,把它设置为只渲染到那个Render Texture。然后创建第二个Camera,用全屏的Raw Image或者Blit把Render Texture显示到屏幕上。
如果你用的是内置管线,那就更直接了:主Camera的Target Texture设成低分辨率RT,然后用一个全屏的Quad或者GUI.DrawTexture把它画出来。
3.3 分辨率选择的计算公式
目标分辨率怎么选?这里有一个简单的公式:
目标分辨率 = 屏幕分辨率 / 缩放倍数
缩放倍数必须是整数,这样才能保证每个像素格子对应屏幕上的整数个物理像素,不会出现像素大小不一致的情况。比如:
| 屏幕分辨率 | 缩放倍数 | 目标分辨率 | 适用场景 |
|---|---|---|---|
| 1920x1080 | 6 | 320x180 | 16:9宽屏,经典复古比例 |
| 1920x1080 | 4 | 480x270 | 稍高细节,适合现代像素画 |
| 2560x1440 | 8 | 320x180 | 2K屏幕,保持相同颗粒感 |
| 3840x2160 | 12 | 320x180 | 4K屏幕,颗粒感一致 |
注意,目标分辨率不一定要严格等于屏幕分辨率除以整数。你可以选择一个接近的值,然后在显示的时候做黑边处理(Letterbox),保持宽高比。比如320x180是16:9,和1920x1080的宽高比一致,直接放大6倍就是1920x1080,完美填满。
但如果你的目标分辨率是320x200(16:10),在16:9的屏幕上就会有多余的空间,这时候要么加黑边,要么拉伸(但拉伸会破坏像素的方形比例,不推荐)。
3.4 像素完美显示的整数缩放
整数缩放是像素画渲染的铁律。如果缩放倍数不是整数,比如1920/320=6是整数,没问题;但如果屏幕是1366x768,1366/320=4.27,不是整数,那就会出现有些像素格子占4个物理像素,有些占5个,画面看起来就不均匀。
解决方法是:要么调整目标分辨率,让缩放倍数变成整数;要么在显示的时候加黑边,只使用屏幕的一部分。比如1366x768的屏幕,可以用320x180的目标分辨率,缩放4倍后是1280x720,剩下的86x48像素用黑边填充。
在Unity里实现黑边很简单:把显示Render Texture的Raw Image的Aspect Ratio Fitter组件加上,设成Fit In Parent,然后调整RectTransform的尺寸,让它保持目标宽高比。
4. 着色器编写:光照与像素的和平共处
4.1 像素画需要什么样的光照
传统3D光照是连续的,法线插值会让表面看起来平滑。但像素画的光照应该是离散的,光照变化应该以像素为单位跳变,而不是渐变。
举个例子:一个球体在传统光照下,从亮到暗是平滑过渡的;但在像素画风格下,应该只有几个固定的亮度层级,比如亮、中亮、中暗、暗,每个层级之间的边界是硬切的。
实现这种效果有两种思路:
- 思路一:在片元着色器里量化光照结果。先按正常方式计算光照,然后把结果量化到有限的几个层级。
- 思路二:用ramp贴图。把光照强度映射到一张1D的渐变贴图上,贴图本身是像素化的,只有几个色阶。
思路二更灵活,美术可以在ramp贴图上自由调整色阶的数量和颜色。思路一更简单,适合快速原型。
4.2 光照量化的着色器实现
下面是一个简单的光照量化片元着色器示例,基于URP的Shader Graph或者手写HLSL都可以:
half4 frag(Varyings input) : SV_Target { // 采样基础贴图,点采样 half4 baseColor = _MainTex.Sample(point_clamp_sampler, input.uv); // 计算光照 half3 normal = normalize(input.normalWS); half3 lightDir = normalize(_MainLightDirection); half NdotL = dot(normal, lightDir); // 量化光照到4个层级 half quantizedLight = floor(NdotL * 4) / 4; // 应用光照 half3 finalColor = baseColor.rgb * (quantizedLight * 0.8 + 0.2); return half4(finalColor, baseColor.a); }这里的floor(NdotL * 4) / 4把连续的NdotL值量化到了0、0.25、0.5、0.75、1.0这五个层级。你可以根据需要调整层级数量,层级越少,像素感越强。
注意:量化光照会导致阴影边缘出现明显的阶梯,这是预期的效果。但如果阶梯太明显,可以在量化之前加一点抖动(Dithering),让过渡稍微自然一些,同时保持像素感。
4.3 阴影的像素化处理
阴影是另一个容易破坏像素感的地方。Unity的实时阴影默认是软阴影,边缘有渐变。对于像素画,你需要:
- 把阴影贴图的分辨率调低,比如256x256或者512x512。
- 把阴影的Filter Mode改成Point。
- 在着色器里对阴影衰减做量化处理。
在URP里,你可以在Light的Inspector里找到Shadow Type,改成Hard Shadows。然后在URP Asset的Shadows设置里,把Shadow Resolution调低,Shadow Cascades关掉或者只留一级。
如果阴影还是太软,可以在着色器里对阴影衰减值做和光照一样的量化:
half shadowAtten = MainLightRealtimeShadow(shadowCoord); shadowAtten = floor(shadowAtten * 2) / 2; // 量化到0和0.5两个层级这样阴影就只有“全亮”和“半暗”两种状态,边缘是硬切的,符合像素画的风格。
4.4 边缘检测与描边
像素画经常需要描边来增强轮廓。在3D模型上做描边,常用的方法是背面法(Inverted Hull)或者屏幕空间边缘检测。
背面法是把模型复制一份,沿法线方向外扩一点,然后用纯色渲染,只显示背面。这样在模型边缘就会形成一圈描边。这个方法的优点是性能好,缺点是对于硬边模型(比如立方体),描边会在棱角处断开。
屏幕空间边缘检测是在后处理阶段,对深度图和法线图做Sobel算子,检测出边缘像素,然后叠加描边颜色。这个方法的优点是描边连续,缺点是性能开销大,而且对于像素画来说,边缘检测的精度需要和低分辨率匹配,否则描边会太细或者太粗。
我个人的经验是:对于角色和重要物体,用背面法;对于场景整体,用屏幕空间边缘检测,但把检测的分辨率降到和渲染分辨率一致,这样描边也是像素化的。
5. 后处理与显示:最后一步的像素完美
5.1 后处理链的配置
低分辨率渲染之后,后处理也需要相应调整。如果你用的是URP的Volume后处理,注意以下几点:
- Bloom:像素画通常不需要Bloom,或者只需要非常轻微的Bloom。Bloom会让亮部扩散,破坏像素的硬边缘。如果一定要用,把Threshold调高,Intensity调低。
- Anti-aliasing:关掉。MSAA、FXAA、TAA都会平滑边缘,和像素画的目标相反。
- Color Grading:可以用,但注意不要引入太多的颜色渐变。像素画的颜色通常是有限的调色板,Color Grading可能会引入中间色。
- Film Grain:可以加一点,增强复古感,但强度要低,否则会掩盖像素细节。
5.2 显示阶段的点采样放大
Render Texture显示到屏幕上的时候,必须用点采样。如果你用的是Raw Image,把它的Texture的Filter Mode设成Point。如果你用的是Blit命令,确保Blit的采样状态是Point。
在URP里,如果你用Full Screen Pass Renderer Feature来做最终显示,可以在Shader里显式声明Point采样:
SamplerState point_clamp_sampler; half4 frag(Varyings input) : SV_Target { return _BlitTexture.Sample(point_clamp_sampler, input.uv); }5.3 像素完美显示的常见问题
问题一:画面出现摩尔纹。这通常是因为低分辨率RT的尺寸和屏幕尺寸不成整数倍关系,导致放大时像素格子大小不一致。解决方法是调整RT尺寸或者加黑边。
问题二:UI也被像素化了。如果你把UI也渲染到低分辨率RT上,UI文字会变得模糊。解决方法是在低分辨率渲染之后,再用一个单独的Camera渲染UI,UI的分辨率保持和屏幕一致。
问题三:画面边缘有黑边或者裁切。这通常是Aspect Ratio不匹配导致的。检查你的RT宽高比和屏幕宽高比是否一致,如果不一致,用Aspect Ratio Fitter或者手动计算黑边尺寸。
问题四:像素格子不是正方形。这通常是因为RT的宽高比和屏幕宽高比不一致,导致放大时X轴和Y轴的缩放倍数不同。解决方法是保持RT和屏幕的宽高比一致,或者用非均匀缩放(但会破坏像素的方形比例)。
6. 常见问题与排查技巧实录
6.1 像素边缘出现杂色怎么办
杂色通常来自三个地方:纹理过滤、UV精度、压缩。排查顺序如下:
- 检查纹理的Filter Mode是不是Point,Compression是不是None。
- 在着色器里加UV量化,确保采样点落在像素中心。
- 检查UV展开是否有重叠或者超出0-1范围。
- 如果用了图集,检查图块之间是否有足够的padding。
我遇到过一次杂色问题,排查了半天才发现是图集的padding只有0.5个像素,点采样在边界处取到了相邻图块的颜色。把padding改成1个像素就解决了。
6.2 光照导致像素边缘模糊
如果光照计算里用了法线插值,即使纹理是点采样,光照的渐变也会让像素边缘看起来模糊。解决方法是量化光照结果,或者用法线贴图的时候也用点采样。
另外,如果你用了Normal Map,注意Normal Map的Filter Mode也要设成Point。Normal Map的插值会影响光照方向,进而影响光照结果的连续性。
6.3 低分辨率渲染后画面太暗或太亮
低分辨率渲染不会改变颜色值,但如果你的后处理链里有依赖分辨率的操作(比如Bloom的模糊半径),可能会受到影响。检查后处理的参数,确保它们不依赖于屏幕分辨率。
另外,如果你用了HDR,低分辨率RT的Color Format需要支持HDR,否则颜色会被截断。像素画通常不需要HDR,所以用LDR格式就好。
6.4 性能优化建议
像素画渲染的性能开销主要来自低分辨率RT的创建和Blit操作。优化建议:
- RT的尺寸尽量小,320x180已经足够,不需要更大。
- 如果场景简单,可以直接在主Camera上设置Render Scale,避免额外的RT和Blit。
- 后处理尽量精简,Bloom和边缘检测是性能大户,能关就关。
- 如果用了屏幕空间边缘检测,把检测的分辨率降到和渲染分辨率一致,不要在高分辨率下检测再降采样。
6.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 像素边缘模糊 | 纹理过滤是双线性 | 改成Point |
| 远处像素糊成一片 | Mipmap开启 | 关掉Mipmap或改成Point过滤 |
| 像素格子大小不一致 | 缩放倍数不是整数 | 调整RT尺寸或加黑边 |
| 光照渐变破坏像素感 | 光照未量化 | 在着色器里量化光照结果 |
| 阴影边缘太软 | 软阴影 | 改成Hard Shadows,降低阴影分辨率 |
| UI文字模糊 | UI也被低分辨率渲染 | UI用单独Camera渲染 |
| 画面有摩尔纹 | RT和屏幕尺寸不匹配 | 调整RT尺寸或加黑边 |
| 像素边缘有杂色 | UV精度不够或图集padding不足 | UV量化,增加图集padding |
7. 我的实操心得与踩坑记录
7.1 不要迷信“一键像素化”插件
市面上有一些号称“一键像素化”的Unity插件,我试过几个,效果都不太理想。它们通常只是把纹理过滤改成Point,或者加一个低分辨率RT,但光照、阴影、后处理这些环节没有配套处理,结果就是纹理清晰了,但光照一打上去又糊了。
真正要做好3D像素画渲染,还是得自己控制每一个环节。好在Unity的URP已经足够灵活,手写着色器和配置后处理链并不复杂。
7.2 分辨率的选择要趁早
我在一个项目里做到一半才决定改分辨率,结果所有的UI布局、摄像机参数、后处理设置都要重新调。建议在项目开始之前就确定好目标分辨率,然后所有的美术资源、UI布局、摄像机FOV都按照这个分辨率来设计。
7.3 测试要在目标分辨率下进行
很多人开发的时候用高分辨率预览,觉得效果不错,一发布到低分辨率设备上就发现像素感太强或者太弱。建议在开发过程中就经常在目标分辨率下测试,确保视觉效果符合预期。
7.4 像素画不是越低分辨率越好
分辨率太低,像素格子太大,画面会失去细节,看起来像马赛克而不是像素画。分辨率太高,像素格子太小,又失去了复古感。我的经验是:320x180到480x270之间是比较合适的范围,具体取决于你的美术风格和屏幕尺寸。
7.5 光照量化层级的选择
光照量化到几个层级,这个需要根据美术风格来定。层级太多(比如8个),像素感不明显;层级太少(比如2个),画面会太扁平。我一般用4到5个层级,既能保持像素感,又有足够的光影层次。
7.6 后处理要克制
像素画的后处理越少越好。Bloom、景深、运动模糊这些都会破坏像素的硬边缘。如果一定要加,把强度调到最低,并且经常对比开关后处理的效果,确保后处理没有喧宾夺主。
7.7 多平台适配的注意事项
不同平台的屏幕分辨率和宽高比差异很大。手机上有16:9、18:9、20:9等各种比例,主机上主要是16:9。建议用Aspect Ratio Fitter或者手动计算黑边,确保在任何屏幕上都能保持像素的方形比例和整数缩放。
如果实在无法做到整数缩放(比如屏幕分辨率特别奇葩),那就优先保证像素的方形比例,缩放倍数可以不是整数,但X轴和Y轴的缩放倍数必须相同。
7.8 一个实用的调试技巧
在开发过程中,我经常用一个简单的调试方法来检查像素是否完美:在屏幕上画一个1像素宽的网格,然后观察像素画的边缘是否和网格对齐。如果对齐了,说明像素完美;如果没对齐,说明缩放倍数或者UV精度有问题。
这个网格可以用一个简单的全屏着色器实现,或者直接在屏幕上贴一张网格贴图。调试的时候打开,发布的时候关掉。
7.9 关于像素画的审美
最后说一点审美层面的东西。3D像素画不是简单地把3D模型像素化,而是一种独特的视觉风格。它需要在3D的立体感和像素的平面感之间找到平衡。光照、阴影、描边、颜色,每一个元素都要为这个平衡服务。
我见过一些项目,技术上像素完美,但看起来就是不对劲。问题往往出在审美上:光照太复杂、颜色太多、细节太密。像素画的魅力在于“少即是多”,每一个像素都要有存在的理由。
7.10 后续可以扩展的方向
如果你已经掌握了基础的3D像素画渲染,可以尝试以下扩展:
- 动态分辨率:根据场景复杂度动态调整渲染分辨率,在保持像素感的同时优化性能。
- 像素化粒子效果:把粒子系统的渲染也纳入低分辨率管线,确保粒子也是像素化的。
- 像素化UI:把UI也做成像素风格,但保持文字的可读性。
- 多分辨率适配:针对不同屏幕尺寸自动选择最合适的目标分辨率和缩放倍数。
这些方向我都在陆续尝试,后续有机会再单独写文章分享。