
实时美颜里的磨皮、调色、腮红,本质上主要是在改变像素的颜色,而瘦鼻、瘦脸、大眼这一类功能处理的是另一个问题:如何改变画面中的局部几何结构。
以瘦鼻为例,我们真正希望看到的效果,并不是把鼻子所在的一块图像简单缩小,而是让鼻翼两侧的视觉轮廓向鼻梁中间收拢,同时保持鼻梁、鼻尖以及周围皮肤纹理连续。
在实时相机中,这件事情并不适合直接修改屏幕上的几何顶点。人脸会旋转,相机可能镜像,不同人的鼻子尺寸不同,最终输出画面的宽高比也可能变化。如果直接围绕屏幕 X/Y 坐标做形变,很快就会遇到方向和尺度不一致的问题。
因此,这套瘦鼻算法采用的是另一种思路:
markdown
Face Landmark
↓
确定 Nose Center / Nose Radius
↓
建立人脸局部坐标系
↓
在鼻部椭圆中计算连续 Warp 权重
↓
得到 Camera Texture 的采样偏移
↓
重新采样原始画面
整个算法最终没有真正"移动"任何一个 Fragment。
它真正改变的是:
当前这个 Fragment,应该去原始 Camera Texture 的什么位置读取颜色。
理解这一点,基本就理解了整个瘦鼻 Shader。
一、瘦鼻的本质是一次反向纹理采样
正常渲染时,当前输出位置通常直接使用对应的 Camera Texture 坐标:
ini
vec4 color =
texture(
uTexture,
textureCoordinate
);
也就是:
Cout(p)=Csrc(p)
输出位置 p 显示原图中同一个位置的颜色。
几何 Warp 所改变的就是这层映射:
Csrc(p+Δp)
屏幕上的 Fragment 本身没有移动,只是它读取原图的位置发生了变化。
Shader 最终真正修改的其实就是:
textureCoordinate + warpDelta
其中 warpDelta 是当前 Fragment 的纹理采样偏移。
这也是为什么瘦鼻时,纹理的采样方向和最终看到的几何移动方向看起来是相反的。
假设鼻梁中心位于:
x=0
对于鼻子右侧的一个输出像素,如果我们让它去原图中稍微更靠右的位置读取颜色:
xs=x+Δx,Δx>0
那么原本更加靠外的鼻翼纹理,就会提前出现在更加靠内的位置。
于是虽然采样向外移动了 ,视觉中的鼻翼却是在向内收缩。
左侧完全相同,只不过偏移方向相反。
因此最终形成的是:
lua
原始鼻部
|---------------|
Warp 后
|---------|
这就是典型的 Inverse Texture Mapping。
瘦脸、大眼、局部拉伸等大量实时美型算法,本质上都建立在同一个思想上:
不改变输出像素的位置,而是重新定义输出像素和原始纹理之间的采样关系。
瘦鼻真正需要解决的,也就变成了一个很明确的问题:
对于鼻子区域里的每一个 Fragment,应该计算多大的纹理采样偏移?
二、先把鼻子放到一个稳定的人脸局部空间里
如果人脸永远保持竖直,那么瘦鼻似乎只需要修改:
textureCoordinate.x
但实际使用时,人脸一定会发生 Roll。
正常情况下,人脸的左右方向大致和屏幕 X 轴一致:
lua
左眼 -------- 右眼
│
鼻子
歪头以后:
markdown
右眼
/
鼻子
/
左眼
这时屏幕 X 轴已经不再代表鼻子的左右方向。
如果继续修改 textureCoordinate.x,最终拉扯的就是屏幕水平方向,而不是鼻翼真正应该收缩的方向。
因此 CPU 会根据 Face Landmark 先计算两条方向:
uFaceXAxis
uFaceYAxis
它们共同构成一套附着在人脸上的局部坐标系:
markdown
Face Y
↑
│
│
Face -X ←────── Nose ──────→ Face +X
│
│
↓
其中 uFaceXAxis 表示人脸左右方向,uFaceYAxis 表示人脸上下方向。
人脸发生 Roll 时,这套坐标轴也一起旋转。
这样 Shader 后面就不需要关心"当前人脸到底歪了多少度",而只需要把当前 Fragment 转换到这套局部坐标系中。
瘦鼻函数因此同时接收两个坐标:
scss
vec2 applyNoseSlim(
vec2 textureCoordinate,
vec2 outputCoordinate
)
这里的两个坐标有明确的分工。
outputCoordinate 用来处理几何问题:
当前 Fragment 相对于鼻子中心处于什么位置?
textureCoordinate 则用来处理最终采样:
这个 Fragment 应该去 Camera Texture 的什么位置取颜色?
所以几何计算首先从:
outputCoordinate - uNoseCenter
开始。
不过这里还有一个问题。
UV 坐标本身并不是一个横纵等比例的空间。
假设输出尺寸为:
1080×1920
横向 UV 移动 0.1 实际对应 108 个像素,而纵向移动 0.1 却对应 192 个像素。
如果直接在 UV 空间里计算距离,那么几何尺度会随着输出宽高比变化。
因此 Shader 先利用:
ini
uTexelSize.x = 1.0 / width;
uTexelSize.y = 1.0 / height;
得到:
ini
float aspect =
uTexelSize.y /
max(uTexelSize.x, 0.000001);
也就是:
aspect=HW
然后对 X 方向进行修正:
ini
vec2 delta =
(outputCoordinate - uNoseCenter)
* vec2(aspect, 1.0);
这样得到的 delta 已经位于一个横纵尺度统一的几何空间中。
接下来只需要做两次点积:
ini
float localX =
dot(delta, uFaceXAxis);
float localY =
dot(delta, uFaceYAxis);
就可以得到当前 Fragment 在人脸局部空间中的位置:
Δ⋅x^face
Δ⋅y^face
从这一刻开始,屏幕坐标基本就退出了算法。
后面的瘦鼻逻辑面对的始终都是一个"摆正以后"的标准鼻子。
三、把真实鼻子统一映射成一个标准 Nose Ellipse
解决方向以后,还需要解决尺寸。
不同人的鼻子大小不同,如果直接根据 localX 和 localY 做形变,那么同样一套 Shader 参数在不同人脸上代表的实际范围完全不同。
因此 CPU 还会根据 Landmark 提供:
uNoseRadius.x
uNoseRadius.y
分别表示鼻部 Warp 区域的横向半径和纵向半径。
Shader 将局部坐标进一步标准化:
ini
float nx =
localX /
max(uNoseRadius.x, 0.0001);
float ny =
localY /
max(uNoseRadius.y, 0.0001);
即:
nx=rxxl
ny=ryyl
这样,无论真实鼻子有多大,都会被映射到相同的标准空间中。
随后计算:
ini
float distanceSquared =
nx * nx +
ny * ny;
也就是:
nx2+ny2=rx2xl2+ry2yl2
这正好对应一个标准椭圆。
于是:
kotlin
if (distanceSquared >= 1.0) {
return textureCoordinate;
}
就可以把整个 Warp 限制在鼻部区域之内。
这里采用椭圆而不是圆形并不是单纯为了调参数方便。
真实鼻部结构本身就是明显的纵向结构,横向和纵向的有效范围并不相同。通过两个独立半径:
rx,ry
能够让 Warp Region 更符合实际鼻子的形状。
到这里,CPU 和 Shader 的职责已经分得很清楚:
CPU 利用 Landmark 告诉 GPU:
鼻子在哪里
鼻子有多大
人脸左右方向在哪里
人脸上下方向在哪里
而 GPU 不再关心 Landmark 本身。
对于 GPU 来说,现在只剩下一个标准问题:
在 nx2+ny2<1 的椭圆内部,为每一个 Fragment 计算一个连续的横向位移。
这才真正进入瘦鼻算法最核心的部分。
四、真正决定效果的是这张连续位移场
局部 Warp 最重要的并不是"能够移动纹理",而是位移必须连续。
如果整个 Nose Ellipse 内部直接使用固定偏移:
D=k
而椭圆外部:
D=0
那么在边界位置,纹理采样坐标会发生瞬时跳变。
最终看到的就是折痕、拉丝或者明显的纹理断层。
所以瘦鼻不能只是定义:
鼻子里面移动多少。
更重要的是定义:
从鼻梁中心到鼻翼,再到 Warp Region 边缘,位移应该怎样连续变化。
当前算法使用了三个相互配合的权重。
首先是左右方向的横向权重:
ini
float horizontalWeight =
max(
1.0 - nx * nx,
0.0
);
即:
Wh=1−nx2
它随着 Fragment 远离鼻梁中轴逐渐下降。
但最终位移还会乘上 localX:
localX * horizontalWeight
所以鼻梁中心虽然:
Wh=1
但:
localX=0
最终位移依然是 0。
Fragment 从鼻梁向外移动以后, ∣localX∣ 开始增大,而 Wh 又逐渐减弱,于是两者结合以后自然形成一个中间区域较强、两端较弱的形变分布。
可以把它简单理解成:
markdown
鼻梁中心 鼻翼附近 外部边缘
0 ───→ 逐渐增强 ───→ 逐渐回到 0
第二个权重负责鼻子的上下方向:
ini
float verticalWeight =
exp(
-ny * ny * 2.5
);
即:
Wv=e−2.5ny2
这是一个高斯型衰减。
越接近鼻部纵向中心,权重越大;越靠近椭圆的上下两端,权重越低。
这样就不会让整根鼻梁从上到下都产生相同幅度的横向形变,而是让 Warp 更集中在鼻部真正需要调整的区域。
第三个权重则负责整个 Nose Ellipse 的边缘:
ini
float boundaryWeight =
1.0 -
smoothstep(
0.60,
1.0,
distanceSquared
);
当:
d2≤0.6
时,核心区域保持完整权重。
从:
d2=0.6
开始,Warp 逐渐进入羽化区,最终在:
d2=1
也就是 Nose Ellipse 边缘回到 0。
最终三组权重相乘:
ini
float warpWeight =
horizontalWeight
* verticalWeight
* boundaryWeight;
得到:
(1−nx2)e−2.5ny21−smoothstep(0.6,1,nx2+ny2)
这条公式实际上就是整个瘦鼻算法的核心。
它定义的并不是一个简单的"强度值",而是一张覆盖鼻部椭圆的连续二维位移权重场。
在鼻梁中轴附近,最终位移因为 localX 接近 0 而自然消失;
向鼻翼方向移动时,位移逐渐增大;
再继续接近 Warp Region 外围时,高斯和 smoothstep 又让位移重新平滑衰减到 0。
因此鼻部纹理不会突然被切开,而是从原始画面连续过渡到形变区域,再连续回到原始画面。
这也是局部 Warp 看起来是否自然的关键。
五、从权重场得到最终的 Camera Texture 偏移
得到 warpWeight 以后,剩下的事情反而很简单。
首先将用户输入的:
uNoseStrength
做一次非线性映射:
ini
float strength =
clamp(
uNoseStrength,
0.0,
1.0
);
strength =
1.0 -
pow(
1.0 - strength,
1.35
);
即:
S(s)=1−(1−s)1.35
相比直接使用线性强度,这条曲线会让中低档位的变化更加容易被感知,同时高档位不会继续无限放大。
最终局部位移为:
ini
float displacement =
localX
* warpWeight
* strength
* kNoseWarpRatio;
其中:
ini
const float kNoseWarpRatio = 0.28;
所以:
xl⋅W⋅S(s)⋅R
其中:
R=0.28
这里再次体现出 localX 的作用。
当 Fragment 位于鼻子左侧:
xl<0
于是:
D<0
位于鼻子右侧:
xl>0
于是:
D>0
鼻梁中轴:
xl=0
则:
D=0
因此左右方向根本不需要额外通过 if 判断。
局部坐标本身已经同时编码了距离和方向。
接下来将这个一维位移重新投影回二维人脸空间:
ini
vec2 warpDelta =
uFaceXAxis
* displacement;
也就是:
Dx^face
这样无论用户如何歪头,Warp 始终沿着人脸真正的横向方向进行。
由于前面的几何空间为了修正宽高比,对 X 做过 Aspect Correction,所以这里还需要恢复成普通 UV:
scss
warpDelta /=
vec2(aspect, 1.0);
最后:
scss
return clamp(
textureCoordinate + warpDelta,
vec2(0.0),
vec2(1.0)
);
得到:
UVcamera+ΔUV
完整地连起来,就是:
UVcamera+x^face⋅xl⋅W(x,y)⋅S(s)⋅R
其中:
(1−nx2)e−2.5ny21−smoothstep(0.6,1,nx2+ny2)
这两条公式基本就可以完整概括当前瘦鼻 Shader。
从算法角度看,它可以描述为:
一个建立在人脸局部坐标系中的、由 Nose Ellipse 限制作用范围,并通过连续权重场控制位移大小的局部反向纹理 Warp。
它并没有真的对鼻子执行缩放,而是改变了鼻部区域内不同 Fragment 对 Camera Texture 的采样位置。
六、瘦鼻之后,后续美颜要继续沿用新的采样空间
当瘦鼻和磨皮、腮红放在同一个 Shader 中时,还有一个很重要的问题:
瘦鼻完成以后,当前 Fragment 所对应的 Camera Texture 坐标已经改变了。
例如:
ini
vec2 sourceTexCoord =
applyNoseSlim(
vTexCoord,
vOutputTexCoord
);
vec4 sourceColor =
texture(
uTexture,
sourceTexCoord
);
此时:
sourceTexCoord
才是当前输出像素真正对应的原始纹理位置。
如果接下来还要进行双边磨皮,那么邻域采样应该继续围绕这个坐标:
ini
vec2 sampleUV =
sourceTexCoord
+ offset
* uTexelSize
* kSampleSpacing;
而不能重新回到:
vTexCoord
否则就会出现:
markdown
中心像素
↓
已经使用 Warp 后的 UV
邻域像素
↓
仍然围绕原始 Camera UV 采样
也就是说,同一次滤波中同时出现了两个采样空间。
更合理的处理顺序应该是:
css
vTexCoord
↓
Nose Warp
↓
sourceTexCoord
↓
Smooth
↓
Rouge / Color Effect
↓
Final Color
这里其实揭示了几何美颜和颜色美颜之间一个非常重要的区别。
磨皮、腮红通常是在:
当前采样空间中修改颜色。
而瘦鼻改变的却是:
后续所有颜色处理所依赖的采样空间本身。
因此,当多个美颜效果组合以后,不能只考虑函数的调用顺序,还要考虑每一个效果到底工作在哪一套纹理坐标中。
完整 GLSL
最终的瘦鼻函数如下:
scss
/*
根据鼻部区域计算瘦鼻后的纹理采样坐标。
几何计算工作在最终输出坐标系,
最终产生的 Warp 作用到 Camera Texture 采样坐标。
*/
vec2 applyNoseSlim(
vec2 textureCoordinate,
vec2 outputCoordinate
) {
if (uNoseStrength <= 0.0001) {
return textureCoordinate;
}
if (uNoseRadius.x <= 0.0001 ||
uNoseRadius.y <= 0.0001 ||
length(uFaceXAxis) <= 0.0001 ||
length(uFaceYAxis) <= 0.0001) {
return textureCoordinate;
}
/*
uTexelSize:
x = 1 / width
y = 1 / height
aspect = width / height
*/
float aspect =
uTexelSize.y /
max(uTexelSize.x, 0.000001);
/*
当前 Fragment 相对于 Nose Center 的位置,
并转换到横纵尺度一致的几何空间。
*/
vec2 delta =
(outputCoordinate - uNoseCenter)
* vec2(aspect, 1.0);
/*
投影到人脸局部坐标系。
*/
float localX =
dot(delta, uFaceXAxis);
float localY =
dot(delta, uFaceYAxis);
/*
使用 Nose Radius 将真实鼻部
归一化到标准椭圆空间。
*/
float nx =
localX /
max(uNoseRadius.x, 0.0001);
float ny =
localY /
max(uNoseRadius.y, 0.0001);
float distanceSquared =
nx * nx +
ny * ny;
/*
Nose Ellipse 之外不发生形变。
*/
if (distanceSquared >= 1.0) {
return textureCoordinate;
}
/*
横向形变分布。
*/
float horizontalWeight =
max(
1.0 - nx * nx,
0.0
);
/*
鼻部纵向高斯衰减。
*/
float verticalWeight =
exp(
-ny * ny * 2.5
);
/*
整个 Nose Ellipse 的边缘羽化。
*/
float boundaryWeight =
1.0 -
smoothstep(
0.60,
1.0,
distanceSquared
);
float warpWeight =
horizontalWeight
* verticalWeight
* boundaryWeight;
/*
用户强度曲线。
*/
float strength =
clamp(
uNoseStrength,
0.0,
1.0
);
strength =
1.0 -
pow(
1.0 - strength,
1.35
);
const float kNoseWarpRatio =
0.28;
/*
localX 同时决定位移大小与左右方向。
*/
float displacement =
localX
* warpWeight
* strength
* kNoseWarpRatio;
/*
沿人脸真实横轴产生 Warp。
*/
vec2 warpDelta =
uFaceXAxis
* displacement;
/*
从等比例几何空间恢复普通 UV。
*/
warpDelta /=
vec2(aspect, 1.0);
/*
重新定义 Camera Texture 的采样位置。
*/
return clamp(
textureCoordinate + warpDelta,
vec2(0.0),
vec2(1.0)
);
}
效果
处理前:

处理后:

处理前:

处理后:

总结
如果把所有工程细节都去掉,这套实时瘦鼻算法其实只做了两件事情。
第一件事情,是利用 Face Landmark 把一个不断旋转、尺寸不断变化的真实鼻子,转换成一个稳定的局部空间:
scss
真实人脸
↓
Nose Center
Nose Radius
Face Axis
↓
标准 Nose Ellipse
第二件事情,则是在这个标准空间中定义一张连续的位移场:
鼻梁中轴
↓
位移从 0 开始
↓
鼻翼附近逐渐增强
↓
靠近 Warp Region 外围逐渐衰减
↓
边界重新回到 0
最后将这张位移场沿着人脸横轴作用到 Camera Texture 的采样坐标上。
因此瘦鼻真正值得关注的,并不是:
ini
textureCoordinate += offset;
这样的 GLSL 写法本身。
真正决定最终效果的是:
如何根据真实人脸结构,建立稳定的局部坐标系,并在这个空间里构造一张合理、连续的纹理位移场。
一旦把这个问题解决以后,瘦鼻只是其中一种具体形态。
瘦脸可以把局部坐标系放到脸颊区域,大眼可以围绕眼睛中心建立径向位移场,下巴调整可以沿 Face Y Axis 构造纵向 Warp。
它们背后的数学模型其实高度统一:
sql
Landmark
↓
Local Space
↓
Warp Region
↓
Continuous Displacement Field
↓
Inverse Texture Sampling
而这,才是实时人脸几何美型 Shader 真正可以复用的核心。