· 5 分钟阅读 · 2019 字
虚线路径裁掉一段后开始滑动:相位必须沿用源路径累计弧长
一条导航虚线在拐点处突然多出一截实线,滚动面板时又像被视口边缘拖着移动。改变线宽或补几个端帽都不能消除这种跳变:只要每个可见线段都从零开始计算虚线,路径分段和裁剪就会改写图案本身。
固定路径只有三个点 (0,0) → (7,0) → (7,9),两段长度分别为 7 和 9。实线长 6、间隙长 4、初始相位为 0,整条路径应该覆盖累计距离 [0,6) 和 [10,16)。若第二段又从零计长,它会把原本应为空隙的 [7,10) 填成实线,并让 [13,16) 提前消失。位置与顶点顺序都正确,错的是附着在几何上的距离坐标。

拐点没有重新开始一条路径
把路径起点到当前点的累计弧长记为 s,实线长度为 d,间隙为 g,周期 P=d+g。覆盖判定为 mod(s+φ,P)<d;这里 φ 为距离单位的相位,取正值会把图案向较小的 s 方向移动。取模应落在 [0,P),不能直接依赖语言对负数余数的符号约定。
三个点的前缀距离是 0、7、16,因此第一段拥有 [0,7),第二段拥有 [7,16)。第二段上的局部距离 t 必须映射成 s=7+t。这个前缀值属于路径,既不属于某次 Draw Call,也不属于某个临时可见片段。动态 Mesh 可以按批次容量拆开,距离属性仍要从同一份路径前缀表取得。
JavaScript 探针的 dash.mjs 先由 pathSpans 生成前缀区间,再让 dashIntervals 枚举实线与输入区间的交集。返回区间统一保持源路径距离,调用方若需要线段内插值,最后再用 (s-start)/(end-start) 换成该段参数。完整核心实现如下:
export function pathSpans(points) {
if (!Array.isArray(points) || points.length < 2 || !points.every(p =>
Array.isArray(p) && p.length === 2 && p.every(Number.isFinite)))
throw new RangeError('invalid points');
let distance = 0;
return points.slice(1).map((p, i) => {
const length = Math.hypot(p[0] - points[i][0], p[1] - points[i][1]);
if (!(length > 0) || !Number.isFinite(distance + length))
throw new RangeError('invalid segment');
const span = { start: distance, end: distance + length };
distance += length;
return span;
});
}
export function dashIntervals(start, end, dash, gap, phase = 0) {
if (![start, end, dash, gap, phase].every(Number.isFinite) ||
end < start || dash <= 0 || gap <= 0)
throw new RangeError('invalid dash domain');
const period = dash + gap;
if (!Number.isFinite(period)) throw new RangeError('invalid period');
phase = ((phase % period) + period) % period;
const first = Math.floor((start + phase) / period);
const last = Math.floor((end + phase) / period);
if (!Number.isSafeInteger(first) || !Number.isSafeInteger(last) ||
Math.max(Math.abs(first), Math.abs(last)) > 1e9 || last - first > 100000)
throw new RangeError('dash range exceeds budget');
const result = [];
for (let k = first; k <= last; k++) {
const left = k * period - phase;
const lo = Math.max(start, left), hi = Math.min(end, left + dash);
if (hi > lo) result.push([lo, hi]);
}
return result;
}
这里主动拒绝重复点、零间隙和反向区间,避免把清洗策略混进图案计算。需要连续实线时应走明确的实线分支;需要容忍重复控制点时应先压缩零长度段,再建立前缀表。单次枚举限制在十万个周期差以内,这是当前 CPU 探针的分配预算,不是绘制系统的吞吐结论。
裁剪改变可见域,不能改变原点
同一路径只显示累计距离 [3,14) 时,正确结果是 [3,6) 与 [10,14),即已有图案与可见域求交。若把可见片段搬到局部 [0,11) 后重新生成,再平移回源坐标,就得到 [3,9) 与 [13,14)。视口只移动了,虚线周期却跟着视口移动;这种错误即使在一条直线上也会出现。

对于被裁剪到参数 t 的线段,新顶点的距离仍是 s_start+t·length。若只是二维仿射空间内的线性裁剪,可以按同一个 t 插值距离;进入透视投影后,屏幕线性参数与世界线性参数并不相同,必须按所选距离域保留正确的插值关系。不能把裁剪后可见线段的长度重新当成源路径总长。
由此可以得到一个直接的回归性质:把任意区间切成左右两半,分别生成再合并相邻实线,结果应与整段生成一致。新增一个共线控制点、调整 Mesh 批次边界、移动裁剪边界,都不应改变同一源距离上的覆盖判定。它比只检查某张静态截图更能约束距离属性的所有权。
单位选错,同样会出现密度变化
这组固定输入工作在二维路径坐标中,没有经过相机投影。若产品要求地图上的每段虚线代表固定世界长度,就在世界度量下累计,缩放视图后屏幕上的周期自然会改变。若要求 HUD 始终是六像素实线、四像素空隙,就应在屏幕或最终 UI 像素度量下计长;相机、投影或非均匀缩放变化后,需要重新得到对应的距离数据。
两种需求不能共享一份未经说明的局部坐标长度。尤其是非均匀缩放,无法用一个统一倍率把所有朝向的局部长度换成屏幕长度。路径数据接口至少应交付距离域、各段前缀和图案参数;重采样或改变控制点时,还必须明确是保留旧路径参数化,还是按新几何重新计长。几何长度真的改变时,后方图案随之移动是合同结果,不属于裁剪相位错误。
长路径还存在精度边界。当前实现把周期索引限制在绝对值十亿以内,但这不是任意比例参数下的精度证明;极小间隙叠加巨大累计距离仍会损失浮点有效位。生产侧可在分块入口保存高精度前缀,把块内距离控制在小范围,再以等价的周期余数传递前缀相位。重置局部数值原点时必须补偿相位,否则精度修复会重新引入同一个接缝。
分段不变性由两类参照共同检查
test-dash.mjs 保留了分段重置和裁剪重置两条错误路径,断言它们产生开篇的错误区间;这两个反例是通过测试验证的失败样本。其余边界包括负源距离、负相位的周期等价、空区间、半开端点、重复点、非有限输入和枚举预算。
随机测试使用固定种子,生成整数长度、相位与切分点,比较整段枚举与分段合并结果;再逐个检查半整数采样点,用独立的取模判定核对覆盖。该输入形状刻意避开浮点端点歧义,验证的是覆盖与分段合同,不包含 GPU 抗锯齿、接头三角化或像素观感。以下为 2026-11-07 当天 Node.js 执行记录:
$ node "$LAB_ROOT/test-dash.mjs"
tests=15 passed=15 failed=0 skipped=0 elapsedMs=201.044
randomCases=10000 referenceSamples=506074
sourceSpans=[0,7),[7,16)
global=[0,6),[10,16)
restartAtJoint=[0,6),[7,13)
clip=[3,6),[10,14)
restartAtClip=[3,9),[13,14)
exitCode=0
这份实现只生成沿中心线的覆盖区间,不决定端帽、线宽与拐角的填充方式。接入渲染时,可以把这些区间转换成独立短线段,也可以把累计距离送入材质做周期判定;选择取决于网格数量、过滤和接头需求。无论采用哪种方式,批次和裁剪都只能消费路径距离,不能重新定义它。这样,视口变化才只改变看得见哪一段,而不会改写那一段原本是什么图案。