最近刷短视频的时候,看到一个视频比较有意思,视频的名称就叫椭圆的命中注定,视频内容就不贴了,下面是本文实现示例
上面所描述的运动是合乎光学逻辑的,即从椭圆的一个焦点出发的光线,经椭圆反射后,光线将经过另外一个焦点
看了视频之后,我立马想到,既然这个过程是有规律的,那么肯定就可以通过编程来实现,所以就动手给完成了
绘制椭圆和焦点
想要通过前端代码手段来实现运动过程,第一时间想到的就是 canvas
绘图,分析视频可知,这个视频中的呈现元素只有三种:椭圆、焦点圆、线条,其中椭圆和左右两个焦点圆是静态的,线条是不断运动的
canvas
提供了绘制椭圆和圆的 api
,直接使用即可
// 绘制椭圆
ctx.ellipse(x0, y0, longRaius, shortRadius, 0, 0, 2 * Math.PI)
// 绘制左焦点
ctx.arc(leftX, y0, 5, 0, 2 * Math.PI)
// 绘制右焦点
ctx.arc(rightX, y0, 5, 0, 2 * Math.PI)
(x0, y0)
是椭圆的中心点坐标,longRaius
是椭圆的长轴半径,shortRadius
是椭圆的短轴半径,这些是自己设定的
椭圆画完之后,还需要把椭圆的左焦点和右焦点画出来,这两个焦点的 y
轴坐标和椭圆的 y
轴坐标一致,都是 y0
,焦点的 x
轴坐标需要根据 x0
和椭圆的半焦距(halfFocal
)计算得出
椭圆的半焦距计算公式:c² = a² - b²
其中 c
就是半焦距,a
是长轴半径 longRaius
,b
是短轴半径 shortRadius
,即:
halfFocal = Math.sqrt(Math.pow(longRaius, 2) - Math.pow(shortRadius, 2))
左焦点 x
轴坐标 leftX = x0 - halfFocal
,右焦点 x
轴坐标 rightX = x0 + halfFocal
从左焦点向外发射线条
设定从左焦点发出的线条,相隔两条线条之间的夹角是 10°
,去掉一条直接指向右焦点的,共 360 / 10 - 1 = 35
条线条
只要有线条两个端点坐标即可绘制出线条,线条的一个端点坐标就是左焦点坐标,需要计算的是另外一个端点坐标(x1, y1)
(称为末端点)
假设上图中的红色线条是从左焦点发出的线条在某一时刻的状态,蓝色虚线平行于y
轴,设定其长度为lenA
,黄色虚线平行于x
轴,设定其长度为lenB
,则红色线条末端点的坐标为 (leftX + lenB, y0 + lenA)
线条从左焦点向外发射的速度是有我们自己设定的,例如设定为每帧运动 1px
,所以在任意时刻线条的长度是可以计算得到的,也就是红色线条长度是确定的,设为 throughCount
设定红色线条与蓝色虚线的夹角是 θ
,则 lenA = sinθ * throughCount
,lenA = cosθ * throughCount
由于线条从左焦点向外发射的角度是我们自己设定的,我们肯定也知道线条是第几个线条,所以这个角度 θ
也是可以确定的
这里需要注意的是,canvas
坐标系内的角度,和我们这里的 θ
不是一回事,需要转换一下
知道了 θ
之后,那么 sinθ
、cosθ
的值调用 js
的 api
就可以计算得到了
则红色线条右侧端点的坐标为
(x1, y1) = (leftX - Math.abs(Math.sin(toRadians(θ))) * throughCount, y0 - Math.abs(Math.cos(toRadians(θ))) * throughCount)
从左焦点发出的35
个线条,末端点的坐标都可以通过上述方式计算,只不过需要根据线条的角度将这些线条分为4
类,每类线条需要基于上面的计算过程调整加减关系
if (angle <= 90) {
x1 = leftX - Math.abs(Math.sin(toRadians(angle))) * throughCount
y1 = y0 - Math.abs(Math.cos(toRadians(angle))) * throughCount
} else if (angle <= 180) {
const _angle = 180 - angle
x1 = leftX + Math.abs(Math.cos(toRadians(_angle))) * throughCount
y1 = y0 - Math.abs(Math.sin(toRadians(_angle))) * throughCount
} else if (angle <= 270) {
const _angle = 270 - angle
x1 = leftX + Math.abs(Math.cos(toRadians(_angle))) * throughCount
y1 = y0 + Math.abs(Math.sin(toRadians(_angle))) * throughCount
} else {
const _angle = 360 - angle
x1 = leftX - Math.abs(Math.sin(toRadians(_angle))) * throughCount
y1 = y0 + Math.abs(Math.cos(toRadians(_angle))) * throughCount
}
得到线条两个端点坐标后,可根据以下代码绘制线条
ctx.beginPath()
ctx.strokeStyle = '#e1ffff'
ctx.lineWidth = 2
ctx.moveTo(leftX, y0)
ctx.lineTo(x1, y1)
ctx.stroke()
通过不断调用 requestAnimationFrame
方法,在方法中累加 throughCount
的值,即可得到实现 35
个线条从左焦点向外发散的动画
线条碰撞椭圆边之后反射
线条从左焦点出发,触碰到椭圆边后会进行一次光学反射,经过了这次反射后线条方向才会直指右焦点
当线条触碰到椭圆边的时候,意味着线条的末端点是位于椭圆上的,那么只需要判断线条的末端点坐标是否在椭圆上即可
当椭圆焦点在x
轴时,椭圆的标准方程是:x^2/a^2+y^2/b^2=1
,即
// 在椭圆边上或椭圆外
const isOutEllipse = (x1: number, y1: number) => {
return Math.pow((x1 - x0), 2) / Math.pow(longRaius, 2) + Math.pow((y1 - y0), 2) / Math.pow(shortRadius, 2) >= 1
}
由于线条每个 requestAnimationFrame
内运动的长度是我们自己设定的,所以末端点的坐标可能无法精确地刚好位于椭圆上,因而需要允许一定的误差
当在某个 requestAnimationFrame
内计算得出末端坐标已经抵达椭圆边界了,那么记录此时的末端点坐标为 hitCoordinate = [x1, y1]
,并且记录此时的线条长度为 firstEndCount
反射之后,线条的第二段轨迹需要重新设定计算方法
第二段轨迹的初始端点上面已经记录了,又已知线条末端点最终肯定可以抵达右焦点,那么线条第二段轨迹的起终状态可以直接画出,即上图的第二段红色线条,以这段线条为斜边,蓝色虚线平行于y
轴,设定其长度为lenA
,黄色虚线平行于x
轴,设定其长度为lenB
,这三条线段构成一个直角三角形
由于已经确定了与椭圆边的碰撞点坐标和右焦点坐标,所以
lenA = y0 - y1
lenB = rightX - x1
直角三角形的其中两条边长度已经确定了,那么第三条边,即第二段红色线条的长度也就能确定了,设为 sendEndCount
设定红色线条与蓝色虚线的夹角为 θ
,那么
sinθ = lenB / sendEndCount
cosθ = lenA / sendEndCount
又因为线条在每个 requestAnimationFrame
的长度是我们自己控制的,是已知的,那么再根据已知的 sinθ
、cosθ
,即可得到每个 requestAnimationFrame
内线条末端点坐标
当计算得到线条的末端点坐标等于右焦点坐标,则停止动画,这里同样需要注意误差的存在
if (isSame(_x1, rightX) && isSame(_y1, y0) && !stopFlag) {
// 抵达右焦点,所有的线条全部进入终止态
stopFlag = true
// 绘制最后一次到终态
draw()
}
小结
可能是因为初中毕业太久了,涉及到的公式要花费些时间才能明白是什么意思,这个是整个实现过程中的难点(手动狗头)