文章

Lerp01WithEdge 算法推理与最终说明

Lerp01WithEdge 算法推理与最终说明

Lerp01WithEdge 算法推理与最终说明

1. 最终结论

Lerp01WithEdge 是一个 01 输入双端外扩映射函数

它将输入控制参数 In 从标准 [0,1] 区间, 映射到可越界的 [-Edge, 1 + Edge] 区间。

核心目标是:

1
2
3
4
In = 0                  -> Out = -Edge
In = Threshold          -> Out = Threshold
In = 1 - Threshold      -> Out = 1 - Threshold
In = 1                  -> Out = 1 + Edge

中间区间保持不变:

1
2
Threshold <= In <= 1 - Threshold
Out = In

左端和右端只在端部区间内加速外扩:

1
2
3
[0, Threshold]             -> [-Edge, Threshold]
[Threshold, 1 - Threshold] -> [Threshold, 1 - Threshold]
[1 - Threshold, 1]         -> [1 - Threshold, 1 + Edge]

最终工程实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/// 输入控制参数 In, 加上扩展宽度 Edge, 加上变速区间 Threshold, 输出重映射后的控制参数 Out。
/// 该 Out 可以保证, 即便加上扩展宽度, 仍旧可以在 In 为 0 或 1 时完整覆盖端部范围。
/// @param In 输入控制参数, 范围为 0-1。
/// @param Edge 两端扩张宽度, 范围为 0 以上。
/// @param Threshold 端部加速区间, 范围为 0-0.5。越大则越早开始变速, 但速度变化越缓。越小则越晚开始变速, 但速度变化越快。
/// @param Out 重映射后的控制参数, 理论范围为 -Edge 到 1+Edge。
void Lerp01WithEdge_float(float In, float Edge, float Threshold, out float Out)
{
    // 1. Normalize inputs.
    In = saturate(In);
    Edge = max(Edge, 0.0);

    float thresholdRaw = max(Threshold, 0.0);
    const float kEps = 1e-6;

    // 0 when Threshold is approximately 0, 1 otherwise.
    float enable = step(kEps, thresholdRaw);

    float threshold = min(max(thresholdRaw, kEps), 0.5);
    float invTh = rcp(threshold);

    // 2. Compute candidate outputs for three regions.

    // Center region: keep aligned.
    float center = In;

    // Left edge region: [0, threshold] -> [-Edge, threshold].
    float wL = enable * saturate((threshold - In) * invTh);
    float left = lerp(threshold, -Edge, wL);

    // Right edge region: [1 - threshold, 1] -> [1 - threshold, 1 + Edge].
    float wR = enable * saturate((In - (1.0 - threshold)) * invTh);
    float right = lerp(1.0 - threshold, 1.0 + Edge, wR);

    // 3. Region selection.

    // Left: In < threshold.
    float isLeft = enable * (1.0 - step(threshold, In));

    // Right: In >= 1 - threshold.
    float isRight = enable * step(1.0 - threshold, In);

    // Center: otherwise.
    float isCenter = 1.0 - isLeft - isRight;

    // 4. Compose.
    Out = left * isLeft + center * isCenter + right * isRight;
}

2. 问题起点: 需要一个端部快速变化的系数

最初的问题是:

1
2
3
4
输入 a 的范围是 [0,1]。
需要得到一个系数 b。
b 通常为 1。
当 a 越接近 0 或 1 时, b 需要迅速衰减到 0。

例如:

1
2
3
0 < a < 0.1 时, a 越接近 0, b 越接近 0。
0.9 < a < 1 时, a 越接近 1, b 越接近 0。
中间区域 b 基本保持为 1。

这个阶段的算法思路是 边缘衰减

典型写法是先计算距离两端的最小距离:

1
float edgeDistance = min(a, 1.0 - a);

然后将边缘宽度归一化:

1
float x = saturate(edgeDistance / threshold);

最后得到衰减系数:

1
float b = pow(x, exponent);

或者用 smoothstep 得到更平滑的边缘过渡:

1
2
3
float left = smoothstep(0.0, threshold, a);
float right = smoothstep(0.0, threshold, 1.0 - a);
float b = left * right;

这个阶段的核心结论是:

1
2
越靠近 0 或 1, 输出越接近 0。
中间区域输出接近 1。

3. 需求变化: 不再是边缘衰减, 而是右端外扩映射

后续需求发生变化:

1
2
3
4
5
输入 a 范围仍然是 [0,1]。
输出 b 范围变成 [0,t]。
t > 1。
当 a < 0.9 时, b = a。
当 a > 0.9 时, a 从 0.9 到 1, b 从 0.9 到 t。

这时算法目标从“生成衰减系数”变成了 区间重映射

也就是:

1
2
[0, 0.9] -> [0, 0.9]
[0.9, 1] -> [0.9, t]

中间不变, 右端区间被拉伸。

分段写法是:

1
2
3
4
5
6
7
8
9
10
11
12
float ComputeB(float a, float t)
{
    a = saturate(a);

    if (a <= 0.9)
    {
        return a;
    }

    float alpha = (a - 0.9) / 0.1;
    return lerp(0.9, t, alpha);
}

这里得到一个关键公式:

1
2
float alpha = (a - inMin) / (inMax - inMin);
float b = lerp(outMin, outMax, alpha);

也就是:

1
2
先把输入区间归一化到 [0,1],
再用 lerp 映射到目标区间。

这是后续算法的基础。


4. 去掉硬编码: 将 0.1 变成可调变量

接着, 右端 [0.9,1] 这段长度不希望写死为 0.1, 而是需要变成变量。

于是引入:

1
threshold

它表示端部过渡区间长度。

原来的:

1
0.9

变成:

1
1.0 - threshold

原来的:

1
0.1

变成:

1
threshold

因此右侧映射变成:

1
2
3
float rightStart = 1.0 - threshold;
float alpha = (a - rightStart) / threshold;
float b = lerp(rightStart, t, alpha);

此时算法变成:

1
2
[0, 1 - threshold] -> 原样输出
[1 - threshold, 1] -> 映射到 [1 - threshold, t]

这个阶段的核心结论是:

1
2
3
threshold 表示端部变速区间长度。
threshold 越大, 越早进入变速区间, 变化更缓。
threshold 越小, 越晚进入变速区间, 变化更快。

5. 需求再次变化: 左右两端都需要外扩

然后需求被改成当前的完整形式:

1
2
3
4
5
6
7
8
9
a 的范围是 [0,1]。
b 的范围是 [-t, 1+t]。
当 threshold < a < 1 - threshold 时, b = a。
当 a < threshold 时:
    a 从 threshold 到 0。
    b 从 threshold 到 -t。
当 a > 1 - threshold 时:
    a 从 1 - threshold 到 1。
    b 从 1 - threshold 到 1 + t。

这时算法不再只是右端外扩, 而是 左右双端外扩

完整映射关系变成:

1
2
3
4
a = 0                  -> b = -t
a = threshold          -> b = threshold
a = 1 - threshold      -> b = 1 - threshold
a = 1                  -> b = 1 + t

对应三个区间:

1
2
3
左端: [0, threshold] -> [-t, threshold]
中间: [threshold, 1 - threshold] -> 原样输出
右端: [1 - threshold, 1] -> [1 - threshold, 1 + t]

6. 推导左端映射

左端输入区间是:

1
[0, threshold]

目标输出区间是:

1
[-t, threshold]

但是为了符合直觉, 按从中心往边缘的方向描述:

1
2
a = threshold -> b = threshold
a = 0         -> b = -t

因此归一化权重应该满足:

1
2
a = threshold 时, alpha = 0
a = 0 时, alpha = 1

所以:

1
float alphaL = (threshold - a) / threshold;

再限制到 [0,1]:

1
float alphaL = saturate((threshold - a) / threshold);

最终左侧输出:

1
float b_left = lerp(threshold, -t, alphaL);

也就是:

1
2
alphaL = 0 -> threshold
alphaL = 1 -> -t

7. 推导中间映射

中间区间是:

1
[threshold, 1 - threshold]

需求是保持输入输出一致:

1
float b_center = a;

也就是:

1
b = a

这个区间不做外扩, 不做变速。


8. 推导右端映射

右端输入区间是:

1
[1 - threshold, 1]

目标输出区间是:

1
[1 - threshold, 1 + t]

归一化权重应该满足:

1
2
a = 1 - threshold 时, alpha = 0
a = 1 时, alpha = 1

因此:

1
float alphaR = (a - (1.0 - threshold)) / threshold;

限制到 [0,1]:

1
float alphaR = saturate((a - (1.0 - threshold)) / threshold);

最终右侧输出:

1
float b_right = lerp(1.0 - threshold, 1.0 + t, alphaR);

也就是:

1
2
alphaR = 0 -> 1 - threshold
alphaR = 1 -> 1 + t

9. 分段版算法

根据三个区间, 可以得到直观的分段版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
float ComputeB(float a, float t, float threshold)
{
    a = saturate(a);

    if (a < threshold)
    {
        float alpha = saturate((threshold - a) / threshold);
        return lerp(threshold, -t, alpha);
    }

    if (a > 1.0 - threshold)
    {
        float alpha = saturate((a - (1.0 - threshold)) / threshold);
        return lerp(1.0 - threshold, 1.0 + t, alpha);
    }

    return a;
}

这个版本最容易阅读。

但在 HLSL 中, 为了减少分支并更适合 ShaderGraph Custom Function 或 GPU 批量计算, 后续整理为无分支版本。


10. 从分段版改成无分支版

无分支版本的思路是:

1
2
3
4
不先判断当前属于哪个区间。
先同时计算三个候选结果: left, center, right。
再计算三个区域权重: isLeft, isCenter, isRight。
最后加权合成。

候选值:

1
2
3
4
5
6
7
float center = In;

float wL = saturate((threshold - In) / threshold);
float left = lerp(threshold, -Edge, wL);

float wR = saturate((In - (1.0 - threshold)) / threshold);
float right = lerp(1.0 - threshold, 1.0 + Edge, wR);

区域选择:

1
2
3
float isLeft = 1.0 - step(threshold, In);
float isRight = step(1.0 - threshold, In);
float isCenter = 1.0 - isLeft - isRight;

最终合成:

1
Out = left * isLeft + center * isCenter + right * isRight;

这里使用 step(edge, x) 的语义:

1
2
x < edge  -> 0
x >= edge -> 1

所以:

1
1.0 - step(threshold, In)

表示:

1
2
In < threshold -> 1
In >= threshold -> 0

而:

1
step(1.0 - threshold, In)

表示:

1
2
In >= 1.0 - threshold -> 1
In < 1.0 - threshold  -> 0

11. Threshold = 0 的问题

直接使用:

1
2
float alphaL = (threshold - In) / threshold;
float alphaR = (In - (1.0 - threshold)) / threshold;

会在 threshold = 0 时发生除以 0。

但工程上 Threshold = 0 是有意义的, 它应该表示:

1
2
关闭端部变速。
Out = In。

因此最终版本加入了 enable 控制:

1
2
3
4
5
6
float thresholdRaw = max(Threshold, 0.0);
const float kEps = 1e-6;

float enable = step(kEps, thresholdRaw);
float threshold = min(max(thresholdRaw, kEps), 0.5);
float invTh = rcp(threshold);

Threshold 接近 0 时:

1
enable = 0;

此时左右区域关闭:

1
2
3
isLeft = 0;
isRight = 0;
isCenter = 1;

最终:

1
Out = In;

这样既避免了除以 0, 又保留了 Threshold = 0 作为“关闭功能”的语义。


12. Threshold 上限为什么是 0.5

Threshold 表示左右两端各自的端部区间长度。

左侧区间:

1
[0, Threshold]

右侧区间:

1
[1 - Threshold, 1]

如果 Threshold > 0.5, 左右区间会发生重叠。

例如:

1
2
3
Threshold = 0.6
左侧区间: [0, 0.6]
右侧区间: [0.4, 1]

中间 [Threshold, 1 - Threshold] 会变成反向区间。

所以最终将其限制为:

1
threshold = min(max(thresholdRaw, kEps), 0.5);

也就是:

1
0 < threshold <= 0.5

13. Edge 的含义

之前推导中的 t 在最终代码中命名为 Edge

原因是最终算法中的 t 不再只是“右侧目标最大值”, 而是两端外扩距离:

1
2
左端从 0 外扩到 -Edge。
右端从 1 外扩到 1 + Edge。

因此命名为 Edge 更贴近工程语义。

约束为:

1
Edge = max(Edge, 0.0);

负数没有实际意义, 因此被钳制为 0。


14. 为什么这个算法能解决“扩展宽度后仍完整覆盖”的问题

在很多 Shader 控制参数中, 01 往往对应效果的两个边界。

如果直接使用标准 [0,1] 控制参数, 当效果本身带有额外宽度, 模糊, 扩散, 挤出或边缘过渡时, 仅仅到达 01 可能不足以完全覆盖或完全移除边缘像素。

该算法通过把输出范围扩展为:

1
[-Edge, 1 + Edge]

使得:

1
2
In = 0 -> Out = -Edge
In = 1 -> Out = 1 + Edge

这样即便后续效果计算中存在额外宽度, 控制值也能越过原始 [0,1] 边界, 从而覆盖端部误差或边缘残留。

中间区间仍然保持 Out = In, 因此不会破坏主要控制区间的线性一致性。


15. Vector3 版本的意义

Lerp01WithEdgeVector3_float 是标量版本的逐通道扩展。

它等价于分别对 X/Y/Z 三个通道执行一次:

1
Lerp01WithEdge_float

每个通道都可以有独立的:

1
2
3
In
Edge
Threshold

因此适合以下情况:

1
2
3
不同轴向有不同外扩宽度。
不同控制通道需要不同端部加速区间。
同一个函数同时处理三个独立的 01 控制量。

16. 当前最终版本的定位

当前 Lerp01WithEdge 已经不是一个临时公式, 而是一个可以复用的数学工具函数。

它的工程定位可以概括为:

1
对标准 01 控制参数进行双端安全外扩, 同时保持中间区间线性一致。

适用场景包括但不限于:

1
2
3
4
5
遮罩推进。
边缘擦除。
基于 0-1 控制量的可见性过渡。
带宽度补偿的线性插值。
需要保证端部完全覆盖或完全移除的 Shader 控制参数。

推荐参数范围:

1
2
Edge >= 0
0 <= Threshold <= 0.5

常见取值:

1
2
Edge = 0.05 ~ 0.2
Threshold = 0.05 ~ 0.2

当希望端部变化更快时, 减小 Threshold

当希望端部变化更缓时, 增大 Threshold

当希望关闭外扩宽度时:

1
Edge = 0

当希望关闭端部变速逻辑时:

1
Threshold = 0

17. TODO: 增加曲线映射模式

当前 Lerp01WithEdge 使用的是分段线性映射模式。

当前模式可以称为:

1
Piecewise Linear Mode

其行为如下:

1
2
3
[0, Threshold]             -> 左端线性外扩
[Threshold, 1 - Threshold] -> 保持 Out = In
[1 - Threshold, 1]         -> 右端线性外扩

该模式的优点是:

1
2
3
4
逻辑直观。
中间区间严格保持 Out = In。
Threshold 可以明确控制端部变速区间。
In = 0 和 In = 1 时可以稳定映射到 -Edge 和 1 + Edge。

但它也有一个特征:

1
Out 的数值是连续的, 但在 Threshold 和 1 - Threshold 处, 斜率会发生突变。

也就是说, 当 In 从中间区间进入端部区间时, 映射速度会突然改变。


17.1 下一步演化目标

后续可以增加一个新的曲线映射模式。

该模式可以暂称为:

1
Curve Mode

或:

1
Global Curve Mode

该模式的目标是:

1
2
不要等到 In 到达 Threshold 或 1 - Threshold 后才突然变速。
而是从整个 [0,1] 区间开始, 使用一条连续曲线完成外扩映射。

换句话说, 当前模式是:

1
先保持线性, 到端部区间后再变速。

新的曲线模式是:

1
全程根据曲线逐渐调整映射速度。

17.2 曲线模式的目标映射关系

曲线模式仍然应保留端点约束:

1
2
In = 0 -> Out = -Edge
In = 1 -> Out = 1 + Edge

根据具体需求, 可以选择是否保留中心点约束:

1
In = 0.5 -> Out = 0.5

对于左右对称的曲线模式, 建议保留中心点约束。

也可以选择是否要求:

1
In 接近 0.5 时, Out 尽量接近 In。

这样可以避免中间区域被过度拉伸。


17.3 可选曲线类型

后续可以考虑以下曲线类型。

17.3.1 Power Curve

使用幂曲线控制外扩速度。

示意:

1
float shaped = pow(x, exponent);

特点:

1
2
exponent > 1 时, 端部变化更集中。
exponent < 1 时, 端部变化更提前。

适合需要简单参数控制的场景。


17.3.2 SmoothStep Curve

使用 smoothstep 或更高阶的平滑函数。

示意:

1
float shaped = smoothstep(0.0, 1.0, x);

特点:

1
2
端点附近变化更平滑。
不会像线性分段那样在区间交界处产生明显斜率突变。

适合视觉过渡需要更柔和的场景。


17.3.3 Sin Curve

使用正弦曲线控制映射。

示意:

1
float shaped = 0.5 - 0.5 * cos(x * PI);

特点:

1
2
曲线天然平滑。
适合需要柔和加速和减速的过渡。

17.4 一个可能的曲线模式设计方向

可以把最终映射拆成两部分:

1
Out = In + Offset

其中 Offset 由曲线控制。

目标是:

1
2
3
In = 0   -> Offset = -Edge
In = 0.5 -> Offset = 0
In = 1   -> Offset = Edge

也就是说:

1
2
3
Out = -Edge        when In = 0
Out = 0.5          when In = 0.5
Out = 1 + Edge     when In = 1

一个简单的对称写法可以是:

1
2
3
4
5
6
7
8
float signedX = In * 2.0 - 1.0;          // [-1, 1]
float signX = sign(signedX);
float absX = abs(signedX);               // [0, 1]

float shaped = pow(absX, Exponent);      // [0, 1]
float offset = signX * shaped * Edge;

Out = In + offset;

该模式下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
In = 0:
signedX = -1
offset = -Edge
Out = -Edge

In = 0.5:
signedX = 0
offset = 0
Out = 0.5

In = 1:
signedX = 1
offset = Edge
Out = 1 + Edge

这类方案不依赖 Threshold 分段, 而是让整个 [0,1] 区间都受到曲线影响。


17.5 需要进一步确认的问题

在实现曲线模式前, 需要确认以下设计点:

1
2
3
4
5
曲线模式是否必须保持中间区域完全 Out = In。
是否只要求中心点 In = 0.5 时 Out = 0.5。
是否允许整个 [0,1] 都发生轻微偏移。
是否需要继续保留 Threshold 作为曲线影响范围。
是否需要 Curve Mode 和 Piecewise Linear Mode 共存。

如果希望中间大范围严格保持 Out = In, 那么仍然需要保留分段逻辑, 只是把端部线性段替换为端部曲线段。

如果希望完全避免 Threshold1 - Threshold 处的突然变速, 那么更适合使用全局曲线模式。


17.6 暂定模式划分

后续可以将函数扩展为两种模式:

1
Mode 0: Piecewise Linear Mode

当前版本。

特点:

1
2
3
4
中间区间严格保持 Out = In。
端部区间线性外扩。
Threshold 控制端部区间长度。
Mode 1: Global Curve Mode

新增版本。

特点:

1
2
3
整个 [0,1] 区间都根据曲线产生偏移。
不会在 Threshold 和 1 - Threshold 处产生突然变速。
曲线可以使用 Power, SmoothStep, Sin 等形式。

未来也可以继续增加:

1
Mode 2: Piecewise Curve Mode

特点:

1
2
3
仍然保留 Threshold。
中间区间保持 Out = In。
但端部区间不再使用线性映射, 而是使用 Power, SmoothStep 或 Sin 曲线映射。

需要注意的是, Piecewise Curve Mode 只能让端部区间内部更平滑, 但如果中间区间仍然是严格线性, 在 Threshold1 - Threshold 的交界处仍然可能存在斜率变化。

若目标是完全避免交界处突然变速, 应优先考虑 Global Curve Mode

本文由作者按照 CC BY 4.0 进行授权