· 5 分钟阅读 · 2042 字

剑尖拖尾忽密忽疏:等距采样必须保留跨帧余量

剑尖拖尾每帧补一个点,高帧率时细密,低帧率时拉成几条长带。把规则换成“移动够0.5才补点”,仍可能在慢速运动时完全不出点:每帧只移动0.2,若每次都从当前帧起点重新计距,这些短距离永远无法累积到阈值。决定密度的状态必须跨过帧边界。

棉线与木珠表达沿路径连续计距的摄影机制示意,不代表引擎实测画面

独立 C# 工程 TrailLab 用一条折线固定这个问题:(0,0)→(0.4,0)→(1.2,0)→(1.2,1.6),坐标单位为米,目标间距0.5米。TrailSampler.cs 提供 TrailSampler.Sample,Program.cs 的断言、分段对照与运行输出提供第二类证据。它只验证拖尾、轨迹装饰或沿程粒子共用的几何采样核,不包含 Unity 拖尾渲染器、带宽生成与实机视觉测试。

0.4米不能在下一帧消失

第一段长0.4,不产生新点,但应留下0.4的余量。第二段长0.8,距离下一个点只差0.1,于是在横坐标0.5和1.0各产生一点,段末余量为0.2。第三段转为向上移动1.6,先走0.3补齐间距,再每隔0.5补点。最终得到五个位置,最后剩下0.3米。

若逐段清零,三段分别产生 floor(0.4/0.5)=0、floor(0.8/0.5)=1、floor(1.6/0.5)=3 个点,总数只有四个。更严重的是,把一条8米直线切成很多不足0.5米的小段,这种实现会一个点都不产生。提高采样频率反而减少输出,是状态定义错误的直接信号。

这里的余量 carry 表示从上一次已发射位置沿输入路径走到当前端点的距离,满足 0≤carry<spacing。如果此前还没有发射点,则把路径起点作为计距原点。本核不自动输出起点;需要首点的拖尾由调用方单独初始化,防止每批输入重复插入连接点。

转弯后相邻点靠近,不代表沿程间距变小

第二点在 (1,0),第三点在 (1.2,0.3)。两点的沿程距离是 0.2+0.3=0.5,直线距离却只有 sqrt(0.2²+0.3²)≈0.360555。以两点欧氏距离检查“等距”,会错误拒绝正确结果。

固定折线的五个采样点、跨拐角弦长与余量提交关系

这个差别也影响渲染。如果只把采样点直接连成带面,连接线会切过拐角。需要保住轮廓时,可把转角作为额外拓扑节点,但必须把“几何连接点”和“按距离发射的事件点”分开,否则每个转角都会凭空增加粒子或贴花数量。等距采样解决沿程密度,拐角细分负责形状误差,两者的验收条件不同。

同样,输入若只剩首尾 (0,0)→(1.2,1.6),采样器看到的是2米直线,只能输出四点;真实折线路程为2.8米。保存余量无法重建漏掉的转弯。动画或物理层需要按允许的轨迹误差提供中间位置,渲染帧率本身不应成为唯一的路径精度参数。

核心函数交付点集,也交付下一次的距离状态

实现逐段计算首次采样距离 spacing-carry,随后递增一个间距;段末将最后一次采样之后的距离保存为新余量。全部输出在函数内部构造,预算不足或数值非法时抛出异常,不向调用方交付半批结果。

public readonly record struct Point(double X, double Y);
public sealed record TrailResult(Point[] Samples, double Carry);

public static class TrailSampler
{
    public static TrailResult Sample(IReadOnlyList<Point> path,
        double spacing, double carry = 0, int maxSamples = 10000)
    {
        if (!double.IsFinite(spacing) || spacing <= 0 ||
            !double.IsFinite(carry) || carry < 0 || carry >= spacing ||
            maxSamples < 0) throw new ArgumentOutOfRangeException();
        if (path.Count == 0) throw new ArgumentException("Empty path");
        foreach (Point p in path)
            if (!double.IsFinite(p.X) || !double.IsFinite(p.Y))
                throw new ArgumentException("Invalid point");
        List<Point> output = new();
        for (int i = 1; i < path.Count; i++)
        {
            Point a = path[i - 1], b = path[i];
            double dx = b.X - a.X, dy = b.Y - a.Y;
            double length = Math.Sqrt(dx * dx + dy * dy);
            if (!double.IsFinite(length)) throw new ArithmeticException();
            if (length == 0) continue;
            double tolerance = spacing * 1e-12;
            double next = spacing - carry;
            while (next <= length + tolerance)
            {
                if (output.Count == maxSamples)
                    throw new InvalidOperationException("Sample budget exceeded");
                double t = Math.Min(next / length, 1);
                output.Add(new(a.X + t * dx, a.Y + t * dy));
                double advanced = next + spacing;
                if (advanced == next || !double.IsFinite(advanced))
                    throw new ArithmeticException();
                next = advanced;
            }
            carry = Math.Max(0, length - (next - spacing));
            if (carry < 0 || carry >= spacing)
                throw new ArithmeticException("Residual lost precision");
        }
        return new(output.ToArray(), carry);
    }
}

这里将端点容差固定为间距的 1e-12。测试初版用严格的 next≤length,在8米直线的不同分段下漏掉末端样本;分段计算引入的舍入让理论相等的两个数落到了比较符号两侧。当前实现允许阈值在这个容差内贴合端点,插值比例上限为一,并将极小负余量归零。它定义了明确的近似规则,不承诺任意分段下浮点结果位级相同;大量极短线段累积的误差仍需按资产尺度单独评估。

非有限输入、长度计算溢出、无法继续递增的距离与余量范围异常均被拒绝。重复端点直接跳过,保留原余量;不会把停止移动解释成新的发射事件。间距改变时,旧余量也不能直接套用新合同:若要保留原有距离相位,需要先明确重采样策略,或者显式重置轨迹。

流式调用时,特效实例持有“上一输入端点与余量”。下一批路径必须从该端点开始,只有函数成功返回后才一起替换端点和余量,再消费本批样本。固定路径预算为4时拒绝,预算为5时接受。若调用方先更新端点再发现容量不足,重试将失去那段尚未提交的路径;本核的返回结构只能支持这个提交顺序,不能替调用方执行它。

用同一路径的不同分段检验密度

Program.Main 除了正常折线,还把8米直线分别切为1至200段,保持间距0.125米,逐点检查64个输出及末端零余量,位置比较容差为 1e-10。另一项断言将固定折线拆成三次调用,逐次传递余量,与一次调用的点集和末余量对照。分段对照检查状态能否跨调用延续;固定坐标检查算法是否沿正确路径行走。

日期:2026-10-19;环境:.NET SDK 9.0.202,Release
工作目录:$WORK_DIR
 dotnet build .tmp/daily-labs/2026-10-19-2000/TrailLab.csproj -c Release
 exit=0; warnings=0; errors=0; elapsed=5.50s
 dotnet run --project .tmp/daily-labs/2026-10-19-2000/TrailLab.csproj -c Release --no-build
 exit=0; passed=23; failed=0; skipped=0; elapsed=66.1686ms
 partitions=200; samples_per_straight_path=64
 fixed_samples=[(0.5,0),(1,0),(1.2,0.3),(1.2,0.8),(1.2,1.3)]
 carry≈0.3; reset_per_segment_count=4
 corner_chord≈0.360555; corner_arc=0.5
 missing_corner_length=2.0; missing_corner_count=4

23项断言还覆盖零长段、单点路径、短段、恰好命中端点、已有余量、非法间距与坐标,以及预算上下界。耗时仅记录这组断言的执行,不构成引擎性能结论。函数工作量为输入段数加输出点数,输出预算约束的是点集规模,不能阻止调用方传入过多零长段;接入实时线程时还应独立限制输入批大小。

拖尾密度由路径长度和跨调用距离相位共同决定。传送、回放跳转或对象池复用改变了路径连续性,应结束旧轨迹并重建起点,不能把这段跳跃当成长距离运动补出一串点。保持路径连续的运动使用余量承接,路径断开的运动显式重置,才能同时控制正常运动的密度与异常跳跃的输出规模。