HTML5 Canvas: curve image along the path

2019-08-06 23:41发布

问题:

I am trying to curve image along the path.

Here what I got so long.

I did this by cutting image into parts, placing them on a certain point on the line, and rotating them by tangent angle of that point.

Everything is great, except if you look closely there are cracks between each image section, although each image begins exactly where previous ends.

Can anybody help to get rid of those cracks.

Here is jsBin.

回答1:

Bezier 2nd & 3rd order ScanLine rendering

Drawing an image with opacity in sections will not work as there will always be some pixels overlapping. The result will be seams.

Quality and quick, webGL

The easiest approch is to use WebGL and render the curve as a set of polygons. It is quick, and can be rendered offscreen.

Scanline rendering

First I must point out this is very SLOW and not for animation.

The alternative is to create a scan line render that scans the pixels one row at a time. For each pixel you find the closest point on the curve as the bezier position 0-1 and the distance from the curve. This gives you the x and y mapping coordinate of the image. You also need to find which side of the curve you are. This can be found by computing the tangent at the point on the curve and using the cross product of the tangent and pixel to find which side of the line you are.

This method will work for most curves but breaks down when the curve is self intersecting or the width of the source image causes pixels to overlap. As scan line rendering ensures no pixels will be written twice the only artifacts generated will be seams along lines where the distance to the curve abruptly changes.

The advantage of scan line rendering is that you can create very high quality rendering (trading off time) using super sampling.

Workers

Scanline rendering is ideal for parallel processing techniques. Using workers to do parts of the scan will give a nearly linear performance boost. On some browsers you can find the number of available processing cores with window.clientInformation.hardwareConcurrency creating any more workers than this value will not give you improvement but will start to reduce performance. If you can not find the number of cores it is best to keep an eye on performance and not spawn any more workers if throughput is not increasing.

Demo

The following is the most basic scan line render of a curve without any super sampling. The function at the heart of the method getPosNearBezier finds the position via brute force. It samples all the points along the curve to find the closest. As is this method is VERY slow, but there is plenty of room for optimisation and you should be able to double or triple the performance with some extra smarts.

// creates a blank image with 2d context
var createImage=function(w,h){var i=document.createElement("canvas");i.width=w;i.height=h;i.ctx=i.getContext("2d");return i;}

// setup canvas
var canvas = createImage(400,400);
var ctx = canvas.ctx;
document.body.appendChild(canvas);
ctx.clearRect(0,0,canvas.width,canvas.height);
   document.body.style.background = "#999";

const quality = 500; // this value should be greater than the approx length
                     // of the bezier curve in pixels.
// create source image with gradient alpha 0 to 1 to 0
var sWidth = 300;
var sHeight = 100;
var checkerSize = 20;
var darkG = ctx.createLinearGradient(0,0,0,sHeight);
var lightG = ctx.createLinearGradient(0,0,0,sHeight);
for(var i = 0; i <= 1; i += 1/20){
    darkG.addColorStop(i,"rgba(0,0,0,"+Math.pow(Math.sin(i * Math.PI),2)+")");
    lightG.addColorStop(i,"rgba(255,255,255,"+Math.pow(Math.sin(i * Math.PI),2)+")");
}
// draw checker pattern on source image
var testImage = createImage(sWidth,sHeight);
for(var i = 0; i < sHeight; i += checkerSize){
    for(var j = 0; j < sWidth; j += checkerSize){
        if(((i/checkerSize+j/checkerSize) % 2) === 0){
            testImage.ctx.fillStyle = darkG;
        }else{
            testImage.ctx.fillStyle = lightG;
        }
        testImage.ctx.fillRect(j,i,checkerSize,checkerSize);
    }
}
        
// ctx.drawImage(testImage,0,0);
// get source image as 32bit pixels (note Endian of this word does not effect the result) 
var sourcePixels = new Uint32Array(testImage.ctx.getImageData(0,0,testImage.width,testImage.height).data.buffer);
var pixelData;


// variables for bezier functions.
// keep these outside the function as creating them inside will have a performance/GC hit
var x = 0;
var y = 0;
var v1 = {x,y};
var v2 = {x,y};
var v3 = {x,y};
var v4 = {x,y};
var tng = {x,y};
var p = {x,y};
var curvePos = {x,y};
var c1,u1,u,b1,a,b,c,d,e,vx,vy;
var bez = {};
bez.p1 = {x : 40, y : 40};  // start
bez.p2 = {x : 360, y : 360}; // end
bez.cp1 = {x : 360, y : 40}; // first control point
bez.cp2 = {x : 40, y : 360}; // second control point if undefined then this is a quadratic

// This is a search and is thus very very slow.
// get the unit pos on the bezier that is closest to the point point
// resolution is the search steps (default 100)
// pos is a estimate of the pos, if given then a higher resolution search is done around this pos
function getPosNearBezier(point,resolution,pos){  
    // translate curve to make vec the origin 
    v1.x = bez.p1.x - point.x;
    v1.y = bez.p1.y - point.y;
    v2.x = bez.p2.x - point.x;
    v2.y = bez.p2.y - point.y;
    v3.x = bez.cp1.x - point.x;
    v3.y = bez.cp1.y - point.y; 
    if(bez.cp2 !== undefined){
        v4.x = bez.cp2.x - point.x;
        v4.y = bez.cp2.y - point.y;        
    }
    if(resolution === undefined){
        resolution = 100;
    }
    c1 = 1/resolution;
    u1 = 1 + c1/2;
    var s = 0;
    if(pos !== undefined){
        s = pos - c1 * 2;
        u1 = pos + c1 * 2;
        c1 = (c1 * 4) / resolution;
    }
    d = Infinity;
    if(bez.cp2 === undefined){
        for(var i = s; i <= u1; i += c1){
            a = (1 - i); 
            c = i * i; 
            b = a*2*i;
            a *= a;  
            vx = v1.x * a + v3.x * b + v2.x * c;
            vy = v1.y * a + v3.y * b + v2.y * c;
            e = Math.sqrt(vx*vx+vy*vy);
            if(e < d ){
                pos = i;
                d = e;
                curvePos.x = vx;
                curvePos.y = vy;
            }
        }
    }else{
        for(var i = s; i <= u1; i += c1){
            a = (1 - i); 
            c = i * i; 
            b = 3 * a * a * i; 
            b1 = 3 * c * a; 
            a = a*a*a;
            c *= i; 
            vx = v1.x * a + v3.x * b + v4.x * b1 + v2.x * c;
            vy = v1.y * a + v3.y * b + v4.y * b1 + v2.y * c;
            e = Math.sqrt(vx*vx+vy*vy);
            if(e < d ){
                pos = i;
                d = e;
                curvePos.x = vx + point.x;
                curvePos.y = vy + point.y;
            }
        }
    }
    return pos;
};

function tangentAt( position) {  // returns the normalised tangent at position
    if(bez.cp2 === undefined){
        a = (1-position) * 2;
        b = position * 2;
        tng.x = a * (bez.cp1.x - bez.p1.x) + b * (bez.p2.x - bez.cp1.x);
        tng.y = a * (bez.cp1.y - bez.p1.y) + b * (bez.p2.y - bez.cp1.y);
    }else{
        a  = (1-position)
        b  = 6 * a * position;        // (6*(1-t)*t)
        a *= 3 * a;                  // 3 * ( 1 - t) ^ 2
        c  = 3 * position * position; // 3 * t ^ 2
        tng.x  = -bez.p1.x * a + bez.cp1.x * (a - b) + bez.cp2.x * (b - c) + bez.p2.x * c;
        tng.y  = -bez.p1.y * a + bez.cp1.y * (a - b) + bez.cp2.y * (b - c) + bez.p2.y * c;
    }   
    u = Math.sqrt(tng.x * tng.x + tng.y * tng.y);
    tng.x /= u;
    tng.y /= u;
    return tng;                 
}

function getRow(y){
    pixelData = ctx.getImageData(0,y,canvas.width,1)
    return new Uint32Array(pixelData.data.buffer);
}
function setRow(y,data){        
    return ctx.putImageData(pixelData,0,y);
}

// scans a single line
function scanLine(y){
    var pixels = getRow(y);
    for(var x = 0; x < canvas.width; x += 1){
        p.x = x;
        p.y = y;
        var bp = getPosNearBezier(p,quality);
        if(bp >= 0 && bp <= 1){ // is along curve
            tng = tangentAt(bp); // get tangent so that we can find what side of the curve we are
            vx = curvePos.x - x;
            vy = curvePos.y - y;
            var dist = Math.sqrt(vx * vx + vy * vy);
            dist *= Math.sign(vx* tng.y  - vy*tng.x)
            dist += sHeight /2
            if(dist >= 0 && dist <= sHeight){
                var srcIndex = Math.round(bp * sWidth) + Math.round(dist) * sWidth;
                if(sourcePixels[srcIndex] !== 0){
                    pixels[x] = sourcePixels[srcIndex];
                }
            }
        }
    }
    setRow(y,pixels);
}

var scanY = 0;
// scan all pixels on canvas
function scan(){
    scanLine(scanY);
    scanY += 1;
    if(scanY < canvas.height){
        setTimeout(scan,1);
    }
}
// draw curve
ctx.fillStyle = "blue";
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(bez.p1.x,bez.p1.y);
ctx.bezierCurveTo(bez.cp1.x,bez.cp1.y,bez.cp2.x,bez.cp2.y,bez.p2.x,bez.p2.y);
ctx.stroke();
//start scan
scan();

WebGL example

This example just renders the bezier onto an offscreen canvas using webGL and then renders that canvas onto the 2D canvas, so you still have full use of the 2D API.

Its a bit of a mess. But from your bin you know what you are doing so hopefully this will help.

var createImage=function(w,h){var i=document.createElement("canvas");i.width=w;i.height=h;i.ctx=i.getContext("2d");return i;}
var createCanvas=function(w,h){var i=document.createElement("canvas");i.width=w;i.height=h;return i;}
var can,gl; // canvas and webGL context
var canvas = createImage(512,512);
var ctx = canvas.ctx;
document.body.appendChild(canvas);
document.body.style.background = "#999";
var x = 0;
var y = 0;
var v1 = {x,y};
var v2 = {x,y};
var v3 = {x,y};
var v4 = {x,y};
var tng = {x,y};
var p = {x,y};
var curvePos = {x,y};
var c1,u1,b1,a,b,c,d,e,vx,vy;

// the bez we are using
var bez = {};
bez.p1 = {x : 50, y : 50};  // start
bez.p2 = {x : 350, y : 350}; // end
bez.cp1 = {x : 300, y : 50}; // first control point
bez.cp2 = {x : 50, y : 310}; // second control point if undefined then this is a quadratic

function getBezierAt(bez,pos){  
    if(bez.cp2 === undefined){
        a = (1 - pos); 
        c = i * pos; 
        b = a*2*pos;
        a *= a;  
        curvePos.x = bez.p1.x * a + bez.cp1.x * b + bez.p2.x * c;
        curvePos.y = bez.p1.y * a + bez.cp1.y * b + bez.p2.y * c;
    }else{
        a = (1 - pos); 
        c = pos * pos; 
        b = 3 * a * a * pos; 
        b1 = 3 * c * a; 
        a = a*a*a;
        c *= pos; 
        curvePos.x = bez.p1.x * a + bez.cp1.x * b + bez.cp2.x * b1 + bez.p2.x * c;
        curvePos.y = bez.p1.y * a + bez.cp1.y * b + bez.cp2.y * b1 + bez.p2.y * c;
    }
    return curvePos;
};

function tangentAt(bez, position) {  // returns the normalised tangent at position
    if(bez.cp2 === undefined){
        a = (1-position) * 2;
        b = position * 2;
        tng.x = a * (bez.cp1.x - bez.p1.x) + b * (bez.p2.x - bez.cp1.x);
        tng.y = a * (bez.cp1.y - bez.p1.y) + b * (bez.p2.y - bez.cp1.y);
    }else{
        a  = (1-position)
        b  = 6 * a * position;        // (6*(1-t)*t)
        a *= 3 * a;                  // 3 * ( 1 - t) ^ 2
        c  = 3 * position * position; // 3 * t ^ 2
        tng.x  = -bez.p1.x * a + bez.cp1.x * (a - b) + bez.cp2.x * (b - c) + bez.p2.x * c;
        tng.y  = -bez.p1.y * a + bez.cp1.y * (a - b) + bez.cp2.y * (b - c) + bez.p2.y * c;
    }   
    var u = Math.sqrt(tng.x * tng.x + tng.y * tng.y);
    tng.x /= u;
    tng.y /= u;
    return tng;                 
}

function createTestImage(w,h,checkerSize,c1,c2){
    var testImage = createImage(w,h);
    var darkG = testImage.ctx.createLinearGradient(0,0,0,h);
    var lightG = testImage.ctx.createLinearGradient(0,0,0,h);
    for(var i = 0; i <= 1; i += 1/20){
        darkG.addColorStop(i,"rgba("+c1.join(",")+","+(Math.pow(Math.sin(i * Math.PI),5))+")");
        lightG.addColorStop(i,"rgba("+c2.join(",")+","+Math.pow(Math.sin(i * Math.PI),5)+")");
    }
    for(var i = 0; i < h; i += checkerSize){
        for(var j = 0; j < w; j += checkerSize){
            if(((i/checkerSize+j/checkerSize) % 2) === 0){
                testImage.ctx.fillStyle = darkG;
            }else{
                testImage.ctx.fillStyle = lightG;
            }
            testImage.ctx.fillRect(j,i,checkerSize,checkerSize);
        }
    }    
    return testImage;
}

// Creates a mesh with texture coords for webGL to render
function createBezierMesh(bezier,steps,tWidth,tHeight){
    var i,x,y,tx,ty;
    var array = [];
    var step = 1/steps;
    for(var i = 0; i < 1 + step/2; i += step){
        if(i > 1){  // sometimes there is a slight error
            i = 1;
        }
        curvePos = getBezierAt(bezier,i);
        tng = tangentAt(bezier,i);
        x = curvePos.x - tng.y * (tHeight/2);
        y = curvePos.y + tng.x * (tHeight/2);
        tx = i;
        ty = 0;
        array.push({x,y,tx,ty})
        x = curvePos.x + tng.y * (tHeight/2);
        y = curvePos.y - tng.x * (tHeight/2);
        ty = 1;
        array.push({x,y,tx,ty})
    }
    return array;
}

function createShaders(){
    var fShaderSrc = ` 
        precision mediump float; 
        uniform sampler2D image;  // texture to draw  
        varying vec2 texCoord;   // holds text coordinates
        void main() {
           gl_FragColor = texture2D(image,texCoord);
        }`;
    var vShaderSrc = `
        attribute vec4 vert;     // holds a vert with pos as xy textures as zw
        varying vec2 texCoord;   // holds text coordinates
        void main(){
            gl_Position = vec4(vert.x,vert.y,0.0,1.0); // seperate out the position
            texCoord = vec2(vert.z,vert.w);        // and texture coordinate
        }`;
    var fShader = gl.createShader(gl.FRAGMENT_SHADER);
    gl.shaderSource(fShader, fShaderSrc);
    gl.compileShader(fShader);
    var vShader = gl.createShader(gl.VERTEX_SHADER);
    gl.shaderSource(vShader, vShaderSrc);
    gl.compileShader(vShader);
    var program = gl.createProgram();
    gl.attachShader(program, fShader);
    gl.attachShader(program, vShader);
    gl.linkProgram(program);
    gl.useProgram(program);    
    program.vertAtr = gl.getAttribLocation(program, "vert"); // save location of verts
    gl.enableVertexAttribArray(program.vertAtr);    // turn em on
    return program;
}
function createTextureFromImage(image){
    var texture = gl.createTexture()
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
    gl.bindTexture(gl.TEXTURE_2D, null);    
    return texture;
}
function createMesh(array,vertSize) {
    var meshBuf ;
    var w = gl.canvas.width;
    var h = gl.canvas.height;
    var verts = [];
    for(var i = 0; i < array.length; i += 1){
        var v = array[i];
        verts.push((v.x - w / 2) / w * 2 , -(v.y - h / 2) / h * 2, v.tx, v.ty);
    }
    verts = new Float32Array(verts);
    gl.bindBuffer(gl.ARRAY_BUFFER, meshBuf = gl.createBuffer());    
    gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);
    meshBuf.vertSize = vertSize;
    meshBuf.numVerts = array.length ;  
    return {verts,meshBuf}
 }
function drawMesh(mesh){
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);    
    gl.useProgram(mesh.program);
    gl.bindBuffer(gl.ARRAY_BUFFER, mesh.meshBuf);
    gl.bufferData(gl.ARRAY_BUFFER, mesh.verts, gl.STATIC_DRAW);
    gl.vertexAttribPointer(mesh.program.vertAtr, mesh.meshBuf.vertSize, gl.FLOAT, false, 0, 0);    
    gl.activeTexture(gl.TEXTURE0);
    gl.bindTexture(gl.TEXTURE_2D, mesh.texture);    
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, mesh.meshBuf.numVerts);
}
function startWebGL(imgW,imgH){
    can = createCanvas(canvas.width,canvas.height);
    gl = can.getContext("webgl");
    gl.viewportWidth = can.width;
    gl.viewportHeight = can.height;
    gl.enable(gl.DEPTH_TEST);
    gl.enable(gl.BLEND);
    var mesh = createMesh(createBezierMesh(bez,50,imgW,imgH),4);
    mesh.program = createShaders();
    mesh.W = imgW;
    mesh.H = imgH;
    mesh.texture = createTextureFromImage(createTestImage(imgW,imgH,imgH/4,[255,255,255],[0,255,0]));
    gl.viewport(0, 0, gl.viewportWidth, gl.viewportHeight);    
    gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
    gl.clearColor(0,0,0,0);
    drawMesh(mesh)
    return mesh;
}
// recreates bezier mesh and draws it
function updateBezier(bezier,mesh){
    var array = createBezierMesh(bezier,50,mesh.W,mesh.H);
    var index = 0;
    var w = gl.canvas.width;
    var h = gl.canvas.height;    
    for(var i = 0; i < array.length; i += 1){
        var v = array[i];
        mesh.verts[index ++] = (v.x - w / 2) / w * 2;
        mesh.verts[index ++] = -(v.y - h / 2) / h * 2;
        mesh.verts[index ++] = v.tx;
        mesh.verts[index ++] = v.ty;
    }    
    drawMesh(mesh);
}

ctx.font = "26px arial";
// main update function
function update(timer){
    var w = canvas.width;
    var h = canvas.height;
    ctx.setTransform(1,0,0,1,0,0); // reset transform
    ctx.globalAlpha = 1;           // reset alpha
    ctx.clearRect(0,0,canvas.width,canvas.height);
    var x= Math.cos(timer / 1000) * 100;
    var y= Math.sin(timer / 1000) * 100;
    bez.p1.x = 50 + x;
    bez.p1.y = 50 + y;
    var x= Math.cos(timer / 2000) * 100;
    var y= Math.sin(timer / 2000) * 100;
    bez.p2.x = 350 + x;
    bez.p2.y = 350 + y;
    updateBezier(bez,glMesh)
    ctx.drawImage(can,0,0);
    ctx.fillText("WebGL rendered to 2D canvas.",10,30)
    requestAnimationFrame(update);
}
var glMesh = startWebGL(512,64);
requestAnimationFrame(update);

Note Both examples use ES6 syntax, use babel if you want IE11 support.