我不太清楚你那边之前几篇是怎么折腾的,反正我个人的感受是:UE里聊SDF,大多数人第一反应都是Mesh Distance Field那套,也就是把场景几何体变成距离场,做软阴影、AO、碰撞偏移之类。但把视角从3D拉回2D,SDF能干的活一点都不少。尤其是当手上积攒了一堆美术给的Logo、文字、特效贴图,需要描边、发光、溶解、形变,同时还希望它们在任意分辨率下都保持平滑干净,那就绕不开一个操作:把普通图片制作为“距离场图”。这一篇就是系列里偏2D偏美术的一篇,从原理讲到你真正能落地的代码和UE材质节点,把图片转SDF这条路完整走一遍。
想直接抄作业的,这篇不会让你失望;想搞懂“为什么这么干”的,这篇也有你要的答案。
1. 先搞清楚SDF距离场图解决的问题
1.1 普通贴图放大缩小之后的那点破事
用一张普通图片在UE里做UI图标或者特效贴图,十有八九会遇到这几个事:放大后锯齿发毛,缩小后高光细节直接糊掉;想要一个描边效果,美术得提前渲一张带描边的图;想要发光,又得再渲一张发光通道。同一种图案,美术手里要维护四五个版本,改一个尺寸就要重新出图,这个流程低效到让人头疼。
SDF贴图完全换了一种思路:它不存颜色,只存“这个像素离形状边界有多远”。这样一个单通道灰度图,就能在材质里通过一个距离值动态算出来描边、发光、软化边缘、溶解、投影等一大堆效果。你不再需要“永远正确”的像素颜色,只需要“知道距离”,后续效果全部由材质计算实时生成。
有人可能觉得这是拿空间换时间,其实在大多数2D场景里,SDF贴图体积还更小,因为它是单通道灰度,压缩了颜色信息,又把形状信息变成连续梯度,甚至可以顺利走mip。只能说这买卖怎么看都不亏。
1.2 “距离”比“颜色”更适合渲染
SDF这个概念本身不复杂。假设屏幕上一个形状边界把空间分成两部分,边界上每个点的距离为0;形状内部的像素,记录“我到边界有多远”;形状外部的像素,记录“我到边界有多远”,但方向相反,于是有正负之分,所以叫有向距离场。
这套思路在字体渲染领域已经用了很多年。Valve在2007年发过一篇经典的《Improved Alpha-Tested Magnification for Vector Textures and Special Effects》,当时就是为了解决游戏里的文字缩放问题——普通位图文字一放大就糊,用SDF后文字边缘始终锐利,代价只是一张灰度图和一次距离判断。后来很多2D游戏框架、UI系统都开始用SDF做文本和矢量图形加速。UE里虽然不直接给UI文字暴露SDF纹理,但你自己把图片转成SDF再采样的路子是完全走得通的。
当然,单通道SDF有个天然短板:尖锐拐角处容易失去细节,因为距离值只表示“到最近边的距离”,恢复不了拐角的方向信息,所以文字这类带复杂直角的图形用单通道SDF会有圆角感。想要更精细可以做中距离场MSDF,把多个方向信息存到多通道。本文先以单通道为主,把流程跑通。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从位图到距离场:生成原理和前置条件
2.1 核心算法其实就两步
把图片转成SDF,本质上是把一个二值形状变成一张距离图。计算分两步:先确定哪些像素属于形状内部,再对每个像素求它到最近边界像素的欧几里得距离。听起来是O(N²)的暴力活,但现代库都做了距离变换优化,运行效率完全不是瓶颈。
我最常用的Python方案是scipy.ndimage.distance_transform_edt,它对二值数组求每个非零位置到最近零位置的距离,内部积分实现,远快过暴力遍历。只用它的话得到的是“无符号距离”,也就是只记录“到边多远”,不分内外。要变成有向距离场,需要做两次变换:一次对形状内部区域算距离,一次对外部区域算距离,然后把外部距离取负,合成一张带符号的距离场。
这里贴一个可独立运行的基础版本:
python复制import numpy as np
from scipy import ndimage
from PIL import Image
SRC = "logo_white_on_black.png"
DST = "logo_sdf.png"
img = Image.open(SRC).convert("L")
arr = np.array(img)
binary = arr > 128
dist_inside = ndimage.distance_transform_edt(binary)
dist_outside = ndimage.distance_transform_edt(~binary)
sdf = np.where(binary, dist_inside, -dist_outside)
MAX_DIST = 8
normalized = np.clip(sdf / MAX_DIST * 0.5 + 0.5, 0.0, 1.0)
out_img = Image.fromarray((normalized * 65535).astype(np.uint16))
out_img.save(DST)
这个脚本的核心就是distance_transform_edt两次调用,一次对binary,一次对~binary,最后用np.where把内部距离设为正值,外部设为负值。MAX_DIST这个参数值得好好解释:它决定贴图里保留“多宽”的距离渐变带。如果设成8,意味着距离边界8个像素内的地方都有梯度信息;超过8像素的位置,距离值会被裁剪到0或1。这个值越大,描边、投影能采样的范围越大,但同样距离值分辨率下,对比度会变弱;取太小,边缘附近的信息不够用,描个宽边就露馅。我自己的习惯是4到16之间,看目标使用场景。
还有一个关键点:很多人第一次生成SDF后用看图软件打开,发现形状内部不是纯白,而是很多灰白渐变,边缘一圈还有半透明过渡,于是觉得图坏了。其实完全正常。边界位置是0.5的灰,内部越靠近中心越接近1,外部越远越接近0。这张图你直接肉眼看的“清晰度”不如原图,但放到UE材质里采样,边缘表现反而比原图好得多。
2.2 符号约定别搞反,渲染端全靠它
脚本里我用的约定是:内部为正,外部为负,边界为0。贴图编码时通过0.5 + sdf / MAX_DIST * 0.5把0映射到0.5,把最大正距离映射到1,把最大负距离映射到0。这样一来,位图上的0.5就是形状边界。
在UE材质里采样时,要记得把编码后的值还原:
hlsl复制float d = Tex.R * 2.0 - 1.0;
这一步很不起眼,但特别容易忘。你不还原的话,边界不在0而在0.5,后续所有abs、smoothstep、step的阈值都得跟着0.5算,不仅绕,而且容易在换贴图时出错。统一还原成[-1,1]之后,所有SDF逻辑都围绕0转,直觉上清楚,调参也方便。
2.3 图片预处理做不好,后面全白干
生成SDF不是拿一张彩色图直接跑就完事,输入质量直接决定输出效果,踩过坑之后我养成了三个习惯。
第一,二值化要处理抗锯齿边缘。很多PNG是抗锯齿的,边缘像素有半透明渐变,直接按灰度阈值二值化,细线条可能会断掉。更好的做法是如果有Alpha通道,就优先用Alpha通道判断形状:Alpha大于某个阈值的像素算是内部。半透明边缘在SDF生成时反而能提供一点点亚像素信息,但主要价值还是帮你判断边界位置。
第二,必须留白边。这个坑特别隐蔽。如果形状轮廓紧贴着画布边缘,那么距离场在轮廓外侧根本没有空间往外“生长”,就像一个房子建在悬崖边缘,另一头悬空了。在UE里一采样,边缘外几像素之后全是裁切值,描边、发光这些效果会被硬生生截断,出现一条明显的“断崖”。解决办法是在图片四周预留边距,至少要比MAX_DIST大,考虑到实际使用,建议至少16像素。有些美术给图习惯画布裁得很紧,这时候你拿到图先别急着转,用PS或脚本扩边。
第三,杂点和脏点要清理。二值图里一个孤立的噪点会在距离场里形成一个小小的“岛”,导致边缘出现奇怪的突起,放大后就像笔刷底下弹了个脏点。先做一次中值滤波或者形态学开闭运算,把噪声去掉,再生成SDF,干净很多。
3. 实操路线A:用现成工具快速转SDF图
3.1 Laigter:给2D精灵和图标做SDF的最快方案
如果只想快速验证效果,不想碰Python,Laigter是很好用的免费开源工具。它原本主要面向2D游戏精灵,能自动生成SDF图、法线图、视差图,帮助2D素材实现动态光照。虽然主要功能是光照,但它的SDF导出完全可用。
使用流程很简单:
- 拖入一张PNG,透明通道或者黑白轮廓都可以;
- 在左侧面板里找到Distance Field相关的选项,可以让工具自动计算SDF;
- 调整边缘宽度和软化程度,实时预览;
- 导出时把SDF单独存成PNG或TGA,其他通道可以关掉。
它导出的SDF图在UE里可以直接用,但要注意它的编码边界不一定也是0.5,记得先看一眼导出图的像素值,如果边缘不是50%灰,你得在材质里修改还原公式。这个我后面会专门说怎么验证。
3.2 TexturePacker:批量合图时顺带出SDF
TexturePacker大家更熟,做图集合批的利器。它在导出精灵图集时内置了SDF选项,可以在Data Settings里勾选生成Distance Field,并且要求你设置Padding。它会把每张小图标生成对应的SDF数据,合并成整张图集,同时输出元数据JSON,里面标了每张小图的矩形区域和SDF相关参数。
这个方案适合项目里的图集流程已经跑起来的情况。美术同事正常出图,图集工具在打包时自动生成SDF版本,UE客户端只需要引入图集和材质,不需要额外维护两份资源。缺点是SDF的精度和导出参数受工具控制,如果想对每个文件单独调MAX_DIST之类的参数,得回到Python或者DCC工具。
3.3 其他选择:SDFGen、Substance Designer
SDFGen是一个C++小工具,命令行传入输入图片和输出路径就能生成SDF,体积小,适合放在CI或者自动化管线里。Substance Designer则是程序化材质老司机们的最爱,里面有距离变换节点,可以把灰度图转成想要的距离场。它最大的优势是能和整套材质资产无缝衔接,生成结果直接作为纹理输入到后续节点网络里,改参数实时刷新。
这几类工具对比一下:
| 工具 | 学习成本 | 批量能力 | 精度控制 | 适合场景 |
|---|---|---|---|---|
| Laigter | 低 | 一般 | 中 | 快速原型、2D精灵 |
| TexturePacker | 低 | 高 | 中 | 图集批量导出 |
| Python脚本 | 中 | 高 | 高 | 美术管线、自定义规则 |
| Substance Designer | 中 | 中 | 高 | 程序化材质 |
| SDFGen | 中 | 高 | 中 | 自动化命令行 |
我自己平时最常用的还是Python脚本,因为它能塞进美术资源流水线,路径、Padding、最大距离全都可以用配置文件控制,出问题的概率比手动操作小很多。
4. 实操路线B:用Python脚本批量生成SDF距离场图
4.1 升级版脚本:自动扩边、支持Alpha通道、批量处理
前面给的是单张图的最小脚本,实际上生产环境会遇到三个问题:图片可能带透明通道、尺寸可能贴边、需要批量处理。所以我平时跑的脚本比基础版多几层防护:
python复制import numpy as np
from scipy import ndimage
from PIL import Image
from pathlib import Path
SRC_DIR = Path("./in")
DST_DIR = Path("./out")
DST_DIR.mkdir(exist_ok=True)
MAX_DIST = 8.0
THRESHOLD = 128
PAD = 16
def process(src_path, dst_path):
img = Image.open(src_path)
if "A" in img.getbands():
arr = np.array(img.getchannel("A"))
else:
arr = np.array(img.convert("L"))
binary = arr > THRESHOLD
# 自动扩展画布,给距离场留出外扩空间
h, w = binary.shape
padded = np.zeros((h + PAD * 2, w + PAD * 2), dtype=bool)
padded[PAD:PAD + h, PAD:PAD + w] = binary
dist_inside = ndimage.distance_transform_edt(padded)
dist_outside = ndimage.distance_transform_edt(~padded)
sdf = np.where(padded, dist_inside, -dist_outside)
normalized = np.clip(sdf / MAX_DIST * 0.5 + 0.5, 0.0, 1.0)
out = Image.fromarray((normalized * 65535).astype(np.uint16))
out.save(dst_path)
for png in SRC_DIR.glob("*.png"):
process(png, DST_DIR / (png.stem + "_sdf.png"))
脚本里最值得说的是PAD这步。很多人看代码觉得扩边是画蛇添足,其实这一步才是决定SDF边缘“能不能呼吸”的关键。我直接把PAD写死成16,并且在生成前对二值数组做padding。这样做的好处是无论美术给的原图有没有留白,最终生成的SDF贴图都有一圈干净的背景距离信息,UE里采样时不会出现边缘断崖。
另一个细节是优先读Alpha通道。如果图片带透明通道,通常我们想要的形状就是alpha不透明的部分,用灰度通道反而容易被彩色信息污染。这个判断放在process函数最开始,能兼容绝大多数美术交付的PNG。
4.2 怎么验证生成的SDF图是对的
生成完SDF之后,先不要急着往UE里拖。我建议用UE自带或者普通图片查看器看一眼,确认几个特征:
- 边界处是不是50%灰。这是最直观的验证方式。你用像素取色工具点一下形状边缘,如果RGB三个值都接近128附近,说明边界编码基本正确。
- 内部是不是有一条从灰到白的渐变。如果内部离边缘远的地方直接是纯白,说明
MAX_DIST偏小,或者图片本身很大,距离值已经超出了编码范围。 - 外部有没有一圈从灰到黑的渐变。没有这圈渐变,就意味着没留够Padding或者生成时裁切了,在UE里做发光、投影会直接露馅。
如果边界点出来不是50%灰,而是其他值,别慌。可能你的工具或脚本用的编码公式不同,比如有人用0.5 - sdf/max*0.5,把内外方向反过来。这种情况下,要么修改生成脚本统一编码,要么在UE材质里按实际值偏移。我建议修改生成脚本,让所有SDF资源遵循同一个约定,省得每次换图都得改材质。
4.3 常见生成失败案例
我遇到过几次生成结果完全不对的情况,先说最容易犯的:把二值方向搞反。比如原图是黑底白字,你直接arr > 128会把白色当成形状内部,这没问题;但如果原图是白底黑字,arr > 128就会把背景当成内部,生成出来的SDF完全颠倒。解决方法是统一规范输入格式,或者在脚本里加一个判断,算一下黑白像素比例再决定是否翻转binary。
细线条断裂也常见。有些Logo线条只有1像素宽,二值化后还可能断成虚线,距离场就会显示成破碎的形状。处理方法是先对binary做一次膨胀操作,把细线连接起来,再生成SDF。不过膨胀也会让轮廓变粗,所以具体膨胀半径要看你的素材分辨率。
还有就是16位PNG在一些引擎导入管线里支持不好。如果你的美术管线不接受16位灰度PNG,退而求其次用8位PNG也能跑,但距离渐变会出现肉眼可见的条带。我的建议是优先保留16位,UE吃16位PNG没问题;如果中间工具链不支持,再考虑8位并接受一点精度损失。
5. 导入UE:贴图设置和材质采样一条龙
5.1 导入之后这些纹理选项必须改
把SDF贴图拖进UE后,双击打开纹理资产,默认的导入设置是按颜色贴图来的,直接采样SDF你会得到一堆莫名奇妙的灰阶值。必须手动改这几个地方:
- Compression Settings:默认是
Default (DXT),块压缩会毁掉距离场的连续渐变。改成UserInterface2D (RGBA)或者HDR (RGBA),要求高精度的话用VectorDisplacementmap,但具体项目中我也常用HDR,因为它是16位浮点,精度和兼容性均衡。如果你的UE版本支持R16F/RGBA16F格式,那就用R16F,单通道体积更小,精度也更合适。 - SRGB:务必关闭。SDF是线性数据,不是颜色。SRGB一旦开着,采样器会做Gamma变换,距离值被非线性扭曲,最直接的后果是描边宽度两侧不一致,越亮的地方偏得越厉害。
- Mip Gen Settings:UI和2D特效场景建议直接
NoMipmaps。如果这个SDF贴图要用在3D世界物体上,那还是需要mip,但要清楚默认的box filter会对距离做平均,下采样后0.5边界区域会变宽,远处看起来像双影。这不是UE的bug,是距离场和普通颜色贴图的下采样逻辑不一样。 - Address X/Y:设为
Clamp。SDF需要的是边界外的背景距离值,如果设成Wrap,采样到UV 0到1之外时会回绕到另一侧,距离值突然跳变,边缘会出现镜像状的破损。 - Filter:Bilinear够用。Trilinear在3D场景也可以,但配合Mipmap的默认平均,远处边缘质量会更软。
5.2 材质蓝图里怎么还原并采样
假设你已经在纹理资产里看到了那张SDF图,现在到材质编辑器里建一个最基础的显示材质:
- 新建材质,
Blend Mode按需设Masked或Translucent; - 加一个
TextureSample节点,连上SDF纹理; - 取R通道,乘2减1,得到真正的有向距离值
d; - 用
Step(0, d)或者smoothstep把d转成不透明蒙版; - 把蒙版连到
Opacity Mask或Opacity。
一个比较典型的单通道SDF采样材质,距离还原公式就那行:
hlsl复制float d = Tex.R * 2.0 - 1.0;
如果你要的是抗锯齿边缘,不要写死一个固定宽度的smoothstep,因为屏幕分辨率、缩放比例一变,固定宽度就出现硬边或毛边。用fwidth做自适应:
hlsl复制float aa = fwidth(d);
float alpha = 1.0 - smoothstep(0.0, aa * 1.2, d);
fwidth是HLSL内置函数,计算当前像素在屏幕空间的变化率,也就是相邻像素之间的值差多少。用它做smoothstep的宽度,等于让边缘的抗锯齿宽度和实际屏幕像素尺寸挂钩,无论贴图被放大到多大,边缘都保持一个像素左右的柔和过渡。这在3D中被缩放、在UI中适配分辨率时特别有用。
5.3 用SDF做描边和发光的材质模板
描边的原理很简单:距离值越靠近0,说明越靠近边界。于是abs(d)就得到一个“离边界距离”的无符号量。想要粗细可控的描边,用smoothstep对abs(d)做窗函数:
hlsl复制float border = 1.0 - smoothstep(StrokeWidth - Soft, StrokeWidth + Soft, abs(d));
这里StrokeWidth是描边宽度,Soft是边缘软化量。由于SDF值是连续的,你可以在实例材质里用参数实时调描边宽度,不需要重新生成贴图,这是我最喜欢的一点了。
发光效果则可以像这样:
hlsl复制float glow = exp(-abs(d) * GlowFalloff);
GlowFalloff控制发光的衰减速度,越大发光越紧贴边缘,越小越弥散。exp曲线做衰减很自然,距离值0处最亮,越远越暗,而且没有任何硬边,很适合做霓虹、光剑之类的边缘发光。
6. 实战效果:几个能直接抄的小场景
6.1 文字和Logo描边
我经常在项目里做的一件事,是把一张黑底白字的Logo图片转成SDF,然后在UE材质里动态控制描边宽度。原来美术要出两套图,一套原图,一套描边图;现在只需要一张SDF贴图和一个材质参数。
具体蓝图结构大概是:采样SDF纹理,还原d,算border,算fill,fill = step(0, d)表示形状内部不透明,然后BaseColor用borderColor * border + fillColor * fill,Opacity用saturate(border + fill)。这样一个材质实例就能同时控制描边粗细、描边颜色、填充颜色,运行时也能调,做UI反馈、选中高亮都很好用。
6.2 边缘溶解、冰霜扩散
溶解是另一种常见需求,比如角色消失、物品破碎、场景淡入淡出。用SDF来做,公式非常简洁:
hlsl复制float noiseValue = texture2D(NoiseTex, UV).r;
float dissolveMask = d + noiseValue - DissolveAmount;
clip(dissolveMask);
d是从SDF纹理还原出来的距离值,noiseValue是噪声贴图采样结果,DissolveAmount是材质参数。当DissolveAmount从-1变化到1时,溶解边缘会沿着形状的真实轮廓推进。由于SDF存的是几何距离,而不是颜色,溶解边缘不会像普通贴图那样出现像素化碎片。同样的思路,把公式里的DissolveAmount换成噪声偏移和正弦波,就能做冰霜从边缘慢慢蔓延的效果,边缘还会轻微波动。
6.3 伪阴影、形变和亚像素动画
SDF还有一个隐性福利:它能对UV做偏移而不撕裂形状。普通贴图你把UV扭成一个波浪,边缘像素会互相拉扯,出现白色裂缝;但SDF贴图只存距离值,你可以放心地把UV扭到任意位置,只要在一个texel范围内,形状仍然完整,因为最终边缘是根据距离阈值重构出来的,而不是来自某个固定颜色。我用这个做过果冻蠕动、水面倒影扭曲、UI按钮弹性按压,效果都很稳。
伪阴影也可以直接用距离值算:偏移半个像素采样两次SDF,一个作为阴影通道,一个作为主通道,两张蒙版互相错位,再对阴影蒙版做模糊,就能做出“悬浮UI投影”的感觉。这个比做法线贴图再Bake投影轻量多了,一张SDF搞定。
7. 踩坑记录:SDF贴图在UE里容易翻车的地方
7.1 纹理块压缩把距离场干成马赛克
这是我在新手阶段遇到最狠的一个坑。默认的DXT压缩会把16位灰度信息压缩成4x4像素块的颜色索引,普通贴图还能看,但SDF贴图连续渐变的灰阶直接被踩成一层一层的,边缘变成明显的锯齿,仔细看还有4x4的方块。
解决方式就是确保Compression Settings选的是非块压缩格式,比如UserInterface2D、HDR、VectorDisplacementmap,或者直接强制NoCompression。改了之后纹理内存会变大,但SDF本身就是小尺寸单通道,就算1024x1024,代价也可控。别在这种地方省内存,返工成本比内存贵多了。
7.2 SRGB没关,描边宽度左右不对称
距离值被SRGB变换扭曲后,0.5边界附近的线性关系被破坏。最直观的表现是:描边在形状内侧比外侧宽,或者反过来,总之左右不对称,颜色亮度也不对。如果你做的SDF显示效果“哪里怪怪的”,先检查SRGB是不是关掉了。这是数据贴图,不是给眼睛看的颜色,坚决要关。
7.3 Padding不够,发光在边缘断崖
如果生成SDF之前没有留足外扩,或者用了紧贴轮廓的裁剪图,你在材质里把描边宽度调大,或者发光衰减调慢,会看到边缘外几像素后直接变成透明。那个效果不是“平滑淡出”,而是“一脚踩空”。检查一下源图边缘到画布边缘的距离,如果小于你预设的MAX_DIST,那就要重新扩边生成。
7.4 透明混合排序的坑
SDF常用于半透明效果,但UE的Translucent排序是按物体中心或深度排的,多个SDF特效叠在一起时,可能会出现穿插错乱,前面一个角色溶解的特效跑到后面去了。处理办法有两种:一个是设置材质域的排序优先级,另一个是用Separate Translucency,让SDF特效放到单独的半透明渲染阶段。具体选哪种取决于你的项目架构,但至少得意识到这是半透明渲染的通用问题,不是SDF本身能解决的。
7.5 8位灰度条带
用8位PNG保存SDF时,距离渐变只有256个灰阶,放大后能看到一圈一圈的条形带,尤其在边缘软化范围比较大的时候更明显。解决办法就是保存16位灰度PNG,UE能直接导入16位PNG,精度足够。如果项目管线实在不支持16位,也可以把SDF范围压缩得更极端一点,让渐变带宽集中在需要精度的地方,但这属于妥协方案。
7.6 材质实例里调参时记住“边界=0”
最后提醒一下自己的UE材质逻辑:所有SDF效果都是围绕d = 0展开的,而不是围绕0.5。每次打开材质,第一眼先确认当前节点是否已经做过R * 2 - 1,如果直接拿原始R通道做abs,边界在0.5,数值不一致,很多效果会在换贴图后突然出错。这是一个小细节,但确实救过我好几次。
这些坑踩完之后,我对SDF做2D效果一直没有再返过工。后续我大概率会继续把这套思路扩展到体积SDF和网格SDF的混合玩法上,比如把一张贴图生成的SDF作为模型表面细节,再和关卡全局距离场做交互。如果后面真做出来了,我再回来把这篇补完。
