就圆锥曲线而言,不幸的是,双曲线是 Canvas 无法原生渲染的一类曲线,因此您只能逼近所需的曲线。这里有一些选项:
- 通过在远处的一两个点和极值附近的很多个点采样双曲线,使曲线变平,这样您就可以绘制一个看起来的简单多边形像一条曲线。
- 使用单个“最佳近似”二次或三次曲线对双曲线建模。
- 正如@fang 所提到的:在几个点对曲线进行采样,然后通过这些点将 Catmull-Rom 样条转换为 Bezier 形式。
- 结合方法 1 和 2。使用单个贝塞尔曲线来近似双曲线中实际上看起来弯曲的部分,并使用直线来表示没有弯曲的部分。
- 结合方法 1 和 3,对弯曲钻头使用 Catmull-Rom 样条,对直钻头使用直线。
1:曲线变平
曲线变平基本上是微不足道的。旋转曲线直到它与轴对齐,然后使用标准双曲函数计算y 给定x,其中a 是极值之间距离的一半,b 是半短轴:
x²/a² - y²/b² = 1
x²/a² = 1 + y²/b²
x²/a² - 1 = y²/b²
b²(x²/a² - 1) = y²
b²(x²/a² - 1) = y²
± sqrt(b²(x²/a² - 1)) = y
插入您的值,遍历x 以获得一系列(x,y) 坐标(请记住在极值附近生成更多坐标),然后将它们转换为moveTo() 作为第一个坐标,然后是其余的需要您拨打许多lineTo() 电话。只要您的点密度对于您所呈现的比例足够高,这应该看起来不错:
function flattenHyperbola(a, b, inf=1000) {
const points = [],
a2 = a**2,
b2 = b**2;
let x, y, x2;
for (x=inf; x>0.1; x/=2) {
x2 = (a+x)**2;
y = -Math.sqrt(b2*x2/a2 - b2);
points.push({x: a+x, y});
}
points.push({x:a, y:0});
for (x=0.1; x<inf; x*=2) {
x2 = (a+x)*(a+x);
y = Math.sqrt(b2*x2/a2 - b2);
points.push({x: a+x, y});
}
return points;
}
让我们用红色绘制双曲线,用蓝色绘制近似值:
当然,这种方法的缺点是您需要为用户查看图形的每个比例创建单独的扁平曲线。或者,您需要生成一条带有很多点的扁平曲线,然后根据放大/缩小的程度跳过坐标来绘制它。
2:贝塞尔近似
双曲线的参数表示是f(t)=(a*sec(t), b*tan(t))(或者更确切地说,这是 y 轴对齐的双曲线的表示 - 我们可以通过应用标准旋转变换来获得任何其他变体)。我们可以快速查看这些函数的泰勒级数,看看我们可以使用哪种贝塞尔曲线阶数:
sec(t) = 1 + t²/2 + 5t⁴/15 + ...
tan(t) = x + t³/3 + 2t⁵/15 + ...
因此,我们也许可以只使用每个维度的前两项,在这种情况下,我们可以使用三次贝塞尔曲线(因为最高阶是 t³):
事实证明,这是行不通的:它太不准确了,所以我们将不得不更好地近似:我们创建一个贝塞尔曲线,其起点和终点“距离很远”,使用控件点设置such that the Bezier midpoint coincides with the hyperbola's extrema。如果我们尝试这样做,我们可能会误以为这会起作用:
但是如果我们选择足够远的x,我们会看到这个近似值很快就失效了:
function touchingParabolicHyperbola(a, b, inf=1000) {
const beziers = [],
a2 = a**2,
b2 = b**2;
let x, x2, y, A, CA;
for(x=50; x<inf; x+=50) {
x2 = x**2;
y = sqrt(b2*x2/a2 - b2);
// Hit up https://pomax.github.io/bezierinfo/#abc
// and model the hyperbola in the cubic graphic to
// understand why the next, very simple-looking,
// line actually works:
A = a - (x-a)/3;
// We want the control points for this A to lie on
// the asymptote, but for small x we want it to be 0,
// otherwise the curve won't run parallel to the
// hyperbola at the start and end points.
CA = lerp(0, A*b/a, x/inf);
beziers.push([
{x, y: -y},
{x: A, y:-CA},
{x: A, y: CA},
{x, y},
]);
}
return beziers;
}
这向我们展示了一系列曲线,开始看起来不错,但很快就变得完全无用:
一个明显的问题是曲线最终会越过渐近线。我们可以通过将控制点强制为 (0,0) 来解决这个问题,这样贝塞尔外壳是一个三角形,而曲线将始终位于其中。
function tangentialParabolicHyperbola(a, b, inf=1000) {
const beziers = [],
a2 = a**2,
b2 = b**2;
let x, x2, y;
for(x=50; x<inf; x+=50) {
x2 = x**2;
y = sqrt(b2*x2/a2 - b2);
beziers.push([
{x, y:-y},
{x: 0, y:0},
{x: 0, y:0},
{x, y},
]);
}
return beziers;
}
这导致了一系列曲线,从一侧无用到另一侧无用:
所以单曲线近似并不是那么好。如果我们使用更多的曲线呢?
3:使用 Catmull-Rom 样条的 Poly-Bezier
我们可以通过沿双曲线使用多条贝塞尔曲线来克服上述问题,我们可以(几乎不费吹灰之力)通过在双曲线上选取几个坐标来计算,然后通过这些点构造一个Catmull-Rom spline。由于通过 N 个点的 Catmull-Rom 样条曲线等效于由 N-3 段组成的 poly-Bezier,因此这可能是制胜策略。
function hyperbolaToPolyBezier(a, b, inf=1000) {
const points = [],
a2 = a**2,
b2 = b**2,
step = inf/10;
let x, y, x2,
for (x=a+inf; x>a; x-=step) {
x2 = x**2;
y = -Math.sqrt(b2*x2/a2 - b2);
points.push({x, y});
}
for (x=a; x<a+inf; x+=step) {
x2 = x**2;
y = Math.sqrt(b2*x2/a2 - b2);
points.push({x, y});
}
return crToBezier(points);
}
the conversion function 是:
function crToBezier(points) {
const beziers = [];
for(let i=0; i<points.length-3; i++) {
// NOTE THE i++ HERE! We're performing a sliding window conversion.
let [p1, p2, p3, p4] = points.slice(i);
beziers.push({
start: p2,
end: p3,
c1: { x: p2.x + (p3.x-p1.x)/6, y: p2.y + (p3.y-p1.y)/6 },
c2: { x: p3.x - (p4.x-p2.x)/6, y: p3.y - (p4.y-p2.y)/6 }
})
}
return beziers;
}
让我们绘制:
与拉平相比,我们必须在前期做更多的工作,但好处是我们现在有一条在任何比例下实际上“看起来都像曲线”的曲线。
4:结合(1)和(2)
现在,大多数双曲线实际上“看起来是直的”,因此对这些部分使用大量 Bezier 曲线确实有点傻:为什么不只用曲线建模弯曲的钻头,而用直线建模直线钻头呢?
我们已经看到,如果我们将控制点固定为 (0,0),则可能会有一条曲线至少足够好,所以让我们结合方法 1 和 2,我们可以创建一条带有 start 和端点“足够接近”曲线,并将两条线段连接到将贝塞尔曲线连接到渐近线上的两个远点(位于y=±b/a * x,因此x 的任何大值将产生够用了y)
当然,诀窍是找到单条曲线仍能捕捉曲率的距离,同时还要使我们的无穷远线看起来像是平滑地连接到我们的单条曲线。 Bezier projection identity 再次派上用场:我们希望 A 位于 (0,0) 并且我们希望 Bezier 中点位于 (a,0),这意味着我们的起点和终点应该具有 x 坐标987654357@:
function hyperbolicallyFitParabolica(a, b, inf=1000) {
const a2 = a**2,
b2 = b**2,
x = 4*a,
x2 = x**2,
y = sqrt(b2*x2/a2 - b2)
bezier = [
{x: x, y:-y},
{x: 0, y: 0},
{x: 0, y: 0},
{x: x, y: y},
],
start = { x1:x, y1:-y, x2:inf, y2: -inf * b/a},
end = { x1:x, y1: y, x2:inf, y2: inf * b/a};
return [start, bezier, end];
}
这给了我们以下结果(Bezier 为蓝色,线段为黑色):
所以这不是很好,但也不是很糟糕。如果观众不仔细检查渲染当然就足够了,而且它肯定很便宜,但是我们只需多做一点工作就可以做得更好,所以:让我们也看看我们可能在这里想出的最佳近似值:
5:结合(1)和(3)
如果单个 Bezier 不起作用,并且我们已经看到使用 Catmull-Rom 样条而不是单个曲线效果更好,那么我们当然也可以将方法 1 和 3 结合起来。我们可以形成一个 much通过构建两条贝塞尔曲线而不是一条贝塞尔曲线来更好地拟合极值,方法是生成以极值为中心的五个点并将生成的 Catmull-Rom 样条通过这些点转换为贝塞尔形式:
function probablyTheBestHyperbola(a, b, inf=1000) {
let curve = [],
a2 = a**2,
b2 = b**2,
x, y, x2,
cover = 100;
// generate two points approaching the midpoint
for (x=a+cover; x>a; x-=cover/2) {
x2 = x**2;
y = -Math.sqrt(b2*x2/a2 - b2);
curve.add(new Vec2(x, y));
}
// generate three points departing at the midpoint
for (x=a; x<=a+cover; x+=cover/2) {
x2 = x*x;
y = sqrt(b2*x2/a2 - b2);
curve.add(new Vec2(x, y));
}
const beziers = crToBezier(curve),
start = {
x1: points.get(1).x, y1: points.get(1).y,
x2: inf, y2: -inf * b/a
},
end = {
x1: points.get(3).x, y1: points.get(3).y,
x2: inf, y2: inf * b/a
};
return { start, beziers, end };
}
这给了我们以下结果(蓝色为 CR,黑色为线段):
这可能是我们在“计算成本低”、“易于扩展”和“外观合适”之间取得的最佳平衡。