跪拜 Guibai
← Back to the summary

Nose Slimming in Real-Time Shaders Is Just a Continuous Displacement Field


theme: channing-cyan

image.png

Skin smoothing, color grading, and blush in real-time beautification essentially change pixel colors. Functions like nose slimming, face slimming, and eye enlargement tackle a different problem: how to alter the local geometric structure within the frame.

For nose slimming, the desired effect isn't simply shrinking the nose region's image block. It's drawing the visual contours of the nostrils inward toward the bridge while keeping the bridge, tip, and surrounding skin texture continuous.

In a real-time camera, directly modifying geometric vertices on screen is unsuitable. Faces rotate, the camera may mirror, nose sizes vary across people, and the final output's aspect ratio can change. Warping directly around screen X/Y coordinates quickly leads to mismatched direction and scale.

So this nose-slimming algorithm takes a different path:

Face Landmark
      ↓
 Determine Nose Center / Nose Radius
      ↓
 Build a face-local coordinate system
      ↓
 Compute continuous warp weights inside a nose ellipse
      ↓
 Derive a sampling offset for the Camera Texture
      ↓
 Resample the original frame

The entire algorithm never truly "moves" any Fragment.

What it actually changes is:

Where this Fragment should read its color from in the original Camera Texture.

Grasp that, and you essentially grasp the whole nose-slimming Shader.

1. Nose Slimming Is Inverse Texture Sampling

In normal rendering, the current output position typically uses the corresponding Camera Texture coordinate directly:

vec4 color =
    texture(
        uTexture,
        textureCoordinate
    );

That is:

$$ C_{out}(p)=C_{src}(p)
$$

Output position $p$ displays the color from the same position in the source image.

Geometric Warp changes this mapping:

$$ C_{src}(p+\Delta p)
$$

The Fragment on screen hasn't moved; only the position from which it reads the source image has changed.

What the Shader ultimately modifies is:

textureCoordinate + warpDelta

where warpDelta is the texture sampling offset for the current Fragment.

This is also why the texture sampling direction and the perceived geometric movement appear opposite during nose slimming.

Suppose the nose bridge center lies at:

$$ x=0
$$

For an output pixel on the right side of the nose, if we make it read color from a position slightly further right in the source image:

$$ x_s=x+\Delta x,\qquad \Delta x>0
$$

Then the nostril texture that was originally further outward appears earlier, at a more inward position.

So although sampling moved outward, visually the nostril is contracting inward.

The left side is identical, just with the offset direction reversed.

Thus the final result is:

Original Nose

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

After Warp

   |---------|

This is classic Inverse Texture Mapping.

Face slimming, eye enlargement, local stretching, and many other real-time beautification algorithms are all built on the same idea:

Don't change the output pixel's position; redefine the sampling relationship between the output pixel and the original texture.

Nose slimming then boils down to a very clear problem:

For every Fragment inside the nose region, how large a texture sampling offset should be computed?

2. First, Place the Nose in a Stable Face-Local Space

If faces always stayed upright, nose slimming would seemingly only need to modify:

textureCoordinate.x

But in practice, faces will Roll.

Normally, the face's left-right direction roughly aligns with the screen's X-axis:

Left Eye -------- Right Eye
        │
       Nose

After tilting the head:

           Right Eye
         /
       Nose
      /
   Left Eye

Now the screen's X-axis no longer represents the nose's left-right direction.

If you keep modifying textureCoordinate.x, the pull ends up along the screen's horizontal direction, not the direction the nostrils should actually contract.

So the CPU first computes two directions from the Face Landmark:

uFaceXAxis
uFaceYAxis

Together they form a local coordinate system attached to the face:

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

Here uFaceXAxis represents the face's left-right direction, and uFaceYAxis represents the face's up-down direction.

When the face Rolls, these axes rotate with it.

This way the Shader doesn't need to care about "how many degrees the face is currently tilted"; it just converts the current Fragment into this local coordinate system.

The nose-slimming function therefore receives two coordinates:

vec2 applyNoseSlim(
    vec2 textureCoordinate,
    vec2 outputCoordinate
)

These two coordinates have distinct roles.

outputCoordinate handles the geometry problem:

Where is the current Fragment relative to the nose center?

textureCoordinate handles the final sampling:

Where in the Camera Texture should this Fragment fetch its color?

So the geometric calculation starts from:

outputCoordinate - uNoseCenter

But there's another issue here.

UV coordinates themselves are not a space with equal horizontal and vertical scale.

Suppose the output dimensions are:

$$ 1080\times1920
$$

A horizontal UV shift of $0.1$ corresponds to 108 pixels, while a vertical shift of $0.1$ corresponds to 192 pixels.

If you compute distance directly in UV space, the geometric scale changes with the output aspect ratio.

So the Shader first uses:

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

to get:

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

That is:

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

Then it corrects the X direction:

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

The resulting delta now sits in a geometric space with uniform horizontal and vertical scale.

Next, just two dot products:

float localX =
    dot(delta, uFaceXAxis);

float localY =
    dot(delta, uFaceYAxis);

give the current Fragment's position in the face-local space:

$$ \Delta\cdot\hat{x}_{face}
$$

$$ \Delta\cdot\hat{y}_{face}
$$

From this moment on, screen coordinates essentially exit the algorithm.

The subsequent nose-slimming logic always faces a "straightened" standard nose.

3. Map Every Real Nose into a Standard Nose Ellipse

After solving direction, you still need to solve size.

Different people have different nose sizes. If you warp directly based on localX and localY, the same Shader parameters represent completely different actual ranges on different faces.

So the CPU also provides, based on Landmark:

uNoseRadius.x
uNoseRadius.y

representing the horizontal and vertical radii of the nose warp region.

The Shader further normalizes the local coordinates:

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

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

That is:

$$ n_x=\frac{x_l}{r_x}
$$

$$ n_y=\frac{y_l}{r_y}
$$

Now, no matter how large the real nose is, it gets mapped into the same standard space.

Then compute:

float distanceSquared =
    nx * nx +
    ny * ny;

That is:

$$ n_x^2+n_y^2

\frac{x_l^2}{r_x^2}
+
\frac{y_l^2}{r_y^2}
$$

This corresponds exactly to a standard ellipse.

Thus:

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

confines the entire warp within the nose region.

Using an ellipse rather than a circle isn't just for parameter-tuning convenience.

The real nose structure is distinctly longitudinal; the effective horizontal and vertical ranges differ. Two independent radii:

$$ r_x,\qquad r_y
$$

let the Warp Region better match the actual nose shape.

At this point, the CPU and Shader responsibilities are clearly divided:

The CPU uses Landmark to tell the GPU:

Where the nose is
How large the nose is
Where the face's left-right direction is
Where the face's up-down direction is

And the GPU no longer cares about the Landmark itself.

For the GPU, only one standard problem remains:

Inside the ellipse where $n_x^2+n_y^2<1$, compute a continuous horizontal displacement for every Fragment.

This is where the core of the nose-slimming algorithm truly begins.

4. What Really Determines the Effect Is This Continuous Displacement Field

The most important thing for local Warp isn't "being able to move texture" — it's that the displacement must be continuous.

If the entire Nose Ellipse interior used a fixed offset:

$$ D=k
$$

and the exterior:

$$ D=0
$$

then at the boundary, the texture sampling coordinates would jump instantaneously.

The visible result would be creases, streaks, or obvious texture discontinuities.

So nose slimming can't just define:

How much to move inside the nose.

More importantly, it must define:

From the nose bridge center to the nostrils, and then to the Warp Region edge, how should the displacement vary continuously.

The current algorithm uses three cooperating weights.

First, a horizontal weight for the left-right direction:

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

That is:

$$ W_h=1-n_x^2
$$

It gradually decreases as the Fragment moves away from the nose bridge center axis.

But the final displacement is also multiplied by localX:

localX * horizontalWeight

So at the nose bridge center, although:

$$ W_h=1
$$

we have:

$$ localX=0
$$

and the final displacement is still 0.

As the Fragment moves outward from the bridge, $|localX|$ starts increasing while $W_h$ gradually weakens. The combination naturally forms a deformation distribution that is stronger in the middle region and weaker at both ends.

You can roughly understand it as:

Nose Bridge Center    Near Nostrils        Outer Edge

    0   ───→   Gradually Strengthens   ───→   Gradually Returns to 0

The second weight handles the nose's up-down direction:

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

That is:

$$ W_v=e^{-2.5n_y^2}
$$

This is a Gaussian decay.

The closer to the nose's vertical center, the larger the weight; the closer to the top and bottom ends of the ellipse, the lower the weight.

This prevents the entire nose bridge from top to bottom receiving the same magnitude of horizontal deformation, concentrating the Warp more on the area that truly needs adjustment.

The third weight handles the entire Nose Ellipse's edge:

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

When:

$$ d^2\le0.6
$$

the core region retains full weight.

Starting from:

$$ d^2=0.6
$$

the Warp gradually enters a feathering zone, finally returning to 0 at:

$$ d^2=1
$$

which is the Nose Ellipse edge.

Finally the three weights multiply together:

float warpWeight =
    horizontalWeight
    * verticalWeight
    * boundaryWeight;

yielding:

$$ (1-n_x^2)
e^{-2.5n_y^2}
\left[
1-
smoothstep
\left(
0.6,
1,
n_x^2+n_y^2
\right)
\right]
$$

This formula is essentially the core of the entire nose-slimming algorithm.

It defines not a simple "strength value" but a continuous two-dimensional displacement weight field covering the nose ellipse.

Near the nose bridge center axis, the final displacement naturally vanishes because localX is close to 0;

moving toward the nostrils, the displacement gradually increases;

continuing further toward the Warp Region periphery, the Gaussian and smoothstep smoothly attenuate the displacement back to 0.

Thus the nose texture is never suddenly cut; it transitions continuously from the original frame into the deformed region, and continuously back to the original frame.

This is the key to whether a local Warp looks natural.

5. From the Weight Field to the Final Camera Texture Offset

After obtaining warpWeight, the remaining steps are straightforward.

First, apply a nonlinear mapping to the user input:

uNoseStrength

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

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

That is:

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

Compared to using linear strength directly, this curve makes changes in the low-to-mid range more perceptible while preventing the high range from amplifying endlessly.

The final local displacement is:

float displacement =
    localX
    * warpWeight
    * strength
    * kNoseWarpRatio;

where:

const float kNoseWarpRatio = 0.28;

So:

$$ x_l
\cdot W
\cdot S(s)
\cdot R
$$

where:

$$ R=0.28
$$

Here localX's role appears again.

When the Fragment is on the left side of the nose:

$$ x_l<0
$$

so:

$$ D<0
$$

On the right side:

$$ x_l>0
$$

so:

$$ D>0
$$

At the nose bridge center axis:

$$ x_l=0
$$

so:

$$ D=0
$$

Thus the left-right direction needs no extra if check.

The local coordinate itself encodes both distance and direction simultaneously.

Next, reproject this one-dimensional displacement back into two-dimensional face space:

vec2 warpDelta =
    uFaceXAxis
    * displacement;

That is:

$$ D\hat{x}_{face}
$$

This way, no matter how the user tilts their head, the Warp always proceeds along the face's true horizontal direction.

Since the earlier geometric space applied Aspect Correction to X to fix the aspect ratio, we need to revert to normal UV here:

warpDelta /=
    vec2(aspect, 1.0);

Finally:

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

giving:

$$ UV_{camera}
+
\Delta UV
$$

Fully connected, it is:

$$ UV_{camera}
+
\hat{x}_{face}
\cdot
x_l
\cdot
W(x,y)
\cdot
S(s)
\cdot
R
$$

where:

$$ (1-n_x^2)
e^{-2.5n_y^2}
\left[
1-
smoothstep
\left(
0.6,
1,
n_x^2+n_y^2
\right)
\right]
$$

These two formulas essentially summarize the current nose-slimming Shader completely.

From an algorithmic perspective, it can be described as:

A local inverse texture Warp built in a face-local coordinate system, with its scope confined by a Nose Ellipse, and displacement magnitude controlled by a continuous weight field.

It doesn't actually perform a scale operation on the nose; it changes the sampling position within the Camera Texture for different Fragments inside the nose region.

6. After Nose Slimming, Subsequent Beautification Must Continue Using the New Sampling Space

When nose slimming, skin smoothing, and blush sit in the same Shader, another important issue arises:

After nose slimming completes, the Camera Texture coordinate corresponding to the current Fragment has already changed.

For example:

vec2 sourceTexCoord =
    applyNoseSlim(
        vTexCoord,
        vOutputTexCoord
    );

vec4 sourceColor =
    texture(
        uTexture,
        sourceTexCoord
    );

At this point:

sourceTexCoord

is the actual original texture position for the current output pixel.

If bilateral skin smoothing follows, neighborhood sampling should continue around this coordinate:

vec2 sampleUV =
    sourceTexCoord
    + offset
    * uTexelSize
    * kSampleSpacing;

and must not revert to:

vTexCoord

Otherwise you get:

Center Pixel
    ↓
Already using post-Warp UV

Neighborhood Pixels
    ↓
Still sampling around original Camera UV

That is, two sampling spaces appear simultaneously within a single filtering pass.

A more reasonable processing order should be:

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

This reveals a very important distinction between geometric beautification and color beautification.

Skin smoothing and blush typically:

Modify color within the current sampling space.

Nose slimming, however, changes:

The sampling space itself that all subsequent color processing depends on.

Therefore, when multiple beautification effects are combined, you must consider not only the function call order but also which texture coordinate space each effect operates within.

Complete GLSL

The final nose-slimming function is as follows:

/*
  Compute the texture sampling coordinate after nose slimming
  based on the nose region.

  Geometric calculation works in the final output coordinate system;
  the resulting Warp acts on the Camera Texture sampling coordinate.
 */
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);

    /*
     Current Fragment's position relative to Nose Center,
     converted to a geometric space with uniform
     horizontal and vertical scale.
     */
    vec2 delta =
        (outputCoordinate - uNoseCenter)
        * vec2(aspect, 1.0);

    /*
     Project into the face-local coordinate system.
     */
    float localX =
        dot(delta, uFaceXAxis);

    float localY =
        dot(delta, uFaceYAxis);

    /*
     Use Nose Radius to normalize the real nose
     into a standard ellipse space.
     */
    float nx =
        localX /
        max(uNoseRadius.x, 0.0001);

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

    float distanceSquared =
        nx * nx +
        ny * ny;

    /*
     No deformation outside the Nose Ellipse.
     */
    if (distanceSquared >= 1.0) {
        return textureCoordinate;
    }

    /*
     Horizontal deformation distribution.
     */
    float horizontalWeight =
        max(
            1.0 - nx * nx,
            0.0
        );

    /*
     Vertical Gaussian decay for the nose.
     */
    float verticalWeight =
        exp(
            -ny * ny * 2.5
        );

    /*
     Edge feathering for the entire Nose Ellipse.
     */
    float boundaryWeight =
        1.0 -
        smoothstep(
            0.60,
            1.0,
            distanceSquared
        );

    float warpWeight =
        horizontalWeight
        * verticalWeight
        * boundaryWeight;

    /*
     User strength curve.
     */
    float strength =
        clamp(
            uNoseStrength,
            0.0,
            1.0
        );

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

    const float kNoseWarpRatio =
        0.28;

    /*
     localX determines both displacement magnitude
     and left-right direction.
     */
    float displacement =
        localX
        * warpWeight
        * strength
        * kNoseWarpRatio;

    /*
     Generate Warp along the face's true horizontal axis.
     */
    vec2 warpDelta =
        uFaceXAxis
        * displacement;

    /*
     Revert from uniform-scale geometric space
     to normal UV.
     */
    warpDelta /=
        vec2(aspect, 1.0);

    /*
     Redefine the Camera Texture sampling position.
     */
    return clamp(
        textureCoordinate + warpDelta,
        vec2(0.0),
        vec2(1.0)
    );
}

Effect

Before:

After:

Before:

After:

Summary

Stripping away all the engineering details, this real-time nose-slimming algorithm really does only two things.

The first thing: use Face Landmark to convert a constantly rotating, constantly size-varying real nose into a stable local space:

Real Face
   ↓
Nose Center
Nose Radius
Face Axis
   ↓
Standard Nose Ellipse

The second thing: define a continuous displacement field within this standard space:

Nose Bridge Center Axis
   ↓
Displacement starts from 0
   ↓
Gradually strengthens near the nostrils
   ↓
Gradually attenuates near the Warp Region periphery
   ↓
Returns to 0 at the boundary

Finally, apply this displacement field along the face's horizontal axis to the Camera Texture's sampling coordinates.

So what's truly worth paying attention to in nose slimming isn't:

textureCoordinate += offset;

such GLSL syntax itself.

What really determines the final effect is:

How to build a stable local coordinate system based on real facial structure, and construct a reasonable, continuous texture displacement field within that space.

Once that problem is solved, nose slimming is just one specific form.

Face slimming can place the local coordinate system at the cheek area; eye enlargement can build a radial displacement field around the eye center; chin adjustment can construct a vertical Warp along the Face Y Axis.

The underlying mathematical models are highly unified:

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

And this is the truly reusable core of real-time facial geometric beautification Shaders.