曲线入门问题
本文正在参加「金石计划」 各种前端应用中会经常使用曲线,如绘制圆形、椭圆形等图形,使用CSS实现淡入淡出动画,渲染平滑的趋势报表图。借助canvas、echarts、highcharts、D3.js可以轻轻松松实现以上功能,但如果在不借助各种开源库情况下自己实现:
- 椭圆、圆的绘制
- 淡入淡出动画
- 平滑的报表趋势图
本篇内容主要借助这三个问题,了解Cubic Bezier Curve
是什么,如何使用三次贝塞尔曲线从0到1实现三个问题中的场景,实现的代码会push到github,可下载体验。
Cubic Bezier Curve应用
Cubic Bezier Curve是前端常被使用的曲线类型,也被应用到很多领域,如:
- 计算机图形:当需要绘制各种类型的形状,如圆、椭圆、螺旋形等等,可使用三次贝塞尔曲线绘制其线条和形状。
- 动画特效:用于动画设计,以创建流畅的过渡特效。如用于创建淡入淡出、缩放和滑动等流畅、平滑的效果。
- 设计工具:三次贝塞尔曲线适用于产品设计,当设计汽车、家具等模型时,可使用它创建平滑的模型形态。
- 排版:三次贝塞尔曲线用于Typography,以创建流畅的字体和字体样式。在字体设计中,三次贝塞尔曲线用于定义单个字形或字符的轮廓,以及字体的整体风格和外观。这些曲线用于定义构成字体的笔划、衬线和其他细节。也可用于文本布局,如一段文字沿着一条平滑的曲线排列。
Cubic Bezier Curve定义
什么是三次贝塞尔曲线,三次贝塞尔曲线是计算机图形学最常用的曲线类型,由四个points组成:两个端点(P0、P3)、两个控制点(P1、P2)。从端点P0开始,通过P1、P2控制曲率来添加平滑的途经点直到端点P3,如下图所示:
假如已知四个points,如何根据这四个点计算出途经点?这里需要使用到三次贝塞尔曲线方程,中间插值被归一化到范围为[0, 1]的t,当t=0,可知B(0) = P0
,但t=1,可以B(1) = P3
。随着P1、P2两个控制点的位置调整,绘制出的曲线也会有比较明显的区分。
曲线方程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四个点代替即可推导出三次曲线方程。
从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。
至于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的坐标。
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值,那么也可以轻松地计算出椭圆的点集合,根据曲线方程公式生成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();
}
}
淡入淡出动画
用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.css、React Spring、Lax.js。如何实现一个类似cubic-bezier
函数的淡入淡出动画?可以考虑借助前面实现的bezierFormula
、bezierCurve
函数来实现,首先定义动画的API:animate(el: HTMLElement, props: AnimationProps)
, props参数包含duration
、easing
、styles
三个属性,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实现,部分逻辑还未完全理解,以后有时间再慢慢啃。
曲线平滑,绘制报表
图表是前端会经常使用的功能,下图是highcharts实现的一个折线报表,某些场景会将折线处理为平滑的曲线,假如要从0到1实现一个支持平滑的曲线报表,该如何实现?
报表的输入是点集合,如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
, 它是一种基于高斯消元法的算法。
控制点推算
将三次贝塞尔曲线写为:
其一阶导为:
由于在中间节点(P0、P3)处平滑,其斜率相等,则有:
即:
其中 P0,i和P3,i-1表示同一个节点Ki,可得到等式1:
曲线的二阶导为:
二阶导在节点处也连续,则有:
其中P0,i和P3,i-1为同一节点Ki,得到等式2:
假设原始点一共有n个片段,方程1、方程2定义为中间每两个片段的方程,控制点一共有2n个,但方程只有2(n-1)个,还差两个方程才能求解。由于0和n-1两个边界处曲率不再变化,两个自然边界条件B''0(0)=0和B''n-1(0)=0。可得到方程3、方程4:
合并方程1、方程2,可得:
方程3、方程4可写为:
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
。
从第一行一步步消元,直到第六行,最终得到如下形式,其中v1,...,v5以及l1,...,l6为中间变量,可得到P6 = l6,从而可推算P5至P1的值。
当算出所有P1控制点,可结合方程1、方程2推算所有P2控制点:
代码实现
根据上面的介绍,需要对每个片段分别平滑,然后拼接起来。下面为平滑的代码实现,先调用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'
}
]
})
实现的最终效果如下,可同时支持折线、曲线的渲染。
绘制平行曲线
如何平行
在计算机图形学和设计中,平行曲线通常被用于创建固定距离的形状,例如,可以用平行曲线来创建形状的轮廓线,或者生成线条和曲线的加粗笔画效果, 也用于地图绘制平行的道路车道线。平行曲线会基于现有曲线按固定的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'。
需要特别说明的是,由于要计算交点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文件中。
参考资料
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)
高级应用场景
总的来说,贝塞尔曲线在各种领域都有广泛的应用,它的特点是可以用少量的控制点来描述复杂的曲线形状,同时可以通过对控制点的调整来改变曲线的形态,具有很高的灵活性和可塑性。
- 平面设计:贝塞尔曲线被广泛应用于平面设计,如Adobe Illustrator、Photoshop等软件中用于创建复杂的图形、图标和动画。
- 工业设计:贝塞尔曲线在工业设计中也有广泛应用,在汽车和飞机设计中,可以用来绘制车身、机翼和尾翼等部件的复杂曲线。
- 动画制作:用于创建复杂的动画效果,例如人物角色的运动、表情和形态变化。动画制作软件如Adobe After Effects、Flash等都广泛使用了贝塞尔曲线来实现动画效果的制作。
- 3D建模:贝塞尔曲线可以用于3D建模软件中的曲面建模,例如Maya、3D Studio Max等软件。通过贝塞尔曲线的变形,可以实现各种复杂的3D曲面,如建筑物表面等。
- 游戏开发:贝塞尔曲线可以用于游戏中的粒子效果、物理运动轨迹、动态路径规划等。在游戏制作中,通过贝塞尔曲线的应用,可以实现更加真实和生动的游戏效果。
如果大家有疑问可直接留言,一起探讨!既然看到尾了,点个关注呗。
本文正在参加「金石计划」