丝滑的贝塞尔曲线:从数学原理到应用

1,700 阅读13分钟

曲线入门问题

本文正在参加「金石计划」 各种前端应用中会经常使用曲线,如绘制圆形、椭圆形等图形,使用CSS实现淡入淡出动画,渲染平滑的趋势报表图。借助canvas、echarts、highcharts、D3.js可以轻轻松松实现以上功能,但如果在不借助各种开源库情况下自己实现:

  1. 椭圆、圆的绘制
    屏幕快照 2023-03-21 上午12.34.23.png
  2. 淡入淡出动画
    淡入淡出动画.gif
  3. 平滑的报表趋势图
    屏幕快照 2023-03-21 上午12.57.31.png

本篇内容主要借助这三个问题,了解Cubic Bezier Curve是什么,如何使用三次贝塞尔曲线从0到1实现三个问题中的场景,实现的代码会push到github,可下载体验。

Cubic Bezier Curve应用

Cubic Bezier Curve是前端常被使用的曲线类型,也被应用到很多领域,如:

  1. 计算机图形:当需要绘制各种类型的形状,如圆、椭圆、螺旋形等等,可使用三次贝塞尔曲线绘制其线条和形状。
  2. 动画特效:用于动画设计,以创建流畅的过渡特效。如用于创建淡入淡出、缩放和滑动等流畅、平滑的效果。
  3. 设计工具:三次贝塞尔曲线适用于产品设计,当设计汽车、家具等模型时,可使用它创建平滑的模型形态。
  4. 排版:三次贝塞尔曲线用于Typography,以创建流畅的字体和字体样式。在字体设计中,三次贝塞尔曲线用于定义单个字形或字符的轮廓,以及字体的整体风格和外观。这些曲线用于定义构成字体的笔划、衬线和其他细节。也可用于文本布局,如一段文字沿着一条平滑的曲线排列。

Cubic Bezier Curve定义

什么是三次贝塞尔曲线,三次贝塞尔曲线是计算机图形学最常用的曲线类型,由四个points组成:两个端点(P0、P3)、两个控制点(P1、P2)。从端点P0开始,通过P1、P2控制曲率来添加平滑的途经点直到端点P3,如下图所示: cub_bez_curve.jpeg
假如已知四个points,如何根据这四个点计算出途经点?这里需要使用到三次贝塞尔曲线方程,中间插值被归一化到范围为[0, 1]的t,当t=0,可知B(0) = P0,但t=1,可以B(1) = P3。随着P1、P2两个控制点的位置调整,绘制出的曲线也会有比较明显的区分。 屏幕快照 2023-03-12 上午12.27.56.png 曲线方程B(t)如何得出的?可通过插值方式推导。结合下面的图介绍曲线方程的推导过程,已知两个端点A和D、两个控制点B和C,两点之间的插值公式P(t) = P0 + (P1 - P0)t, 单位时间t范围[0, 1]。

  • 线段AB上的P(t) = A + (B - A)t [方程1]
  • 线段BC上的Q(t) = B + (C -B)t [方程2]
  • 线段CD上的R(t) = C + (D - C)t [方程3]
  • 定义中间点S, 其方程为S(t) = P + (Q - P)t [方程4],代表线段PQ上的一次曲线方程。
  • 定义中间点T,其方程为T(t) = Q + (R - Q)t [方程5],代表线段QR上的一次曲线方程。
  • 定义中间点O,其方程为O(t) = S + (T - S)t [方程6],代表线段ST上的一次曲线方程。

O点为ST的一次曲线方程,为PQR的二次曲线方程,也为ABCD的三次曲线方程。将O(t)的方程使用A、B、C、D四个点代替即可推导出三次曲线方程。

贝塞尔曲线动画.gif

从0到1绘制椭圆

canvas元素提供有三次贝塞尔曲线的绘制APIbezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y),其参数分别表示控制点P1、P2以及端点P3,绘制的当前位置即为P0,如使用moveTo设置当前绘制位置。如下代码借助bezierCurveTo函数可方便地绘制一个椭圆。

function drawEllipse(ctx, x, y, w, h) {
    var kappa = 0.5522848, // 作为计算两个控制点的偏移量的常数,由圆曲率公式推导
        ox = (w / 2) * kappa, // 控制点水平方向偏移量
        oy = (h / 2) * kappa, // 控制点垂直方向偏移量
        xe = x + w,           // x方向结束位置
        ye = y + h,           // y方向结束位置
        xm = x + w / 2,       // x方向中点
        ym = y + h / 2;       // y方向中点
  
    ctx.beginPath();
    // 移动到端点P0
    ctx.moveTo(x, ym);
    // bezierCurveTo(P1, P2, P3),将椭圆分为四段,分别绘制每一段的曲线
    ctx.bezierCurveTo(x, ym - oy, xm - ox, y, xm, y);
    ctx.bezierCurveTo(xm + ox, y, xe, ym - oy, xe, ym);
    ctx.bezierCurveTo(xe, ym + oy, xm + ox, ye, xm, ye);
    ctx.bezierCurveTo(xm - ox, ye, x, ym + oy, x, ym);
    ctx.closePath();
    ctx.stroke();
  }

代码中有使用值为0.5522848的常量kappa,用于计算每段曲线控制点x、y两个方向的偏移量,如p1坐标为[x, ym - oy] ,其中oy为垂直方向的偏移量。kappa作为一个常量,如何计算出来?kappa = 4 * ((sqrt(2) - 1) / 3), 近似为0.5522848。

屏幕快照 2023-03-21 上午1.14.22.png

至于kappa的推导,将圆切分4段,O为坐标原点A、A'、B'、B,C为弧的中心位置AOC为45度,圆半径r等于OA、OB、OC长度,l为AA'、BB'长度,l = r * kappa,为了简化,将r值设置为1,则l = kappa,可得到A、A'、B'、B的坐标。

屏幕快照 2023-03-21 上午1.17.28.png

A  = [0, 1]
A' = [kappa, 1]
B' = [1, kappa]
B  = [1, 0]

右三次贝塞尔曲线方程可知关于u的方程:

P(u) = A * (1 - u)³ + A' * 3u(1 - u)² + B' * 3u²(1 - u) + B * u³ 方程1

位置C处u = 0.5,带入P(u)可得

C = A/8 + 3A'/8 + 3B'/8 + B/8 方程2

已知C为弧AB中点位置,可知在x、y方向的长度c = sqrt(1/2),计算x方向,带入方程2可得:

0/8 + 3 * kappa/8 + 3/8 + 1/8 = sqrt(2)/2
...
kappa = 4 * (sqrt(2) - 1) / 3

最终得到kappa值:

kappa.png

到目前已经知道贝塞尔曲线的方程以及绘制椭圆的kappa值,那么也可以轻松地计算出椭圆的点集合,根据曲线方程公式生成bezier函数, p0、p3为端点,p1、p2为控制点。

export function bezierFormula(p0, p1, p2, p3) {
    return function bezier(t) {
        return p0 * Math.pow(1 - t, 3) + p1 * 3 * t * Math.pow(1 - t, 2) + p2 * 3 * Math.pow(t, 2) * (1 - t) + p2 * Math.pow(t, 2);
    };
}

定义三次贝塞尔曲线APIbezierCurve(p0, p1, p2, p3),分别计算x、y方向的中间插值,这里固定差值长度为100,steps可根据具体业务调整,如按单位长度差值。

/**
 * 计算三次贝塞尔曲线
 * @param p0 端点1
 * @param p1 控制点1
 * @param p2 控制点2
 * @param p3 端点2
 */
export function bezierCurve(p0: [number, number], p1: [number, number], p2: [number, number], p3: [number, number]): [number, number][] {
    const formulaX = bezierFormula(p0[0], p1[0], p2[0], p3[0]);
    const formulaY = bezierFormula(p0[1], p1[1], p2[1], p3[1]);

    const steps = 100, result: [number, number][] = [];
    for (let i = 0; i < steps; i++) {
        result.push([formulaX(i / steps), formulaY(i / steps) ])
    }
    result.push([formulaX(1), formulaY(1)]);

    return result;
}

有了曲线计算函数,就可以得到每段1/4的轨迹点,封装计算椭圆轨迹点的函数ellipse(x: number, y: number, w: number, h: number): [number, number][],分别计算每段弧的轨迹点并拼接起来,最终即可得到完整的椭圆形态。

const kappa = 4 * (Math.sqrt(2) - 1) / 3;

/**
 * 椭圆贝塞尔曲线计算
 * @param x 绘制x起始坐标
 * @param y 绘制y起始坐标
 * @param w 宽度
 * @param h 高度
 */
export function ellipse(x: number, y: number, w: number, h: number): [number, number][] {
    const cx = x + w / 2, cy = y + h / 2;

    // 绘制顺序:左上、右上、右下、左下
    const arcs1 = bezierCurve(
        [x, cy], 
        [x, cy - kappa * h / 2],
        [cx - kappa * w / 2, y],
        [cx, y]
    );
    const arcs2 = bezierCurve(
        [cx, y],
        [cx + kappa * w / 2, y],
        [cx + w / 2, cy - kappa * h / 2],
        [cx + w / 2, cy]
    );
    const arcs3 = bezierCurve(
        [cx + w / 2, cy],
        [cx + w / 2, cy + kappa * h / 2],
        [cx + kappa * w / 2, cy + h / 2],
        [cx, cy + h / 2]
    );
    const arcs4 = bezierCurve(
        [cx, cy + h / 2],
        [cx - kappa * w / 2, cy + h / 2],
        [x, cy + kappa * h / 2],
        [x, cy]
    );
    
    // 每一段删除和下一段起始重叠的点
    arcs1.splice(-1, 1);
    arcs2.splice(-1, 1);
    arcs3.splice(-1, 1);
    arcs4.splice(-1, 1);

    return [...arcs1, ...arcs2, ... arcs3, ...arcs4];
}

获取到完整的轨迹序列,使用canvas API或者Dom元素都可绘制出和bezierCurveTo相同的效果。

/**
 * 绘制椭圆
 * @param x 绘制x起始坐标
 * @param y 绘制y起始坐标
 * @param w 宽度
 * @param h 高度
 */
export function drawEllipse(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) {
    const points = ellipse(x, y, w, h);
    
    const randomRGB = () => {
       const r = Number(255 * Math.random());
       const g = Number(255 * Math.random());
       const b = Number(255 * Math.random());

       return `rgb(${r},${g},${b})`;
    }
    ctx.lineWidth = 5;
    for (let i = 1; i < points.length; i++) {
        ctx.beginPath();
        const prevPoint = points[i - 1];
        ctx.moveTo(prevPoint[0], prevPoint[1]);
        ctx.strokeStyle = randomRGB();
        const curPoint = points[i];
        ctx.lineTo(curPoint[0], curPoint[1]);
        ctx.stroke();
    }
}

屏幕快照 2023-03-21 上午1.32.55.png

淡入淡出动画

用CSS实现一个easeIn、easOut的动画很简单,通过定义@keyframes,设置transition、transition-timing-functions属性即可实现,transition-timing-function属性指定了三次曲线方程cubic-bezier,其参数为cubic-beizer(x1, y1, x2, y2), 分别表示控制点p1和p2的x、y坐标,如果想要查看具体效果,可在线预览动画效果

@keyframes fadeIn {
    0% { left: 0; }
    100% { left: calc(100% - 50px); }
}

.fade-in-cbezier {
    transition: all 1s;
    transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1.0); 
}

除了使用CSS实现动画,前段也有很多开源库可实现和CSS类似的动画效果,如Animate.cssReact SpringLax.js。如何实现一个类似cubic-bezier函数的淡入淡出动画?可以考虑借助前面实现的bezierFormulabezierCurve函数来实现,首先定义动画的API:animate(el: HTMLElement, props: AnimationProps), props参数包含durationeasingstyles三个属性,styles为长度为2的数组,分别表示from、to的属性。

export type StyleProperties = {
    [x: string]: string | number;
}

export type AnimationProps = {
    duration: number;
    styles: [StyleProperties, StyleProperties];
    easing: string;
}

/**
 * 元素动画
 * @param el DOM元素
 * @param props 动画属性
 */
export function animate(el: HTMLElement, props: AnimationProps): void {
    const duration = props.duration;
    const easingFunc = resolveEasing(props.easing);
    const styleFuncs = resolveStyles(el, props.styles);

    const start = Date.now();
    const animationHandle = () => {
        const timeRatio = (Date.now() - start) / duration;
        console.log(`timeRatio: ${timeRatio}`);
        if (timeRatio <= 1) {
            const percent = easingFunc(timeRatio);
            for (const key in styleFuncs) {
                const elementStyle = el.style as any;
                elementStyle[key] = styleFuncs[key](percent);
            }
            requestAnimationFrame(animationHandle);
        }
    }

    animationHandle();
}

animate函数会将传入的easing、styles解析为(t) => number | string格式的动态函数,通过单位时间t∈[0, 1]获取值。假如easing参数为字符串cbezier(0.25, 0.1, 0.25, 1.0),cbezier表示使用三次贝塞尔动画,[0.25, 0.1]为P1,[0.25, 1.0]为P2,resolveEasing会从字符串解析参数,并调用bezierFormula获取纵向的动态函数。
resolveStyles函数解析属性的from、to值,并为每个属性生成计算函数,假如left从0到100%,则动态值为0 + 100 * percent,percent∈[0, 1]表示时间进度。

/**
 * 动画效果函数,返回基于t(范围[0, 1])的函数,其执行结果范围为[0, 1]
 * @param easing 
 * @returns 动画函数
 */
function resolveEasing(easing: string): (t: number) => number {
    const bezierMatch = /cbezier\((\d+.?\d+),\s?(\d+.?\d+),\s?(\d+.?\d+),\s?(\d+.?\d+)\)/g.exec(easing);
    if (bezierMatch?.length) {
        const x1 = Number(bezierMatch[1]), 
            y1 = Number(bezierMatch[2]), 
            x2 = Number(bezierMatch[3]), 
            y2 = Number(bezierMatch[4]);
        
        return bezierFormula(0, y1, y2, 1);
    }

    return (t: number) => t;
}

function resolveStyles(el: Element, styles: [StyleProperties, StyleProperties]) {
    const keys = Object.keys(styles[0]);
    const styleFuncs: { [x: string]: (t: number) => number | string } = {};

    for (const key of keys) {
        // 测试先支持百分比格式
        const unit = '%';
        const sVal = Number(/(\d+)%/g.exec(styles[0][key] + '')?.[1]);
        const eVal = Number(/(\d+)%/g.exec(styles[1][key] + '')?.[1]);
        const total = eVal - sVal;

        styleFuncs[key] = (percent: number) => {
            const curVal = sVal + total * percent;
            console.log(`percent: ${percent}, style func: ` + curVal + unit);
            return curVal + unit;
        }
    }

    return styleFuncs;
}

最终动画效果如下,红色为Linear、绿色为CSS的cubic-beizer、蓝色为自己实现的cbezier,自己实现的动画和cubic-beizer还是有明显差别,要达到相同的效果,还需要进一步优化resolveEasing函数,这里附上一个比较完整cubic-bezier实现,部分逻辑还未完全理解,以后有时间再慢慢啃。

自定义曲线动画.gif

曲线平滑,绘制报表

图表是前端会经常使用的功能,下图是highcharts实现的一个折线报表,某些场景会将折线处理为平滑的曲线,假如要从0到1实现一个支持平滑的曲线报表,该如何实现?

屏幕快照 2023-03-18 下午2.22.44.png

报表的输入是点集合,如A = [[1, 1], [4, 7], [7, 4], [11, 10], 生成的线类型一般为line或者smooth,当指定为smooth时,需要将A转换为平滑的曲线,其原理为将每段进行平滑,如先平滑A1 = [[1, 1], [4, 7]],但平滑需要两个控制点,中间的两个控制点如何计算?由于需要保持平滑,因此A1的控制点P2需要和下一段段B = [[4, 7], [7, 4]]的控制点P1保持斜率相等,使[4, 7]位置曲线平滑。控制点的推算需要借助Thomas Algorithm, 它是一种基于高斯消元法的算法。

控制点推算

将三次贝塞尔曲线写为:

屏幕快照 2023-03-19 上午10.52.44.png

其一阶导为:

屏幕快照 2023-03-19 上午10.58.05.png

由于在中间节点(P0、P3)处平滑,其斜率相等,则有:

屏幕快照 2023-03-19 上午11.00.39.png 即: 屏幕快照 2023-03-19 上午11.01.23.png

其中 P0,i和P3,i-1表示同一个节点Ki,可得到等式1:

屏幕快照 2023-03-19 上午11.04.19.png

曲线的二阶导为:

屏幕快照 2023-03-19 上午11.05.13.png

二阶导在节点处也连续,则有:

屏幕快照 2023-03-19 上午11.07.13.png

其中P0,i和P3,i-1为同一节点Ki,得到等式2:

屏幕快照 2023-03-19 上午11.07.36.png

假设原始点一共有n个片段,方程1、方程2定义为中间每两个片段的方程,控制点一共有2n个,但方程只有2(n-1)个,还差两个方程才能求解。由于0和n-1两个边界处曲率不再变化,两个自然边界条件B''0(0)=0和B''n-1(0)=0。可得到方程3、方程4:

屏幕快照 2023-03-19 上午11.40.02.png

屏幕快照 2023-03-19 上午11.40.23.png

合并方程1、方程2,可得:

屏幕快照 2023-03-19 上午11.40.58.png

方程3、方程4可写为:

屏幕快照 2023-03-19 上午11.53.00.png

屏幕快照 2023-03-19 上午11.53.15.png

2n个控制点,2n个方程,那么可以根据方程组计算控制点了。上面3个方程未知的都为P1, x,可通过解三对角矩阵(Tridiagonal Matrices),常用的解法为Thomas Algorithm,一种基于高斯消元的算法。 起始位置a1 = 0, b1 = 2, c1 = 1, r1 = K0 + 2K2,位置位置ai = 1,bi = 4,ci=1, ri=4Ki + 2Ki+1, 终点位置a6=2, b6=7, c6=0, r6=8Kn-1 + Kn

屏幕快照 2023-03-19 下午12.07.50.png

从第一行一步步消元,直到第六行,最终得到如下形式,其中v1,...,v5以及l1,...,l6为中间变量,可得到P6 = l6,从而可推算P5至P1的值。

屏幕快照 2023-03-19 下午12.21.20.png

当算出所有P1控制点,可结合方程1、方程2推算所有P2控制点:

屏幕快照 2023-03-19 下午12.25.57.png
屏幕快照 2023-03-19 下午12.26.02.png

代码实现

根据上面的介绍,需要对每个片段分别平滑,然后拼接起来。下面为平滑的代码实现,先调用computeControlPoints函数分别计算横轴、纵轴两个方向的P1、P2控制点,当计算完所有控制点,每片段可根据P0、P1、P2、P3调用bezierCurve函数计算曲线。

/**
 * 曲线平滑,对轨迹点points做平滑处理 
 * @param points 
 * @returns 
 */
export function smooth(points: [number, number][]): [number, number][] {
    if (points.length <= 2) {
        return points;
    } 
    const { p1: xp1, p2: xp2 } = computeControlPoints(points.map(p => p[0]));
    const { p1: yp1, p2: yp2 } = computeControlPoints(points.map(p => p[1]));

    const smoothed: [number, number][] = [];
    for (let i = 0; i < points.length - 1; i++) {
        const p0 = points[i], 
            p1: [number, number] = [xp1[i], yp1[i]], 
            p2: [number, number] = [xp2[i], yp2[i]], 
            p3 = points[i + 1];
        const line = bezierCurve(p0, p1, p2, p3);
        line.splice(-1, 1);
        smoothed.push(...line);
    }
    smoothed.push(points[points.length - 1]);

    return smoothed;
}

曲线平滑的核心在computeControlPoints函数,按照上面介绍的推算过程一步步实现,首先将最后三个方程的系数ai、bi、ci分别保存到a、b、c三个数组,然后按Thomas algorithm进行矩阵行消元,当得到p1[n-1]之后,再推算出P1[n-2],...,P1[0]。最后结合方程1、方程4计算出所有P2控制点的值。

/**
 * 计算由points组成的n-1条线段的中间控制点p1、p2
 * @param points number[] 轨迹点坐标集合
 * @returns { p1: number[], p2: number[] } 控制点p1、p2的集合
 */
export function computeControlPoints(points: number[]): { p1: number[], p2: number[] } {
    const n = points.length - 1;
    const p1 = new Array(n), p2 = new Array(n);

    const a = new Array(n), b = new Array(n), c = new Array(n), r = new Array(n);
    // first segment
    a[0] = 0;
    b[0] = 2;
    c[0] = 1;
    r[0] = points[0] + 2 * points[1];

    // middle segment
    for (let i = 1; i < n - 1; i++) {
        a[i] = 1;
        b[i] = 4;
        c[i] = 1;
        r[i] = 4 * points[i] + 2 * points[i + 1];
    }

    // last segment
    a[n - 1] = 2;
    b[n - 1] = 7;
    c[n - 1] = 0;
    r[n - 1] = 8 * points[n - 1] + points[n];

    // Thomas algorithm (from Wikipedia):https://www.cnblogs.com/xpvincent/archive/2013/01/25/2877411.html
    for (let i = 1; i <= n - 1; i++) {
        b[i] = b[i] - a[i] * c[i - 1] / b[i - 1];
        r[i] = r[i] - a[i] * r[i - 1] / b[i - 1];
    }
    p1[n - 1] = r[n - 1] / b[n - 1];
    // from n-2 to 0,compute values of p1;
    for (let i = n - 2; i >= 0; i--) {
        // b[i] * p1[i] + c[i] * p1[i + 1] = r[i],
        p1[i] = (r[i] - c[i] * p1[i + 1]) / b[i];
    } 
    // compute p2 by equation p2[i] = 2 * points[i] - p1[i]
    for (let i = 0; i < n - 1; i++) {
        p2[i] = 2 * points[i + 1] - p1[i + 1];
    }
    // by equation p1[n - 1] - 2 * p2[n - 1] + points[n] = 0
    p2[n - 1] = (p1[n - 1] + points[n]) / 2;

    return { p1, p2 };
}

曲线报表

以实现折线图为例,先定义其API参数, 包括title、x轴数据、y轴数据:

export type CChartOptions = {
    title: { text: string },
    yAxis: { 
        title: { text: string }
     },
     xAxis: {
        type: 'category';
        data: [number ,number];
     },
     series: { data: number[]; type: 'line' | 'smooth' }[]
}

定义报表函数cchart(ele, optins), init初始化报表x、y轴,render渲染折线或者曲线。

export function cchart(ele: HTMLElement, options: CChartOptions) {
    const canvas = document.createElement('canvas');
    canvas.width = ele.clientWidth;
    canvas.height = ele.clientHeight;
    ele.appendChild(canvas);

    init(canvas, options);
    render(canvas, options);
}

render的核心是遍历options的series列表,分别计算每条曲线,如果曲线type为smooth,则会将原始点集合orignalPoints通过上面实现的smooth函数平滑处理。

function render(canvas: HTMLCanvasElement, options: CChartOptions) {
    const ctx = <CanvasRenderingContext2D>canvas.getContext('2d');
    assert(ctx, 'Browser don\'t support CanvasRenderingContext2D.');
    const origin = [ configs.padding.x, canvas.height - configs.padding.y ];

    // draw value on y axios with yAxis's data
    let min = Infinity, max = -Infinity;
    options.series[0].data.forEach((val , i) => {
        min = Math.min(val, min);
        max = Math.max(val, max);
    });
    const yUnit = (canvas.height - configs.padding.y * 2) / configs.segLength.y;
    const yResolution = max / (canvas.height - 2 * configs.padding.y);
    const xUnit = (canvas.width - configs.padding.x * 2) / configs.segLength.x;
    const xResolution = (options.xAxis.data.at(-1)! - options.xAxis.data[0]) / (canvas.width - 2 * configs.padding.x);
    ctx.font = "12px Georgia";
    ctx.beginPath();
    const rangeX = options.xAxis.data.at(-1)! - options.xAxis.data[0];
    // draw scale value on x-axios.
    for (let i = 0; i < configs.segLength.x; i++) {
        const value = options.xAxis.data[0] + Math.floor(i * rangeX / configs.segLength.x);
        ctx.fillText(value + '', origin[0] + i * xUnit, origin[1] + 20);
    }
    // draw scale value on y-axios
    for (let i = 0; i < configs.segLength.y; i++) {
        const value = Math.floor((i + 1) * max / configs.segLength.y) + '';
        ctx.fillText(value, origin[0] - value.length * 5 - 10, origin[1] - (i + 1) * yUnit);
    }

    for (let index = 0; index < options.series.length; index++) {
        ctx.beginPath();
        const [originalPoints, destPoints] = getDestPoints(options.xAxis, options.series, index);
        // draw data line
        ctx.strokeStyle = configs.lineColors[index % configs.lineColors.length];
        ctx.lineWidth = 2;
        for (let i = 1; i < destPoints.length; i++) {
            ctx.moveTo(origin[0] + (destPoints[i - 1][0] - options.xAxis.data[0]) / xResolution, origin[1] -  destPoints[i - 1][1] / yResolution);
            ctx.lineTo(origin[0] + (destPoints[i][0]  - options.xAxis.data[0]) / xResolution, origin[1] - destPoints[i][1] / yResolution);
        }

        // draw data point
        ctx.fillStyle = configs.lineColors[index % configs.lineColors.length];
        for (let i = 0; i < originalPoints.length; i++) {
            const psotion = [origin[0] + (originalPoints[i][0] - options.xAxis.data[0]) / xResolution, origin[1] -  originalPoints[i][1] / yResolution]
            ctx.moveTo(psotion[0], psotion[1]);
            ctx.arc(psotion[0], psotion[1], 4, 0, 2 * Math.PI);
            ctx.fill();
        }
        ctx.stroke();        
    }
}

在业务侧直接调用cchart函数并传入数据,其格式类似于highcharts。

cchart(container, { 
    title: { text: 'Employment Growth' },
    yAxis: { 
        title: { text: 'Quantity' }
     },
     xAxis: {
        type: 'category',
        data: [2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019, 2020],
     },
     series: [
        { 
            data: [43934, 48656, 65165, 81827, 112143, 142383, 171533, 165174, 155157, 161454],
            type: 'smooth'
        }
     ]
})

实现的最终效果如下,可同时支持折线、曲线的渲染。

屏幕快照 2023-03-19 下午12.54.35.png

绘制平行曲线

如何平行

在计算机图形学和设计中,平行曲线通常被用于创建固定距离的形状,例如,可以用平行曲线来创建形状的轮廓线,或者生成线条和曲线的加粗笔画效果, 也用于地图绘制平行的道路车道线。平行曲线会基于现有曲线按固定的offset偏移,其API形式如offset(curve, distance),distance的正、负表示在当前曲线的左侧或右侧平行。

基于已知的cubic bezier curve,由多段segment组成,每段segment包含两个端点p0、p3以及两个控制点p1、p2,可以根据每段segment求出对应平行线的四个点。计算逻辑如下图所示,黑色为原始曲线,p0和p3位置可得到法线n0、n3,通过p0和n0以及distance,可得到P'0,通过p3和n3以及distance,可达到P'3。接下来需要计算剩余的两个控制带点。

P0、P'0所在直线L0,P3、P'3所在直线L3,两条直线相较于点O。L1为点O和点P1组成的线段,L2为点O和P2组成的线段,另外T'1、T'2分别和T1、T'2的斜率保持一致,因此可计算出线段T'1、T'2。最终通过线段L1和T'1得到交点P'1即为平行线的控制点,同理可得到控制点P'2。最后由P'0、P'1、P'2、P'3四个点组成平行曲线段segment'。

屏幕快照 2023-03-20 下午11.43.38.png

需要特别说明的是,由于要计算交点O,如果L0和L3平行,无法计算交点,则此算法无法支持。

绘制实现

根据以上的计算逻辑,先定义曲线平行的API:offset(segments: Segment[], distance: number): Segment[], 参数segments为当前曲线的片段集合,每个片段由四个点组成,distance为平行偏移量,计算得到平行后的曲线片段集合,其格式也为Segment数组。

type Segment = {
    p0: [number, number],
    p1: [number, number],
    p2: [number, number],
    p3: [number, number],
};

export function offset(segments: Segment[], distance: number): Segment[] {
    return segments.map((s) => {
        return scale(s, distance);
    })
}

offset函数遍历segments集合,每个片段分别调用scale函数计算平行后的新片段。函数offsetAt(segment: Segment, t: number, distance: number)用于计算位置t处的法线上偏移距离为distance的点,v存储t为0和1位置沿法线偏移10的点集合,其目的是得到线段L0、L3,然后使用lli4函数计算其交点O。 接着根据0、1两个端点和法线n计算出新的端点存储在np[0]、np[3]。剩余两个控制点的计算,对应最后的forEach逻辑,p为新的端点,d为端点p处的斜率,而p2即为从p点沿着斜率方向的一个点,对应了线段T'1,再根据交点O和原控制点P1得到线段L1,有了两条线段T'1和L1,根据线段求交点函数lli4,可计算出新控制点P'1,同理得到新控制点P'2。 最终,我们得到了原片段segment平行后的新片段segment‘, 其端点和控制点P'0、P'1、P'2、P'3。

export function scale(segment: Segment, distance: number): Segment {
     const r1 = distance;
     const r2 = distance;
     const order = 3;
     const points = [segment.p0, segment.p1, segment.p2, segment.p3];

    // v为起、终两个位置的法线距离为10的两个点
    const v = [offsetAt(segment, 0, 10), offsetAt(segment, 1, 10)];
     // np为计算结果
    const np: [number, number][] = [];
    // 0为两条线的交点
    const o = lli4([v[0].x, v[0].y], v[0].c, [v[1].x, v[1].y], v[1].c);
    if (!o) {
        throw new Error("cannot scale this curve. Try reducing it first.");
    }
    [0, 1].forEach(function (t) {
        // order代表几次曲线,2次、3次曲线。
        const p = (np[t * order] = [...points[t * order]]);
        // 计算p点的位置
        p[0] += (t ? r2 : r1) * v[t].n[0];
        p[1] += (t ? r2 : r1) * v[t].n[1];
    });

    [0, 1].forEach((t) => {
        // p为端点位置
        const p = np[t * order];
        // t位置一阶导值,斜率,端点位置的斜率
        const d = derivative(segment, t);
        // 感觉p2为一个在切线上的点
        const p2: [number, number] = [ p[0] + d.x, p[1] + d.y] // { x: p[0] + d.x, y: p[1] + d.y };
        // p为端点、p2为切线上的点、points[t + 1]为相邻的下一个端点
        const desto =  <{x: number, y: number}>lli4(p, p2, [o.x, o.y], points[t + 1]);
        np[t + 1] = [desto.x, desto.y];
    });

    return { p0: np[0], p1: np[1], p2: np[2], p3: np[3] };
}

计算过程涉及到平行点函数offsetAt、法线函数normal、导数函数derivative,都包含在源代码中,可直接到github查看,实现的效果包含在examples/src/Offset.js文件中。

屏幕快照 2023-03-20 上午9.40.46.png

参考资料

1、 Drawing a circle with BézierCurves
2、A Primer on Bézier Curves
3、Smooth Bézier Spline Through Prescribed Points
4、三对角矩阵(Tridiagonal Matrices)的求法:Thomas Algorithm(TDMA)

高级应用场景

总的来说,贝塞尔曲线在各种领域都有广泛的应用,它的特点是可以用少量的控制点来描述复杂的曲线形状,同时可以通过对控制点的调整来改变曲线的形态,具有很高的灵活性和可塑性。

  1. 平面设计:贝塞尔曲线被广泛应用于平面设计,如Adobe Illustrator、Photoshop等软件中用于创建复杂的图形、图标和动画。
  2. 工业设计:贝塞尔曲线在工业设计中也有广泛应用,在汽车和飞机设计中,可以用来绘制车身、机翼和尾翼等部件的复杂曲线。
  3. 动画制作:用于创建复杂的动画效果,例如人物角色的运动、表情和形态变化。动画制作软件如Adobe After Effects、Flash等都广泛使用了贝塞尔曲线来实现动画效果的制作。
  4. 3D建模:贝塞尔曲线可以用于3D建模软件中的曲面建模,例如Maya、3D Studio Max等软件。通过贝塞尔曲线的变形,可以实现各种复杂的3D曲面,如建筑物表面等。
  5. 游戏开发:贝塞尔曲线可以用于游戏中的粒子效果、物理运动轨迹、动态路径规划等。在游戏制作中,通过贝塞尔曲线的应用,可以实现更加真实和生动的游戏效果。

如果大家有疑问可直接留言,一起探讨!既然看到尾了,点个关注呗。
本文正在参加「金石计划」