提问 发文

用canvas绘制沿路径渐变的流光效果

赵炎飞

| 2023-07-30 18:36 603 1 1

实现效果:

        如上图所示,我们用canvas绘制了一条灰色的路径,并在路径上绘制了一条流动的线条,线条在运动过程中。始终保持渐变色,这种沿路径渐变的效果是一般的渐变(线性渐变、径向渐变)无法做到的,要实现这个效果,我们需要将流光切割成多段直线,每段直线都用不同的渐变色绘制,最终使其整体保持同一个渐变色。

实现思路:

1.首先我们需要准备以下几个“必须品”:

    a.路径的点位数据,这里只考虑直线路径,如果夹杂了曲线的话就不好计算了。

let pathData = [
    [100,100],
    [200,100],
    [250,150],
    [250,200],
    [230,250],
    [170,170],
    [140,230],
    [100,230]
];

    b.流光头部颜色和尾部颜色,这里为了简化计算,只考虑了两种颜色的渐变。

const headColor = {r:255,g:255,b:0,a:1}, tailColor={r:255,g:0,b:0,a:0.2};

    c.流光的长度和流光的偏移值offset,offset决定了在每一帧绘制时,流光所处的位置。

const lightLen = 100;
let offset = 0;

    d.最后就是你的html页面里必须有一个canvas元素,然后获取它的2d上下文对象即可

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");


2.初始化轨迹对象,由于底下的灰色轨迹在每一帧的绘制中都是不变的,所以我们可以先用一个Path2D对象将其存储起来。

const track = initPathObj();

function initPathObj(){
    let path = new Path2D();
    path.moveTo(pathData[0][0],pathData[0][1]);
    pathData.slice(1).forEach(d=>{
        path.lineTo(d[0],d[1]);
    });
    return path;
}


3.在开启帧动画之前,先根据路径数据(pathData)将整条路径切割成多条线段,计算出每段线的 offset 范围,线性函数,起点和终点坐标,与x轴的夹角。为什么要计算这些东西呢?首先我们要明白,决定流光的绘制位置时,是只有一个变量offset的,在不同的 offset 下,流光将被转折点切割成n段直线,那么我们要怎么计算出这n段直线的路径数据呢?以下图为例,流光所在的路径包含了三个转折点,它们的坐标分别为(100,100),(200,100),(250,150),假设此时的offset=150,我们要怎么得到流光的起点、转折点和终点的坐标呢?

        首先我们可以根据坐标值,计算出两段路径的线性函数分别为:y=100,y=x-100,它们对应的偏移值区间分别为[0,100],[100,100+50*根号2], 然后根据流光长度(lightLen),偏移值(offset),我们能得知整个流光的区间在 [50,150],很显然,终点位于区间[0,100]之间,而起点位于区间[100,100+50根号2]之间,根据点位所属的区间,我们可以轻易找到每个点所在线段的线性函数,然后根据偏移值计算出点位的 x,y 坐标......好吧,仅根据线性函数是无法得到 x,y 坐标的,因为我们的 offset 表示的是线段长度,想要将线段长度转换成 x,y 坐标,我们还需要借助于三角函数 sin 和 cos,线性函数提供给我们有用的信息就是它的 k 值,根据 atan 函数,我们可以将斜率转换成线段与x轴的弧度值 rad ,拿到 rad 后,根据 x = offset*Math.cos(rad) 和 y = offset*Math.sin(rad) 我们才能得到 x,y 坐标。不过这里需要注意的是,atan函数拿到的弧度值在 -π/2 和 π/2之间(对应的是第四象限和第一象限),并不是360度的,所以我们还需要根据 x 的向量(终点x坐标-起点x坐标=dx)来决定 rad是否需要增加 π。dx<0时 rad+π。

        生成路径每条线段信息的函数如下所示,该函数同时获取了整条路径的总长度(sum),便于后面的计算:

const { paths, sum } = formatPath(pathData);
function formatPath(data){
    let sum = 0;
    let res = [];
    data.slice(1).forEach((d, i)=>{
        const [x1, y1] = data[i], [x2, y2] = d;
        const dx = x2-x1, dy = y2-y1;      			
        const dis = Math.sqrt(dx**2+dy**2);     //两点之间的距离
        const range = [sum, sum+dis];
        const rad = dx==0?(dy>0?1:-1)*Math.PI/2:Math.atan(dy/dx)+(dx<0?Math.PI:0);
        res.push({
            dis,
            pos:[[x1,y1], [x2,y2]],      //线段的起始点坐标
            range,        //线段所含区间
            rad,  //线段与x轴的夹角
            linearFn:(offset)=>[x1 + (offset-range[0]) * Math.cos(rad), y1 + (offset-range[0]) * Math.sin(rad)]
        });
        sum+=dis;
    });
    return {paths:res,sum};
}


4.接下来开启帧动画,在每一帧中,我们需要根据 offset 的值来计算流光的各个点坐标以及渐变色。计算流光点坐标的方法其实在第三步中已经有所提及,下面我们来实现代码:

const lines = calcPoints(paths, [offset-lightLen, offset]);
function calcPoints(paths, steps){
    const [step1, step2] = steps;
    return paths.flatMap(d=>{
        const [small, large]=d.range;
        if(small>=step1 && large<=step2){     // __|__|__
            return [d.pos];
        }else if(small<=step1 && large>=step2){       // | ___ |
            return [[d.linearFn(step1),d.linearFn(step2)]];
        }else if(small<=step1&& large>=step1){       // | __|__
            return [[d.linearFn(step1),d.pos[1]]];
        }else if(small<=step2 && large>=step2){       // __|__ |
            return [[d.pos[0],d.linearFn(step2)]];
        }
        return [];
    });
}

        计算流光点坐标的函数有两个参数,第一个是整条路径的线段数据,第二个参数是个数组,需要传入的是流光的 offset 区间,函数实现其实很简单,我们遍历每条线段,根据线段区间和流光区间的相对关系,计算出每段流光在每段线段中的点坐标。(代码注释里的竖线表示线段区间,下划线表示流光)

        完成这一步,其实我们已经得到一条纯色的流光了,但是纯色的流光不好看,而且可以用dashOffset+dashArray来快速实现,没必要算这么多东西,所以下一步计算每段流光的渐变色才是最关键的。


5.对于整条流光而言,在转弯时,会被切割成多段流光,我们需要先计算出每段流光的起始点占整段流光的百分比(t1和t2),然后根据 t1、t2 对头部颜色和尾部颜色进行线性插值计算,算出每段流光的起始点颜色,这样在绘制完整条流光后,它的起始点颜色将始终是设定的头部颜色和尾部颜色, 且中间的转折点颜色也能正常过渡。下面是代码示例:

//根据分割后的线段计算每段线段需要的渐变色
const gradients = calcGradients(
    offset>sum?interPolaColor(headColor,tailColor,(offset-sum)/lightLen):headColor,
    offset<lightLen?interPolaColor(headColor,tailColor,offset/lightLen):tailColor,
    lines
)
//对两个颜色进行线性插值, t∈[0,1]
function interPolaColor(color1, color2, t){
    const {r:r1,g:g1,b:b1,a:a1=1} = color1;
    const {r:r2,g:g2,b:b2,a:a2=1} = color2;
    const r = r2-r1, g=g2-g1, b=b2-b1, a=a2-a1;
    return {
        r:Math.round(r1+t*r),
        g:Math.round(g1+t*g),
        b:Math.round(b1+t*b),
        a:+(a1+t*a).toFixed(2)
    }
}
//计算当前渐变数组
function calcGradients(color1, color2, lines){
    let sum = 0;
    return lines.map(d=>{
        const dx=d[0][0]-d[1][0], dy=d[0][1]-d[1][1];       
        const dis = Math.sqrt(dx**2+dy**2);
        const start = sum;
        sum+=dis;
        const startColor = interPolaColor(color2, color1, start/lightLen), endColor =  interPolaColor(color2, color1, sum/lightLen);
        return {
            start:d[0],
            end:d[1],
            gradient:[
                `rgba(${startColor.r},${startColor.g},${startColor.b},${startColor.a})`,
                `rgba(${endColor.r},${endColor.g},${endColor.b},${endColor.a})`
            ]
        }
    });
}

        上述代码中,计算渐变色的函数 calcGradients 需要3个参数,其中前两个是头部颜色和尾部颜色,第三个参数是流光的点位数据,该数据在第四步中已得到。我们需要做的就是遍历流光的点位数据,得到每段流光的长度,进行累加,然后将每段流光的起始点对应的长度值除以流光长度,就能得到 t 值,得到 t 值后就可以进行线性插值得到对应的颜色值。这里有个注意的地方就是在传参时,如果流光并没有完整的展现在路径中(当 offset < lightLen和 offset > sum 时),我们需要对头部颜色和尾部颜色先进行一次线性插值,因为此时我们只需要渐变色的一部分就好了,而不是完整的渐变色。

6.最后一步,我们使用requestAnimationFrame开启帧动画,在每一帧中修改 offset 的值,让流光动起来即可,完整代码如下:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        *{
            box-sizing: border-box;
        }
    </style>
</head>
<body>
    <canvas id="canvas" width="500" height="500"></canvas>
    <script>
        //路径数据,这里只考虑直线路径
        let pathData = [
            [100,100],
            [200,100],
            [250,150],
            [250,200],
            [230,250],
            [170,170],
            [140,230],
            [100,230]
        ];
        const canvas = document.getElementById("canvas");
        const ctx = canvas.getContext("2d");
        const lightLen = 100;    //流光长度
        let offset = 0;         //流光移动的距离,范围在[0, 路径长度+流光长度]
        const track = initPathObj();    //初始化流光的轨迹对象
        const headColor = {r:255,g:255,b:0,a:1}, tailColor={r:255,g:0,b:0,a:0.2};      //流光的尾部颜色和头部颜色
        const { paths, sum } = formatPath(pathData);        //paths是每一段线的信息,sum 是完整路径的长度

        const animation = ()=>{
            //将流光根据转折点切分成多段线条进行绘制
            const lines = calcPoints(paths, [offset-lightLen, offset]);
            //根据分割后的线段计算每段线段需要的渐变色
            const gradients = calcGradients(
                offset>sum?interPolaColor(headColor,tailColor,(offset-sum)/lightLen):headColor,
                offset<lightLen?interPolaColor(headColor,tailColor,offset/lightLen):tailColor,
                lines
            )
            ctx.clearRect(0,0,500,500);
            ctx.save();
            ctx.lineWidth=5;
            ctx.strokeStyle="rgba(128,128,128,0.2)";
            ctx.stroke(track);
            ctx.lineWidth=5;
            //分段绘制流光
            lines.forEach((d,i)=>{
                const {start, end, gradient:grad} = gradients[i];
                gradient = ctx.createLinearGradient(start[0],start[1],end[0],end[1]);
                gradient.addColorStop(0,grad[0]);
                gradient.addColorStop(1,grad[1]);
                ctx.strokeStyle = gradient;
                ctx.beginPath();
                ctx.moveTo(d[0][0],d[0][1]);
                ctx.lineTo(d[1][0],d[1][1]);
                ctx.stroke();
            })
            ctx.restore();
            offset = (offset+0.5)%(sum+lightLen);       //每一帧增加0.5的移动距离,在流光完全消失后将offset重置为0
            requestAnimationFrame(animation);
        }
        animation();
        //初始化流光的轨迹对象
        function initPathObj(){
            let path = new Path2D();
            path.moveTo(pathData[0][0],pathData[0][1]);
            pathData.slice(1).forEach(d=>{
                path.lineTo(d[0],d[1]);
            });
            return path;
        }
        //计算每段小路径的数值区间和线性函数
        function formatPath(data){
            let sum = 0;
            let res = [];
            data.slice(1).forEach((d, i)=>{
                const [x1, y1] = data[i], [x2, y2] = d;
                const dx = x2-x1, dy = y2-y1;      
                const dis = Math.sqrt(dx**2+dy**2);     //两点之间的距离
                const range = [sum, sum+dis];
                const rad = dx==0?(dy>0?1:-1)*Math.PI/2:Math.atan(dy/dx)+(dx<0?Math.PI:0);
                res.push({
                    dis,
                    pos:[[x1,y1], [x2,y2]],      //线段的起始点坐标
                    range,        //线段所含区间
                    rad,  //线段与x轴的夹角
                    linearFn:(offset)=>[x1 + (offset-range[0]) * Math.cos(rad), y1 + (offset-range[0]) * Math.sin(rad)]
                });
                sum+=dis;
            });
            return {paths:res,sum};
        }
        //计算当前流光点位数组
        function calcPoints(paths, steps){
            const [step1, step2] = steps;
            return paths.flatMap(d=>{
                const [small, large]=d.range;
                if(small>=step1 && large<=step2){     // __|__|__
                    return [d.pos];
                }else if(small<=step1 && large>=step2){       // | ___ |
                    return [[d.linearFn(step1),d.linearFn(step2)]];
                }else if(small<=step1&& large>=step1){       // | __|__
                    return [[d.linearFn(step1),d.pos[1]]];
                }else if(small<=step2 && large>=step2){       // __|__ |
                    return [[d.pos[0],d.linearFn(step2)]];
                }
                return [];
            });
        }
        //对两个颜色进行线性插值, t∈[0,1]
        function interPolaColor(color1, color2, t){
            const {r:r1,g:g1,b:b1,a:a1=1} = color1;
            const {r:r2,g:g2,b:b2,a:a2=1} = color2;
            const r = r2-r1, g=g2-g1, b=b2-b1, a=a2-a1;
            return {
                r:Math.round(r1+t*r),
                g:Math.round(g1+t*g),
                b:Math.round(b1+t*b),
                a:+(a1+t*a).toFixed(2)
            }
        }
        //计算当前渐变数组
        function calcGradients(color1, color2, lines){
            let sum = 0;
            return lines.map(d=>{
                const dx=d[0][0]-d[1][0], dy=d[0][1]-d[1][1];       
                const dis = Math.sqrt(dx**2+dy**2);
                const start = sum;
                sum+=dis;
                const startColor = interPolaColor(color2, color1, start/lightLen), endColor =  interPolaColor(color2, color1, sum/lightLen);
                return {
                    start:d[0],
                    end:d[1],
                    gradient:[
                        `rgba(${startColor.r},${startColor.g},${startColor.b},${startColor.a})`,
                        `rgba(${endColor.r},${endColor.g},${endColor.b},${endColor.a})`
                    ]
                }
            });
        }
    </script>
</body>
</html>

总结:

        绘制渐变流光其实并没有用到什么复杂的api,完全就是数学的计算和最简单的线性插值函数,感兴趣的同学还可以试着在路径中加入椭圆弧、贝塞尔曲线后,再来实现一下渐变流光,可能会增加代码的复杂度,但是其核心原理还是不变的,依旧是将路径分段,先求出流光的点位数据,再根据点位数据求出渐变色数据,最后分段绘制即可。

收藏 0
分享
分享方式
微信

评论

游客

全部 1条评论

158****7798 158****7798 2023-10-12 18:56
优秀
回复
19

文章

3.2K

人气

8

粉丝

1

关注

官方媒体

轻松设计高效搭建,减少3倍设计改稿与开发运维工作量

开始免费试用 预约演示

扫一扫关注公众号 扫一扫联系客服

©Copyrights 2016-2022 杭州易知微科技有限公司 浙ICP备2021017017号-3 浙公网安备33011002011932号

互联网信息服务业务 合字B2-20220090

400-8505-905 复制
免费试用
微信社区
易知微-数据可视化
微信扫一扫入群