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

0 阅读6分钟

image.png

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

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

在实时相机中,这件事情并不适合直接修改屏幕上的几何顶点。人脸会旋转,相机可能镜像,不同人的鼻子尺寸不同,最终输出画面的宽高比也可能变化。如果直接围绕屏幕 X/Y 坐标做形变,很快就会遇到方向和尺度不一致的问题。

因此,这套瘦鼻算法采用的是另一种思路:

Face Landmark
      ↓
确定 Nose Center / Nose Radius
      ↓
建立人脸局部坐标系
      ↓
在鼻部椭圆中计算连续 Warp 权重
      ↓
得到 Camera Texture 的采样偏移
      ↓
重新采样原始画面

整个算法最终没有真正“移动”任何一个 Fragment。

它真正改变的是:

当前这个 Fragment,应该去原始 Camera Texture 的什么位置读取颜色。

理解这一点,基本就理解了整个瘦鼻 Shader。

一、瘦鼻的本质是一次反向纹理采样

正常渲染时,当前输出位置通常直接使用对应的 Camera Texture 坐标:

vec4 color =
    texture(
        uTexture,
        textureCoordinate
    );

也就是:

Cout(p)=Csrc(p)C_{out}(p)=C_{src}(p)

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

几何 Warp 所改变的就是这层映射:

Csrc(p+Δp)C_{src}(p+\Delta p)

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

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

textureCoordinate + warpDelta

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

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

假设鼻梁中心位于:

x=0x=0

对于鼻子右侧的一个输出像素,如果我们让它去原图中稍微更靠右的位置读取颜色:

xs=x+Δx,Δx>0x_s=x+\Delta x,\qquad \Delta x>0

那么原本更加靠外的鼻翼纹理,就会提前出现在更加靠内的位置。

于是虽然采样向外移动了,视觉中的鼻翼却是在向内收缩

左侧完全相同,只不过偏移方向相反。

因此最终形成的是:

原始鼻部

|---------------|

Warp 后

   |---------|

这就是典型的 Inverse Texture Mapping

瘦脸、大眼、局部拉伸等大量实时美型算法,本质上都建立在同一个思想上:

不改变输出像素的位置,而是重新定义输出像素和原始纹理之间的采样关系。

瘦鼻真正需要解决的,也就变成了一个很明确的问题:

对于鼻子区域里的每一个 Fragment,应该计算多大的纹理采样偏移?

二、先把鼻子放到一个稳定的人脸局部空间里

如果人脸永远保持竖直,那么瘦鼻似乎只需要修改:

textureCoordinate.x

但实际使用时,人脸一定会发生 Roll。

正常情况下,人脸的左右方向大致和屏幕 X 轴一致:

左眼 -------- 右眼
        │
       鼻子

歪头以后:

          右眼
         /
       鼻子
      /
   左眼

这时屏幕 X 轴已经不再代表鼻子的左右方向。

如果继续修改 textureCoordinate.x,最终拉扯的就是屏幕水平方向,而不是鼻翼真正应该收缩的方向。

因此 CPU 会根据 Face Landmark 先计算两条方向:

uFaceXAxis
uFaceYAxis

它们共同构成一套附着在人脸上的局部坐标系:

                  Face Y
                    ↑
                    │
                    │
Face -X  ←────── Nose ──────→ Face +X
                    │
                    │
                    ↓

其中 uFaceXAxis 表示人脸左右方向,uFaceYAxis 表示人脸上下方向。

人脸发生 Roll 时,这套坐标轴也一起旋转。

这样 Shader 后面就不需要关心“当前人脸到底歪了多少度”,而只需要把当前 Fragment 转换到这套局部坐标系中。

瘦鼻函数因此同时接收两个坐标:

vec2 applyNoseSlim(
    vec2 textureCoordinate,
    vec2 outputCoordinate
)

这里的两个坐标有明确的分工。

outputCoordinate 用来处理几何问题:

当前 Fragment 相对于鼻子中心处于什么位置?

textureCoordinate 则用来处理最终采样:

这个 Fragment 应该去 Camera Texture 的什么位置取颜色?

所以几何计算首先从:

outputCoordinate - uNoseCenter

开始。

不过这里还有一个问题。

UV 坐标本身并不是一个横纵等比例的空间。

假设输出尺寸为:

1080×19201080\times1920

横向 UV 移动 0.10.1 实际对应 108 个像素,而纵向移动 0.10.1 却对应 192 个像素。

如果直接在 UV 空间里计算距离,那么几何尺度会随着输出宽高比变化。

因此 Shader 先利用:

uTexelSize.x = 1.0 / width;
uTexelSize.y = 1.0 / height;

得到:

float aspect =
    uTexelSize.y /
    max(uTexelSize.x, 0.000001);

也就是:

aspect=WHaspect=\frac{W}{H}

然后对 X 方向进行修正:

vec2 delta =
    (outputCoordinate - uNoseCenter)
    * vec2(aspect, 1.0);

这样得到的 delta 已经位于一个横纵尺度统一的几何空间中。

接下来只需要做两次点积:

float localX =
    dot(delta, uFaceXAxis);

float localY =
    dot(delta, uFaceYAxis);

就可以得到当前 Fragment 在人脸局部空间中的位置:

Δx^face\Delta\cdot\hat{x}_{face}
Δy^face\Delta\cdot\hat{y}_{face}

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

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

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

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

不同人的鼻子大小不同,如果直接根据 localX 和 localY 做形变,那么同样一套 Shader 参数在不同人脸上代表的实际范围完全不同。

因此 CPU 还会根据 Landmark 提供:

uNoseRadius.x
uNoseRadius.y

分别表示鼻部 Warp 区域的横向半径和纵向半径。

Shader 将局部坐标进一步标准化:

float nx =
    localX /
    max(uNoseRadius.x, 0.0001);

float ny =
    localY /
    max(uNoseRadius.y, 0.0001);

即:

nx=xlrxn_x=\frac{x_l}{r_x}
ny=ylryn_y=\frac{y_l}{r_y}

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

随后计算:

float distanceSquared =
    nx * nx +
    ny * ny;

也就是:

nx2+ny2=xl2rx2+yl2ry2n_x^2+n_y^2 = \frac{x_l^2}{r_x^2} + \frac{y_l^2}{r_y^2}

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

于是:

if (distanceSquared >= 1.0) {
    return textureCoordinate;
}

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

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

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

rx,ryr_x,\qquad r_y

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

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

CPU 利用 Landmark 告诉 GPU:

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

而 GPU 不再关心 Landmark 本身。

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

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

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

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

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

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

D=kD=k

而椭圆外部:

D=0D=0

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

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

所以瘦鼻不能只是定义:

鼻子里面移动多少。

更重要的是定义:

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

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

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

float horizontalWeight =
    max(
        1.0 - nx * nx,
        0.0
    );

即:

Wh=1nx2W_h=1-n_x^2

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

但最终位移还会乘上 localX

localX * horizontalWeight

所以鼻梁中心虽然:

Wh=1W_h=1

但:

localX=0localX=0

最终位移依然是 0。

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

可以把它简单理解成:

鼻梁中心        鼻翼附近        外部边缘

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

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

float verticalWeight =
    exp(
        -ny * ny * 2.5
    );

即:

Wv=e2.5ny2W_v=e^{-2.5n_y^2}

这是一个高斯型衰减。

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

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

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

float boundaryWeight =
    1.0 -
    smoothstep(
        0.60,
        1.0,
        distanceSquared
    );

当:

d20.6d^2\le0.6

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

从:

d2=0.6d^2=0.6

开始,Warp 逐渐进入羽化区,最终在:

d2=1d^2=1

也就是 Nose Ellipse 边缘回到 0。

最终三组权重相乘:

float warpWeight =
    horizontalWeight
    * verticalWeight
    * boundaryWeight;

得到:

(1nx2)e2.5ny2[1smoothstep(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]

这条公式实际上就是整个瘦鼻算法的核心。

它定义的并不是一个简单的“强度值”,而是一张覆盖鼻部椭圆的连续二维位移权重场。

在鼻梁中轴附近,最终位移因为 localX 接近 0 而自然消失;

向鼻翼方向移动时,位移逐渐增大;

再继续接近 Warp Region 外围时,高斯和 smoothstep 又让位移重新平滑衰减到 0。

因此鼻部纹理不会突然被切开,而是从原始画面连续过渡到形变区域,再连续回到原始画面。

这也是局部 Warp 看起来是否自然的关键。

五、从权重场得到最终的 Camera Texture 偏移

得到 warpWeight 以后,剩下的事情反而很简单。

首先将用户输入的:

uNoseStrength

做一次非线性映射:

float strength =
    clamp(
        uNoseStrength,
        0.0,
        1.0
    );

strength =
    1.0 -
    pow(
        1.0 - strength,
        1.35
    );

即:

S(s)=1(1s)1.35S(s)=1-(1-s)^{1.35}

相比直接使用线性强度,这条曲线会让中低档位的变化更加容易被感知,同时高档位不会继续无限放大。

最终局部位移为:

float displacement =
    localX
    * warpWeight
    * strength
    * kNoseWarpRatio;

其中:

const float kNoseWarpRatio = 0.28;

所以:

xlWS(s)Rx_l \cdot W \cdot S(s) \cdot R

其中:

R=0.28R=0.28

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

当 Fragment 位于鼻子左侧:

xl<0x_l<0

于是:

D<0D<0

位于鼻子右侧:

xl>0x_l>0

于是:

D>0D>0

鼻梁中轴:

xl=0x_l=0

则:

D=0D=0

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

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

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

vec2 warpDelta =
    uFaceXAxis
    * displacement;

也就是:

Dx^faceD\hat{x}_{face}

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

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

warpDelta /=
    vec2(aspect, 1.0);

最后:

return clamp(
    textureCoordinate + warpDelta,
    vec2(0.0),
    vec2(1.0)
);

得到:

UVcamera+ΔUVUV_{camera} + \Delta UV

完整地连起来,就是:

UVcamera+x^facexlW(x,y)S(s)RUV_{camera} + \hat{x}_{face} \cdot x_l \cdot W(x,y) \cdot S(s) \cdot R

其中:

(1nx2)e2.5ny2[1smoothstep(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]

这两条公式基本就可以完整概括当前瘦鼻 Shader。

从算法角度看,它可以描述为:

一个建立在人脸局部坐标系中的、由 Nose Ellipse 限制作用范围,并通过连续权重场控制位移大小的局部反向纹理 Warp。

它并没有真的对鼻子执行缩放,而是改变了鼻部区域内不同 Fragment 对 Camera Texture 的采样位置。

六、瘦鼻之后,后续美颜要继续沿用新的采样空间

当瘦鼻和磨皮、腮红放在同一个 Shader 中时,还有一个很重要的问题:

瘦鼻完成以后,当前 Fragment 所对应的 Camera Texture 坐标已经改变了。

例如:

vec2 sourceTexCoord =
    applyNoseSlim(
        vTexCoord,
        vOutputTexCoord
    );

vec4 sourceColor =
    texture(
        uTexture,
        sourceTexCoord
    );

此时:

sourceTexCoord

才是当前输出像素真正对应的原始纹理位置。

如果接下来还要进行双边磨皮,那么邻域采样应该继续围绕这个坐标:

vec2 sampleUV =
    sourceTexCoord
    + offset
    * uTexelSize
    * kSampleSpacing;

而不能重新回到:

vTexCoord

否则就会出现:

中心像素
    ↓
已经使用 Warp 后的 UV

邻域像素
    ↓
仍然围绕原始 Camera UV 采样

也就是说,同一次滤波中同时出现了两个采样空间。

更合理的处理顺序应该是:

vTexCoord
    ↓
Nose Warp
    ↓
sourceTexCoord
    ↓
Smooth
    ↓
Rouge / Color Effect
    ↓
Final Color

这里其实揭示了几何美颜和颜色美颜之间一个非常重要的区别。

磨皮、腮红通常是在:

当前采样空间中修改颜色。

而瘦鼻改变的却是:

后续所有颜色处理所依赖的采样空间本身。

因此,当多个美颜效果组合以后,不能只考虑函数的调用顺序,还要考虑每一个效果到底工作在哪一套纹理坐标中。

完整 GLSL

最终的瘦鼻函数如下:

/*
 根据鼻部区域计算瘦鼻后的纹理采样坐标。

 几何计算工作在最终输出坐标系,
 最终产生的 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 把一个不断旋转、尺寸不断变化的真实鼻子,转换成一个稳定的局部空间:

真实人脸
   ↓
Nose Center
Nose Radius
Face Axis
   ↓
标准 Nose Ellipse

第二件事情,则是在这个标准空间中定义一张连续的位移场:

鼻梁中轴
   ↓
位移从 0 开始
   ↓
鼻翼附近逐渐增强
   ↓
靠近 Warp Region 外围逐渐衰减
   ↓
边界重新回到 0

最后将这张位移场沿着人脸横轴作用到 Camera Texture 的采样坐标上。

因此瘦鼻真正值得关注的,并不是:

textureCoordinate += offset;

这样的 GLSL 写法本身。

真正决定最终效果的是:

如何根据真实人脸结构,建立稳定的局部坐标系,并在这个空间里构造一张合理、连续的纹理位移场。

一旦把这个问题解决以后,瘦鼻只是其中一种具体形态。

瘦脸可以把局部坐标系放到脸颊区域,大眼可以围绕眼睛中心建立径向位移场,下巴调整可以沿 Face Y Axis 构造纵向 Warp。

它们背后的数学模型其实高度统一:

Landmark
   ↓
Local Space
   ↓
Warp Region
   ↓
Continuous Displacement Field
   ↓
Inverse Texture Sampling

而这,才是实时人脸几何美型 Shader 真正可以复用的核心。