21 SDF 的视觉效果
前置知识:第 18 章的填充、描边和
fwidth;第 20 章的差集与距离场变换。
本章示例:带孔徽牌的距离效果,完整源码。
学完本章能做什么
你将能用同一个二维距离场分别生成填充渐变、描边、外发光、柔和阴影和边缘明暗,并能说明每种效果改变的是颜色、查询位置还是距离本身。本章效果都发生在片元阶段,不产生真实三维厚度或场景灯光。
先看现象
示例显示一个右侧带圆孔的圆角徽牌。下拉框可分别查看填充、描边、外发光、偏移阴影和二维边缘明暗,最后在组合预览中叠加。调大描边宽度,金线向边界两侧扩展;调大发光衰减距离,蓝色光晕扩散更远;改变阴影偏移,只移动阴影而不移动徽牌本体;改变二维光方向,边缘亮暗沿轮廓移动。
示例仍用 3:2 的正面平面,坐标 p=(vUv-0.5)*vec2(1.5,1.0)。所有宽度、半径与偏移都以整块平面的高度为单位。网格是 PlaneGeometry(3.0,2.0,1,1);徽牌并没有真实孔洞,平面深度和轮廓依然不变。
1. 先建立一个可复用的场
外轮廓是圆角矩形,孔是右侧的小圆。沿用第 20 章的差集写法:
// 片元阶段;p、半尺寸、半径均在同一二维平面度量。
float sdBadge( vec2 p ) {
float outer = sdRoundBox( p, vec2( 0.50, 0.28 ), 0.09 );
float hole = sdCircle( p - vec2( 0.22, 0.0 ), 0.11 );
return max( outer, -hole );
}
outer<0 且 hole>0 才属于徽牌;圆孔中 -hole>0,所以返回正值。圆孔完全位于外轮廓内,并留有可见边框。所有效果都从 d=sdBadge(p) 开始,不再各自重复实现形状判断。该配置的孔与外边界分离;若日后把孔移到与外轮廓相交,布尔组合附近的距离值需按第 20 章的方法重新检查。
2. 填充与描边:以零边界为中心
片元阶段先用距离导数估计一像素宽度:
// 片元阶段;d 的单位是平面高度。
float aa = max( fwidth( d ), 0.0001 );
float fill = 1.0 - smoothstep( -aa, aa, d );
float outline = 1.0 - smoothstep( -aa, aa,
abs( d ) - 0.5 * uOutline );
fill 在徽牌实体内部接近 1,圆孔和外部接近 0。填充色再随 p.y 混合上下两种线性 RGB 颜色,形成简单渐变;这个渐变只影响颜色,不参与距离计算。uOutline 是完整描边宽度,abs(d) 使金线在外轮廓、孔轮廓的两侧都对称展开。组合预览在填充后叠加描边,因此描边会覆盖填充边缘。
如果只要内侧描边,应改成诸如 -width<d<0 的区间;第 18 章的练习已经处理过这个变体。只给颜色乘一个亮度系数不会得到固定宽度的边界,必须用距离阈值。
3. 外发光:距离越远,贡献越低
示例用指数函数让外侧光晕随距离衰减:
// 片元阶段;uGlow > 0,单位同 d。
float glow = exp( -max( d, 0.0 ) / uGlow ) * ( 1.0 - fill );
color += vec3( 0.04, 0.28, 0.90 ) * glow * 0.65;
在离边界约 uGlow 的外侧,指数因子约为 e⁻¹≈0.37;它是衰减距离,不是光晕的硬半径。1-fill 避免把同样的外发光涂满实体内部;圆孔也是徽牌的外部,因此孔内边缘也会有光晕。示例在“仅外发光”模式里保留暗色轮廓,方便定位发光从哪条边产生。
这里是按 SDF 为每个像素添加局部颜色,并不是光学 bloom 后处理:不会自动照亮周围其他物体,也不会跨越当前平面的可绘制区域。颜色在线性 RGB 中相加,最后用 #include <colorspace_fragment> 转到渲染器输出色彩空间。颜色超过显示范围时会被限制,过强的发光可能失去层次。
4. 柔和阴影:再查询一次偏移后的形状
把同一徽牌的查询坐标向反方向平移,得到一个位于本体后面的剪影:
// 片元阶段;uShadowOffset 与 p 属于同一平面坐标。
float shadowD = sdBadge( p - uShadowOffset );
float shadowAa = max( fwidth( shadowD ), 0.0001 );
float shadow = 1.0 - smoothstep( -shadowAa, uBlur, shadowD );
若 uShadowOffset=(0.07,-0.06),阴影向右下移动;这是“形状向偏移方向移动”与“查询点减偏移”的对应关系。uBlur 控制从阴影边界附近到外侧的软过渡,单位仍是平面高度。示例先把阴影色混到背景,再画发光和本体,所以本体会盖住其后的阴影。把阴影最后混到颜色上会错误地压暗徽牌正面。
这种阴影只是二维偏移遮罩。它不考虑三维光源、物体高度或遮挡关系,也不会投到场景中其他网格上;uBlur 只是软边宽度,不是物理半影公式。
5. 二维边缘明暗:从距离变化估计方向
真实三维法线来自三维表面,第 15~16 章已经讲过。本例想在平面颜色上做一个类似斜面的风格化边缘:先在同一二维坐标中对 sdBadge 做中心差分,得到指向外侧的二维梯度方向:
// 片元阶段;epsilon 的单位是平面高度。
float epsilon = 0.002;
vec2 gradient = vec2(
sdBadge( p + vec2( epsilon, 0.0 ) ) - sdBadge( p - vec2( epsilon, 0.0 ) ),
sdBadge( p + vec2( 0.0, epsilon ) ) - sdBadge( p - vec2( 0.0, epsilon ) )
) / ( 2.0 * epsilon );
vec2 outward = length( gradient ) > 0.00001 ?
normalize( gradient ) : vec2( 0.0, 1.0 );
此 outward 是二维平面中的边界方向,不是观察空间或世界空间三维法线。示例把 UI 中的角度转为二维 light=(cos(angle),sin(angle)),只在内边界附近使用点积改变填充颜色:
// 片元阶段;d<0 的内部边缘带逐渐获得明暗。
float rim = smoothstep( -uBevel, -0.2 * uBevel, d ) *
( 1.0 - smoothstep( 0.0, aa, d ) );
float edgeLight = 0.85 + 0.45 * dot( outward, light );
fillColor *= mix( 1.0, edgeLight, rim );
uBevel 越大,明暗带向内部延伸越深。圆孔的外向梯度指向孔中心,因此光照方向在孔边与外边可能产生不同的亮暗,这符合“实体向外”的符号约定。场在中轴或形状组合的不可微点可能没有稳定梯度;示例在梯度过小时给一个备用方向,并只把结果应用到边缘带。真实的斜面几何、遮挡、高光和阴影都不会因此出现。
中心差分每片元额外查询四次 sdBadge;阴影又查询一次。组合模式比单独填充更费片元计算。若仅需简单圆形,可用解析梯度;若效果较复杂,也可预计算距离纹理或缓存结果。epsilon 太大使方向变钝,太小可能受浮点精度影响;不要把它与 fwidth(d) 的屏幕像素宽度混为同一参数。
6. 按绘制顺序合成
组合预览的顺序是:背景 → 阴影 → 外发光 → 渐变填充与边缘明暗 → 金色描边。每一步都只改变 vec3 color,而 d 保持同一个徽牌场。逐项模式可用于发现过强的某一层;例如发光把边界洗白时,先降低其颜色或强度,再判断描边是否足够清晰。
示例最终输出 gl_FragColor=vec4(color,1.0)。alpha 始终为 1,圆孔显示的是本示例的背景颜色,不是浏览器页面透过平面的真实透明洞。若要做透明材质,需要另行设置材质透明、alpha 与混合/深度行为;本章聚焦距离到颜色的映射。
常见问题
- **光晕把整个徽牌填满:**忘了乘
1-fill,或把负距离也直接送进指数衰减。 - **阴影向相反方向走:**查询点应为
p-uShadowOffset,才能让阴影形状向+uShadowOffset移动。 - **阴影压住正面:**合成顺序应先阴影后本体。
- **孔周围缺少描边或发光:**孔属于形状外部,检查
sdBadge=max(outer,-hole)的符号以及是否复用了同一个d。 - **边缘明暗方向乱跳:**检查中心差分步长、组合场的不可微位置和梯度长度;不要把二维梯度直接当三维法线。
- **期待真正的透明孔或投影:**当前平面 alpha 为 1,所有效果只在其片元颜色中实现。
本章小结
SDF 不只用于填充:abs(d) 给对称描边,正距离的指数衰减给外光晕,偏移后的第二次查询给二维软阴影,距离的二维梯度给风格化边缘明暗。理解单位、导数、绘制顺序与片元查询次数,才能在画面和成本之间做有根据的调整。