贝塞尔曲线细分:中点落在弦上,仍可能漏掉整个弯曲

一条 S 形道路准备转成带状 Mesh,细分器只检查曲线中点到首尾连线的距离。结果只生成一段直线,两个弯都消失了。加严容差也没有帮助:这条曲线的中点恰好落在弦上,计算出的误差始终为零。需要更换的是接受条件,继续缩小同一个阈值无法补回没有测到的弯曲。

S 形带状道路与穿过其中点的首尾直弦示意

主视觉说明路径与直弦的差异,不承担精确尺寸。本篇把道路中心线缩成一个可独立运行的 C# 二维几何问题:从三次贝塞尔产生折线,再由后续网格生成器扩宽。尚未接入 Unity、GPU 或实际道路资产,因此验证范围止于中心线近似,宽度、接缝与三角形质量另行验收。

零误差只发生在被抽中的点

采用 X 向右、Y 向上的同一局部平面,四个控制点依次为 P0=(0,0)、P1=(1,2)、P2=(2,-2)、P3=(3,0),长度使用统一局部单位,参数 t 无量纲。三次多项式可简化成:

B(t) = (1-t)^3 P0 + 3(1-t)^2 t P1 + 3(1-t)t^2 P2 + t^3 P3
x(t) = 3t
y(t) = 6t(1-t)(1-2t),  0 <= t <= 1
B(0.5) = (1.5, 0)
B(0.25) = (0.75, 0.5625)

弦段是 X 轴上的 [0,3]。中点距离为零,但四分之一处已经离弦 0.5625,远大于目标容差 0.05。这不是极端浮点舍入,几个数都能精确表示;它来自曲线的对称性。再加一个抽样点可能挡住此例,却仍需要证明新的抽样规则能够覆盖整个参数区间。

本实现改用两个内部控制点到弦段的最大距离作为上界。三次贝塞尔位于四个控制点的凸包内;弦段半径为 epsilon 的邻域是凸集。只要四个控制点都在这个邻域内,它们的每个凸组合也在,整条曲线就不会超出。两个端点本就在弦上,因此只需检查内部两点。

更具体地说,把每个控制点投影到弦段得到 Q_i。曲线的非负权重总和为 1,sum(w_i Q_i) 仍在弦段内,而 |sum(w_i(P_i-Q_i))| <= max|P_i-Q_i|。这是曲线到弦段的几何距离保证,不是“相同 t 下曲线点到线性插值点”的误差保证,也不保证匀速运动。

距离必须针对有限线段。若内部控制点越过首尾端点,只测到无限直线的垂距会遗漏沿线方向的伸出;首尾重合时,则退化为到单个点的距离。上界偏保守是允许的:控制点在邻域外不代表曲线一定越界,只意味着当前证明不足,应继续细分。

每次二分都保留一个可验收的叶段

Bezier.cs 的入口是 Bezier.Flatten,经 Visit 计算上界,并用 de Casteljau 在参数中点二分。完整核心如下,可与 .NET 9 启用隐式 using 的控制台工程共同构建。

public readonly record struct Point(double X, double Y)
{
    public static Point operator +(Point a, Point b) => new(a.X + b.X, a.Y + b.Y);
    public static Point operator -(Point a, Point b) => new(a.X - b.X, a.Y - b.Y);
    public static Point operator *(Point a, double s) => new(a.X * s, a.Y * s);
    public double Length => Math.Sqrt(X * X + Y * Y);
}
public readonly record struct Cubic(Point A, Point B, Point C, Point D);
public readonly record struct Leaf(double T0, double T1, Point A, Point D, double Bound);
public sealed record Tessellation(Leaf[] Leaves, bool Complete);

public static class Bezier
{
    public static double Distance(Point p, Point a, Point b)
    {
        Point v = b - a, w = p - a;
        double vv = v.X * v.X + v.Y * v.Y;
        double t = vv == 0 ? 0 : Math.Clamp((w.X * v.X + w.Y * v.Y) / vv, 0, 1);
        return (p - (a + v * t)).Length;
    }
    public static Tessellation Flatten(Cubic curve, double tolerance, int maxDepth)
    {
        foreach (Point p in new[] { curve.A, curve.B, curve.C, curve.D })
            if (!double.IsFinite(p.X) || !double.IsFinite(p.Y) ||
                Math.Abs(p.X) > 1e4 || Math.Abs(p.Y) > 1e4)
                throw new ArgumentOutOfRangeException(nameof(curve));
        if (!double.IsFinite(tolerance) || tolerance < 1e-6 || tolerance > 1e4)
            throw new ArgumentOutOfRangeException(nameof(tolerance));
        if (maxDepth < 0 || maxDepth > 12)
            throw new ArgumentOutOfRangeException(nameof(maxDepth));
        var leaves = new List<Leaf>();
        bool complete = Visit(curve, 0, 1, tolerance, maxDepth, leaves);
        return new(leaves.ToArray(), complete);
    }
    private static bool Visit(Cubic c, double t0, double t1, double tolerance,
        int depth, List<Leaf> leaves)
    {
        double bound = Math.Max(Distance(c.B, c.A, c.D), Distance(c.C, c.A, c.D));
        if (bound <= tolerance || depth == 0)
        {
            leaves.Add(new(t0, t1, c.A, c.D, bound));
            return bound <= tolerance;
        }
        // de Casteljau 二分,两个子段共享同一个中点。
        Point ab = (c.A + c.B) * .5, bc = (c.B + c.C) * .5, cd = (c.C + c.D) * .5;
        Point abc = (ab + bc) * .5, bcd = (bc + cd) * .5;
        Point middle = (abc + bcd) * .5;
        double tm = (t0 + t1) * .5;
        bool left = Visit(new(c.A, ab, abc, middle), t0, tm, tolerance, depth - 1, leaves);
        bool right = Visit(new(middle, bcd, cd, c.D), tm, t1, tolerance, depth - 1, leaves);
        return left && right;
    }
}

每个 Leaf 返回原始参数区间、弦的两个端点和计算上界。左右子段使用同一个中点,深度优先输出使区间按参数递增,网格生成器可按第一段起点和各段终点建立顶点序列。重复端点或零长度段仍需在扩宽阶段处理;它们不应被误认为当前容差检查失败。

这里刻意不在左支失败后短路右支递归。预算不足时仍交付覆盖整个参数区间的粗折线,同时令 Complete=false。若直接写成两个递归调用之间的 &&,左边失败会跳过右边,输出就可能只剩半条道路。调用方拥有“继续保留旧 Mesh、增加预算或明确接受粗糙结果”的决定权,不能只看数组非空就提交为精度合格的新网格。

固定 S 曲线、十二个叶段及各段控制点误差上界

图左使用等比例坐标,深色节点来自真实叶段端点;图右十二根柱读取同一次 run.jsonBound。容差 0.05 下得到十二段,最大上界约 0.034046。每段再均匀取 1001 个参数位置,测得最大偏离约 0.023051;这个采样值用于核对实现,不代替前面的全区间推导。

到达深度上限,不等于达到精度

最大深度为 0 时允许只检查根段;当前 S 曲线的根上界为 2,结果明确未达标。深度为 1 也未通过。最多允许深度 12,因此叶段数至多为 4096,递归栈深度随上限线性增长;这是工作量上限,不是任意曲线都能在该预算内满足容差的承诺。

输入拒绝非有限坐标,坐标各分量绝对值不超过一万,容差范围为 [1e-6,1e4]。运算使用 double。上述凸包证明建立在实数运算上,代码没有区间算术或向外舍入,所以不能声称在所有机器精度边界都严格保守;测试使用 1e-10 的比较余量检查采样距离是否越过计算上界。迁移到 float 时应重新测量误差,并为应用的最终容差预留数值余量。

Program.csVerify 使用原始 Bernstein 多项式求曲线点,另用单位方向投影和二维叉积计算参照距离,不复用生产函数 Distance。它同时核对相邻叶段共享端点、参数连续和所有上界达标。失败回归 MidpointRegression 保留开篇的误判:

Require(Bezier.Distance(Evaluate(S, .5), S.A, S.D) == 0);
Require(ReferenceDistance(Evaluate(S, .25), S.A, S.D) == .5625);
Require(Bezier.Flatten(S, .05, 12).Leaves.Length > 1);

这里 S 就是上述四点,Evaluate 为 Bernstein 求值,ReferenceDistance 为独立参照,Require 在条件不成立时抛出异常。测试还覆盖直线、闭合曲线、全点重合、共线控制点越过端点、非法容差、非法坐标和深度预算;扫查使用内部控制点 Y 各取 -3 至 3、三种容差,共 147 组输入。

2026-09-21 在 .NET 9 Release 下执行,$LAB_DIR 指向本篇研究工程:

dotnet build "$LAB_DIR/BezierLab.csproj" -c Release
dotnet run --project "$LAB_DIR/BezierLab.csproj" -c Release --no-build
build exit=0 warnings=0 errors=0 elapsed=00:00:09.10
run exit=0 passed=14 failed=0 skipped=0
assertion_duration_ms=100.629
sampled_checks=99385 observed_bound_excess=0
midpoint_error=0 quarter_error=0.5625
normal_segments=12 max_bound=0.03404591941892772
normal_sampled_deviation=0.023051471641495985
depth_1_complete=false

99,385 次检查来自扫查与固定曲线用例,每个叶段检查 65 个参数位置;前面每段 1001 点的固定结果另行计算。耗时仅记录测试段,未包含构建、进程启动和后续密集测量,不能拿来推断实时网格更新的帧成本。

道路的两个弯现在有了接受依据:每一条输出弦都携带局部上界,整个结果还携带预算是否满足要求的状态。这个接口适合把几何质量和网格提交分开评审。若目标变成屏幕像素误差,需要在相应空间重新建立约束;若生成有宽度的带面,中心线距离达标也无法排除急弯处的边缘自交。折线先忠实保留中心线,后续拓扑才有值得继续加工的输入。