Gradient Stroke Along Curve in Canvas
Asked Answered
P

2

16

I'm trying to draw a curve in canvas with a linear gradient stoke style along the curve, as in this image. On that page there is a linked svg file that gives instructions on how to accomplish the effect in svg. Maybe a similar method would be possible in canvas?

Peavey answered 4/6, 2014 at 0:22 Comment(0)
C
36

A Demo: http://jsfiddle.net/m1erickson/4fX5D/

It's fairly easy to create a gradient that changes along the path:

enter image description here

It's more difficult to create a gradient that changes across the path:

enter image description here

To create a gradient across the path you draw many gradient lines tangent to the path:

enter image description here

If you draw enough tangent lines then the eye sees the curve as a gradient across the path.

enter image description here

Note: Jaggies can occur on the outsides of the path-gradient. That's because the gradient is really made up of hundreds of tangent lines. But you can smooth out the jaggies by drawing a line on either side of the gradient using the appropriate colors (here the anti-jaggy lines are red on the top side and purple on the bottom side).

Here are the steps to creating a gradient across the path:

  • Plot hundreds of points along the path.

  • Calculate the angle of the path at those points.

  • At each point, create a linear gradient and draw a gradient stroked line across the tangent of that point. Yes, you will have to create a new gradient for each point because the linear gradient must match the angle of the line tangent to that point.

  • To reduce the jaggy effect caused by drawing many individual lines, you can draw a smooth path along the top and bottom side of the gradient path to overwrite the jaggies.

Here is annotated code:

<!doctype html>
<html>
<head>
<link rel="stylesheet" type="text/css" media="all" href="css/reset.css" /> <!-- reset css -->
<script type="text/javascript" src="http://code.jquery.com/jquery.min.js"></script>
<style>
    body{ background-color: ivory; }
    #canvas{border:1px solid red;}
</style>       
<script>
$(function(){

    // canvas related variables
    var canvas=document.getElementById("canvas");
    var ctx=canvas.getContext("2d");

    // variables defining a cubic bezier curve
    var PI2=Math.PI*2;
    var s={x:20,y:30};
    var c1={x:200,y:40};
    var c2={x:40,y:200};
    var e={x:270,y:220};

    // an array of points plotted along the bezier curve
    var points=[];

    // we use PI often so put it in a variable
    var PI=Math.PI;

    // plot 400 points along the curve
    // and also calculate the angle of the curve at that point
    for(var t=0;t<=100;t+=0.25){

        var T=t/100;

        // plot a point on the curve
        var pos=getCubicBezierXYatT(s,c1,c2,e,T);

        // calculate the tangent angle of the curve at that point
        var tx = bezierTangent(s.x,c1.x,c2.x,e.x,T);
        var ty = bezierTangent(s.y,c1.y,c2.y,e.y,T);
        var a = Math.atan2(ty, tx)-PI/2;

        // save the x/y position of the point and the tangent angle
        // in the points array
        points.push({
            x:pos.x,
            y:pos.y,
            angle:a
        });

    }


    // Note: increase the lineWidth if 
    // the gradient has noticable gaps 
    ctx.lineWidth=2;

    // draw a gradient-stroked line tangent to each point on the curve
    for(var i=0;i<points.length;i++){

        // calc the topside and bottomside points of the tangent line
        var offX1=points[i].x+20*Math.cos(points[i].angle);
        var offY1=points[i].y+20*Math.sin(points[i].angle);
        var offX2=points[i].x+20*Math.cos(points[i].angle-PI);
        var offY2=points[i].y+20*Math.sin(points[i].angle-PI);

        // create a gradient stretching between 
        // the calculated top & bottom points
        var gradient=ctx.createLinearGradient(offX1,offY1,offX2,offY2);
        gradient.addColorStop(0.00, 'red'); 
        gradient.addColorStop(1/6, 'orange'); 
        gradient.addColorStop(2/6, 'yellow'); 
        gradient.addColorStop(3/6, 'green') 
        gradient.addColorStop(4/6, 'aqua'); 
        gradient.addColorStop(5/6, 'blue'); 
        gradient.addColorStop(1.00, 'purple'); 

        // draw the gradient-stroked line at this point
        ctx.strokeStyle=gradient;
        ctx.beginPath();
        ctx.moveTo(offX1,offY1);
        ctx.lineTo(offX2,offY2);
        ctx.stroke();
    }


    // draw a top stroke to cover jaggies
    // on the top of the gradient curve
    var offX1=points[0].x+20*Math.cos(points[0].angle);
    var offY1=points[0].y+20*Math.sin(points[0].angle);
    ctx.strokeStyle="red";
    // Note: increase the lineWidth if this outside of the
    //       gradient still has jaggies
    ctx.lineWidth=1.5;
    ctx.beginPath();
    ctx.moveTo(offX1,offY1);
    for(var i=1;i<points.length;i++){
        var offX1=points[i].x+20*Math.cos(points[i].angle);
        var offY1=points[i].y+20*Math.sin(points[i].angle);
        ctx.lineTo(offX1,offY1);
    }
    ctx.stroke();


    // draw a bottom stroke to cover jaggies
    // on the bottom of the gradient
    var offX2=points[0].x+20*Math.cos(points[0].angle+PI);
    var offY2=points[0].y+20*Math.sin(points[0].angle+PI);
    ctx.strokeStyle="purple";
    // Note: increase the lineWidth if this outside of the
    //       gradient still has jaggies
    ctx.lineWidth=1.5;
    ctx.beginPath();
    ctx.moveTo(offX2,offY2);
    for(var i=0;i<points.length;i++){
        var offX2=points[i].x+20*Math.cos(points[i].angle+PI);
        var offY2=points[i].y+20*Math.sin(points[i].angle+PI);
        ctx.lineTo(offX2,offY2);
    }
    ctx.stroke();


    //////////////////////////////////////////
    // helper functions
    //////////////////////////////////////////

    // calculate one XY point along Cubic Bezier at interval T
    // (where T==0.00 at the start of the curve and T==1.00 at the end)
    function getCubicBezierXYatT(startPt,controlPt1,controlPt2,endPt,T){
        var x=CubicN(T,startPt.x,controlPt1.x,controlPt2.x,endPt.x);
        var y=CubicN(T,startPt.y,controlPt1.y,controlPt2.y,endPt.y);
        return({x:x,y:y});
    }

    // cubic helper formula at T distance
    function CubicN(T, a,b,c,d) {
        var t2 = T * T;
        var t3 = t2 * T;
        return a + (-a * 3 + T * (3 * a - a * T)) * T
        + (3 * b + T * (-6 * b + b * 3 * T)) * T
        + (c * 3 - c * 3 * T) * t2
        + d * t3;
    }

    // calculate the tangent angle at interval T on the curve
    function bezierTangent(a, b, c, d, t) {
        return (3 * t * t * (-a + 3 * b - 3 * c + d) + 6 * t * (a - 2 * b + c) + 3 * (-a + b));
    };

}); // end $(function(){});
</script>
</head>
<body>
    <canvas id="canvas" width=300 height=300></canvas>
</body>
</html>
Calcareous answered 4/6, 2014 at 5:25 Comment(4)
how about creating many "parallel" curves of different color with 1px width, instead?Prediction
@ErikAllik Creating an offset curve to a cubic Bezier curve is more difficult. It would involve dividing the cubic curve into quadratic curves.Calcareous
@Calcareous this is a great solution, but sure complicated to change anything :) may i ask you what would the code look like if you only have two points instead of a (bezier-)curve? How would i calculate these tangents along those two points? Thanks in advance!Stenotypy
@Calcareous could you also explain how you create the gradient along the path (topmost example)?Mottled
R
2

I am working on doing something very similar, and I just wanted to add a couple things. markE's answer is great, but what he calls tangent lines to the curve, are actually lines normal or perpendicular to the curve. (Tangent lines are parallel, normal lines are perpendicular)

For my particular application, I am using a gradient across a line with transparency. In this case, it is important to get near pixel perfect gradient regions, as overlapping transparency will get drawn twice, changing the desired color. So instead of drawing a bunch of lines perpendicular to the curve, I divided the curve up into quadrilaterals and applied a linear gradient to each. Additionally, using these quadrilateral regions reduces the number of calls to draw you have to make, which can make it more efficient. You don't need a ton of regions to get a pretty smooth effect, and the fewer regions you use, the faster it will be able to render.

I adapted markE's code, so credit to him for that great answer. Here is the fiddle: https://jsfiddle.net/hvyt58dz/

Here is the adapted code I used:

// canvas related variables
var canvas = document.getElementById("canvas");
var ctx = canvas.getContext("2d");

// variables defining a cubic bezier curve
var PI2 = Math.PI * 2;
var s = {
    x: 20,
    y: 30
};
var c1 = {
    x: 200,
    y: 40
};
var c2 = {
    x: 40,
    y: 200
};
var e = {
    x: 270,
    y: 220
};

// an array of points plotted along the bezier curve
var points = [];

// we use PI often so put it in a variable
var PI = Math.PI;

// plot 400 points along the curve
// and also calculate the angle of the curve at that point
var step_size = 100/18;
for (var t = 0; t <= 100 + 0.1; t += step_size) {

    var T = t / 100;


    // plot a point on the curve
    var pos = getCubicBezierXYatT(s, c1, c2, e, T);

    // calculate the tangent angle of the curve at that point
    var tx = bezierTangent(s.x, c1.x, c2.x, e.x, T);
    var ty = bezierTangent(s.y, c1.y, c2.y, e.y, T);
    var a = Math.atan2(ty, tx) - PI / 2;

    // save the x/y position of the point and the tangent angle
    // in the points array
    points.push({
        x: pos.x,
        y: pos.y,
        angle: a
    });

}


// Note: increase the lineWidth if 
// the gradient has noticable gaps 
ctx.lineWidth = 2;
var overlap = 0.2;
var outside_color = 'rgba(255,0,0,0.0)';
var inside_color = 'rgba(255,0,0,0.7)';

// draw a gradient-stroked line tangent to each point on the curve
var line_width = 40;
var half_width = line_width/2;
for (var i = 0; i < points.length - 1; i++) {

    var x1 = points[i].x, y1 = points[i].y;
    var x2 = points[i+1].x, y2 = points[i+1].y;
    var angle1 = points[i].angle, angle2 = points[i+1].angle;
    var midangle = (angle1 + angle2)/ 2;
    // calc the topside and bottomside points of the tangent line
    var gradientOffsetX1 = x1 + half_width * Math.cos(midangle);
    var gradientOffsetY1 = y1 + half_width * Math.sin(midangle);
    var gradientOffsetX2 = x1 + half_width * Math.cos(midangle - PI);
    var gradientOffsetY2 = y1 + half_width * Math.sin(midangle - PI); 
    var offX1 = x1 + half_width * Math.cos(angle1);
    var offY1 = y1 + half_width * Math.sin(angle1);
    var offX2 = x1 + half_width * Math.cos(angle1 - PI);
    var offY2 = y1 + half_width * Math.sin(angle1 - PI);

    var offX3 = x2 + half_width * Math.cos(angle2)
                   - overlap * Math.cos(angle2-PI/2);
    var offY3 = y2 + half_width * Math.sin(angle2)
                   - overlap * Math.sin(angle2-PI/2);
    var offX4 = x2 + half_width * Math.cos(angle2 - PI)
                   + overlap * Math.cos(angle2-3*PI/2);
    var offY4 = y2 + half_width * Math.sin(angle2 - PI)
                   + overlap * Math.sin(angle2-3*PI/2);

    // create a gradient stretching between 
    // the calculated top & bottom points
    var gradient = ctx.createLinearGradient(gradientOffsetX1, gradientOffsetY1, gradientOffsetX2, gradientOffsetY2);
    gradient.addColorStop(0.0, outside_color);
    gradient.addColorStop(0.25, inside_color);
    gradient.addColorStop(0.75, inside_color);
    gradient.addColorStop(1.0, outside_color);
    //gradient.addColorStop(1 / 6, 'orange');
    //gradient.addColorStop(2 / 6, 'yellow');
    //gradient.addColorStop(3 / 6, 'green')
    //gradient.addColorStop(4 / 6, 'aqua');
    //gradient.addColorStop(5 / 6, 'blue');
    //gradient.addColorStop(1.00, 'purple');

    // line cap
    if(i == 0){
        var x = x1 - overlap * Math.cos(angle1-PI/2);
        var y = y1 - overlap * Math.sin(angle1-PI/2);
        var cap_gradient = ctx.createRadialGradient(x, y, 0, x, y, half_width);
        ctx.beginPath();
        ctx.arc(x, y, half_width, angle1 - PI, angle1);
        cap_gradient.addColorStop(0.5, inside_color);
        cap_gradient.addColorStop(1.0, outside_color);
        ctx.fillStyle = cap_gradient;
        ctx.fill();
    }
    if(i == points.length - 2){
        var x = x2 + overlap * Math.cos(angle2-PI/2);
        var y = y2 + overlap * Math.sin(angle2-PI/2);
        var cap_gradient = ctx.createRadialGradient(x, y, 0, x, y, half_width);
        ctx.beginPath();
        ctx.arc(x, y, half_width, angle2, angle2 + PI);
        cap_gradient.addColorStop(0.5, inside_color);
        cap_gradient.addColorStop(1.0, outside_color);
        ctx.fillStyle = cap_gradient;
        ctx.fill();
        console.log(x,y);
    }
    // draw the gradient-stroked line at this point
    ctx.fillStyle = gradient;
    ctx.beginPath();
    ctx.moveTo(offX1, offY1);
    ctx.lineTo(offX2, offY2);
    ctx.lineTo(offX4, offY4);
    ctx.lineTo(offX3, offY3);
    ctx.fill();
}

//////////////////////////////////////////
// helper functions
//////////////////////////////////////////

// calculate one XY point along Cubic Bezier at interval T
// (where T==0.00 at the start of the curve and T==1.00 at the end)
function getCubicBezierXYatT(startPt, controlPt1, controlPt2, endPt, T) {
    var x = CubicN(T, startPt.x, controlPt1.x, controlPt2.x, endPt.x);
    var y = CubicN(T, startPt.y, controlPt1.y, controlPt2.y, endPt.y);
    return ({
        x: x,
        y: y
    });
}

// cubic helper formula at T distance
function CubicN(T, a, b, c, d) {
    var t2 = T * T;
    var t3 = t2 * T;
    return a + (-a * 3 + T * (3 * a - a * T)) * T + (3 * b + T * (-6 * b + b * 3 * T)) * T + (c * 3 - c * 3 * T) * t2 + d * t3;
}

// calculate the tangent angle at interval T on the curve
function bezierTangent(a, b, c, d, t) {
    return (3 * t * t * (-a + 3 * b - 3 * c + d) + 6 * t * (a - 2 * b + c) + 3 * (-a + b));
};
Rufus answered 14/12, 2018 at 19:17 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.