从纹理采样到局部形变:实时瘦鼻 Shader 算法解析

实时美颜里的磨皮、调色、腮红,本质上主要是在改变像素的颜色,而瘦鼻、瘦脸、大眼这一类功能处理的是另一个问题:如何改变画面中的局部几何结构。

以瘦鼻为例,我们真正希望看到的效果,并不是把鼻子所在的一块图像简单缩小,而是让鼻翼两侧的视觉轮廓向鼻梁中间收拢,同时保持鼻梁、鼻尖以及周围皮肤纹理连续。

在实时相机中,这件事情并不适合直接修改屏幕上的几何顶点。人脸会旋转,相机可能镜像,不同人的鼻子尺寸不同,最终输出画面的宽高比也可能变化。如果直接围绕屏幕 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) C_{out}(p)=C_{src}(p) Cout(p)=Csrc(p)

输出位置 pp p 显示原图中同一个位置的颜色。

几何 Warp 所改变的就是这层映射:
Csrc (p+Δp) C_{src}(p+\Delta p) Csrc(p+Δp)

屏幕上的 Fragment 本身没有移动,只是它读取原图的位置发生了变化。

Shader 最终真正修改的其实就是:

复制代码
textureCoordinate + warpDelta

其中 warpDelta 是当前 Fragment 的纹理采样偏移。

这也是为什么瘦鼻时,纹理的采样方向和最终看到的几何移动方向看起来是相反的。

假设鼻梁中心位于:
x=0x=0 x=0

对于鼻子右侧的一个输出像素,如果我们让它去原图中稍微更靠右的位置读取颜色:
xs=x+Δx,Δx>0 x_s=x+\Delta x,\qquad \Delta 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×19201080\times1920 1080×1920

横向 UV 移动 0.10.1 0.1 实际对应 108 个像素,而纵向移动 0.10.1 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=WHaspect=\frac{W}{H} 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 \Delta\cdot\hat{x}{face} Δ⋅x^face
Δ⋅ y^face \Delta\cdot\hat{y}
{face} Δ⋅y^face

从这一刻开始,屏幕坐标基本就退出了算法。

后面的瘦鼻逻辑面对的始终都是一个"摆正以后"的标准鼻子。

三、把真实鼻子统一映射成一个标准 Nose Ellipse

解决方向以后,还需要解决尺寸。

不同人的鼻子大小不同,如果直接根据 localXlocalY 做形变,那么同样一套 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= xlrx n_x=\frac{x_l}{r_x} nx=rxxl
ny= ylry n_y=\frac{y_l}{r_y} ny=ryyl

这样,无论真实鼻子有多大,都会被映射到相同的标准空间中。

随后计算:

ini 复制代码
float distanceSquared =
    nx * nx +
    ny * ny;

也就是:
nx2+ny2= xl2rx2 + yl2ry2 n_x^2+n_y^2 = \frac{x_l^2}{r_x^2} + \frac{y_l^2}{r_y^2} nx2+ny2=rx2xl2+ry2yl2

这正好对应一个标准椭圆。

于是:

kotlin 复制代码
if (distanceSquared >= 1.0) {
    return textureCoordinate;
}

就可以把整个 Warp 限制在鼻部区域之内。

这里采用椭圆而不是圆形并不是单纯为了调参数方便。

真实鼻部结构本身就是明显的纵向结构,横向和纵向的有效范围并不相同。通过两个独立半径:
rx,ry r_x,\qquad r_y rx,ry

能够让 Warp Region 更符合实际鼻子的形状。

到这里,CPU 和 Shader 的职责已经分得很清楚:

CPU 利用 Landmark 告诉 GPU:

复制代码
鼻子在哪里
鼻子有多大
人脸左右方向在哪里
人脸上下方向在哪里

而 GPU 不再关心 Landmark 本身。

对于 GPU 来说,现在只剩下一个标准问题:

nx2+ny2<1 n_x^2+n_y^2<1 nx2+ny2<1 的椭圆内部,为每一个 Fragment 计算一个连续的横向位移。

这才真正进入瘦鼻算法最核心的部分。

四、真正决定效果的是这张连续位移场

局部 Warp 最重要的并不是"能够移动纹理",而是位移必须连续

如果整个 Nose Ellipse 内部直接使用固定偏移:
D=kD=k D=k

而椭圆外部:
D=0D=0 D=0

那么在边界位置,纹理采样坐标会发生瞬时跳变。

最终看到的就是折痕、拉丝或者明显的纹理断层。

所以瘦鼻不能只是定义:

鼻子里面移动多少。

更重要的是定义:

从鼻梁中心到鼻翼,再到 Warp Region 边缘,位移应该怎样连续变化。

当前算法使用了三个相互配合的权重。

首先是左右方向的横向权重:

ini 复制代码
float horizontalWeight =
    max(
        1.0 - nx * nx,
        0.0
    );

即:
Wh=1−nx2 W_h=1-n_x^2 Wh=1−nx2

它随着 Fragment 远离鼻梁中轴逐渐下降。

但最终位移还会乘上 localX

复制代码
localX * horizontalWeight

所以鼻梁中心虽然:
Wh=1 W_h=1 Wh=1

但:
localX=0localX=0 localX=0

最终位移依然是 0。

Fragment 从鼻梁向外移动以后, ∣localX∣|localX| ∣localX∣ 开始增大,而 Wh W_h Wh 又逐渐减弱,于是两者结合以后自然形成一个中间区域较强、两端较弱的形变分布。

可以把它简单理解成:

markdown 复制代码
鼻梁中心        鼻翼附近        外部边缘

    0   ───→   逐渐增强   ───→   逐渐回到 0

第二个权重负责鼻子的上下方向:

ini 复制代码
float verticalWeight =
    exp(
        -ny * ny * 2.5
    );

即:
Wv=e −2.5ny2 W_v=e^{-2.5n_y^2} Wv=e−2.5ny2

这是一个高斯型衰减。

越接近鼻部纵向中心,权重越大;越靠近椭圆的上下两端,权重越低。

这样就不会让整根鼻梁从上到下都产生相同幅度的横向形变,而是让 Warp 更集中在鼻部真正需要调整的区域。

第三个权重则负责整个 Nose Ellipse 的边缘:

ini 复制代码
float boundaryWeight =
    1.0 -
    smoothstep(
        0.60,
        1.0,
        distanceSquared
    );

当:
d2≤0.6d^2\le0.6 d2≤0.6

时,核心区域保持完整权重。

从:
d2=0.6d^2=0.6 d2=0.6

开始,Warp 逐渐进入羽化区,最终在:
d2=1d^2=1 d2=1

也就是 Nose Ellipse 边缘回到 0。

最终三组权重相乘:

ini 复制代码
float warpWeight =
    horizontalWeight
    * verticalWeight
    * boundaryWeight;

得到:
(1−nx2)e −2.5ny2 1−smoothstep(0.6,1,nx2+ny2) (1-n_x^2) e^{-2.5n_y^2} \left 1- smoothstep \\left( 0.6, 1, n_x\^2+n_y\^2 \\right) \\right (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.35S(s)=1-(1-s)^{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 x_l \cdot W \cdot S(s) \cdot R xl⋅W⋅S(s)⋅R

其中:
R=0.28R=0.28 R=0.28

这里再次体现出 localX 的作用。

当 Fragment 位于鼻子左侧:
xl<0 x_l<0 xl<0

于是:
D<0D<0 D<0

位于鼻子右侧:
xl>0 x_l>0 xl>0

于是:
D>0D>0 D>0

鼻梁中轴:
xl=0 x_l=0 xl=0

则:
D=0D=0 D=0

因此左右方向根本不需要额外通过 if 判断。

局部坐标本身已经同时编码了距离和方向。

接下来将这个一维位移重新投影回二维人脸空间:

ini 复制代码
vec2 warpDelta =
    uFaceXAxis
    * displacement;

也就是:
D x^face D\hat{x}_{face} Dx^face

这样无论用户如何歪头,Warp 始终沿着人脸真正的横向方向进行。

由于前面的几何空间为了修正宽高比,对 X 做过 Aspect Correction,所以这里还需要恢复成普通 UV:

scss 复制代码
warpDelta /=
    vec2(aspect, 1.0);

最后:

scss 复制代码
return clamp(
    textureCoordinate + warpDelta,
    vec2(0.0),
    vec2(1.0)
);

得到:
U Vcamera +ΔUV UV_{camera} + \Delta UV UVcamera+ΔUV

完整地连起来,就是:
U Vcamera + x^face ⋅xl⋅W(x,y)⋅S(s)⋅R UV_{camera} + \hat{x}_{face} \cdot x_l \cdot W(x,y) \cdot S(s) \cdot R UVcamera+x^face⋅xl⋅W(x,y)⋅S(s)⋅R

其中:
(1−nx2)e −2.5ny2 1−smoothstep(0.6,1,nx2+ny2) (1-n_x^2) e^{-2.5n_y^2} \left 1- smoothstep \\left( 0.6, 1, n_x\^2+n_y\^2 \\right) \\right (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 真正可以复用的核心。

相关推荐
奈斯先生Vector2 小时前
从“能调用”到“可替换”:Coze 多模型 API 编排层设计指南
linux·运维·人工智能·ubuntu·平面·ios
DogDaoDao2 小时前
HarmonyOS 深度解析:从微内核到 ArkTS 工程实战
android·linux·ios·华为·程序员·harmonyos·harmonyos next
Patrick_Wilson14 小时前
iOS Safari Clipboard API 权限弹窗问题
前端·ios·safari
_瑞17 小时前
APM_符号化原理
ios·汇编语言
花椒技术19 小时前
不是跑通一次 Demo:花椒 QA 怎样把真机 UI 自动化做成回归流程
ios·单元测试·测试
2501_915918411 天前
Swift IDE 有哪些?按平台和需求选择Swift 开发工具
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
songgeb1 天前
UITableView 局部刷新 Crash:Invalid update
ios·架构
2501_916007471 天前
免费抓包工具 Windows 和 Mac 怎么选?根据平台来选择工具
网络协议·计算机网络·网络安全·ios·adb·https·udp
念何架构之路1 天前
响应渲染 render(render/ 包)
ios·iphone·xcode