【译】canvas笔触魔法师

5,005 阅读18分钟

阿最近发现的一篇超好文!前一年自己曾有开发网页手绘板,如果当时有看见它就好啦!文末的两个超6效果千万不要错过喔!p.s. 原文每个例子都附带codepen,感兴趣的话可以点进原文挨个进行试验~

原文地址:Exploring canvas drawing techniques

----------正文分割线----------

我最近在试验网页手绘的不同风格—比如顺滑笔触,贝塞尔曲线笔触,墨水笔触,铅笔笔触,印花笔触等等。结果十分让我惊喜~于是,我决心要整理一份交互式canvas笔触教程以飨这次经历。我们会从基础开始(非常原始的边移鼠标边划线的笔触),到和谐的笔刷式笔触,到曲线复杂,怪异但优美的其他笔触。这篇教程也折射了我对于canvas的探索之路。

我会简要介绍关于笔刷的不同实现方式,只要知道自己实现自由笔触,然后就可以愉快的玩耍啦。

在开始之前,你当然至少要对canvas有所了解喔。

基础

先从最基础的方式开始。

普通笔划

var el = document.getElementById('c');
var ctx = el.getContext('2d');
var isDrawing;

el.onmousedown = function(e) {
  isDrawing = true;
  ctx.moveTo(e.clientX, e.clientY);
};
el.onmousemove = function(e) {
  if (isDrawing) {
    ctx.lineTo(e.clientX, e.clientY);
    ctx.stroke();
  }
};
el.onmouseup = function() {
  isDrawing = false;
};

在canvas上监听mousedown, mousemove和mouseup事件。mousedown时,将起点移至(ctx.moveTo)鼠标点击的坐标。mousemove时,连接(ctx.lineTo)到新坐标,画一条线。最后在mouseup时,结束绘制,并将isDrawing标志设为false。它是为了避免当鼠标没有任何点击操作,只是单纯在画布上失焦移动时,不会划线。你也可以在mousedown事件时监听mousemove事件,在mouseup事件时取消监听mousemove事件,不过设个全局标志的做法要来得更方便。

顺滑连接

刚刚我们开始了第一步。现在则可以通过改变ctx.lineWidth的值来改变线条粗细啦。但是,线条越粗,锯齿边缘也更明显。突兀的线条转折处可以通过设置ctx.lineJoinctx.lineCap为'round'来解决(MDN上的一些案例)。

var el = document.getElementById('c');
var ctx = el.getContext('2d');
var isDrawing;

el.onmousedown = function(e) {
  isDrawing = true;
  ctx.lineWidth = 10;
  ctx.lineJoin = ctx.lineCap = 'round';
  ctx.moveTo(e.clientX, e.clientY);
};
el.onmousemove = function(e) {
  if (isDrawing) {
    ctx.lineTo(e.clientX, e.clientY);
    ctx.stroke();
  }
};
el.onmouseup = function() {
  isDrawing = false;
};

带阴影的顺滑边缘

现在拐角处的线条锯齿没那么严重啦。但是线条主干部分还是有锯齿,由于canvas并没有直接的去除锯齿api,所以我们要如何优化边缘呢?

一种方式是借助阴影。

var el = document.getElementById('c');
var ctx = el.getContext('2d');
var isDrawing;

el.onmousedown = function(e) {
  isDrawing = true;
  ctx.lineWidth = 10;
  ctx.lineJoin = ctx.lineCap = 'round';
  ctx.shadowBlur = 10;
  ctx.shadowColor = 'rgb(0, 0, 0)';
  ctx.moveTo(e.clientX, e.clientY);
};
el.onmousemove = function(e) {
  if (isDrawing) {
    ctx.lineTo(e.clientX, e.clientY);
    ctx.stroke();
  }
};
el.onmouseup = function() {
  isDrawing = false;
};

只需加上ctx.shadowBlurctx.shadowColor。边缘明显更为顺滑,锯齿边缘都被阴影包裹住了。但是却有个小问题。注意到线条的开头部分通常较淡也较糊,尾部颜色却会变得更深。效果独特,不过并不是我们的本意。这是由什么引起的呢?

答案是阴影重叠。当前笔触的阴影覆盖了上条笔触的阴影,阴影覆盖得越厉害,模糊效果越弱,线条颜色也更深。该如何修正这个问题嘞?

基于点的处理

可以通过只画一次来规避这类问题。与其每次在鼠标滚动时都连线,我们可以引进一种新方式:将笔触坐标点存储在数组里,每次都重绘一次。

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineWidth = 10;
ctx.lineJoin = ctx.lineCap = 'round';

var isDrawing, points = [ ];

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};

el.onmousemove = function(e) {
  if (!isDrawing) return;

  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  points.push({ x: e.clientX, y: e.clientY });

  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  for (var i = 1; i < points.length; i++) {
    ctx.lineTo(points[i].x, points[i].y);
  }
  ctx.stroke();
};

el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

可以看到,它和第一个例子几乎一样,从头到尾粗细都是均匀的。现在我们可以尝试给它加上阴影啦~

基于点的处理+阴影

带径向渐变的顺滑边缘

使边缘变得顺滑的另一种处理办法是使用径向渐变。不像阴影效果有点“模糊”大过“顺滑”的感觉,渐变让色彩分配更加均匀。

var el = document.getElementById('c');
var ctx = el.getContext('2d');
var isDrawing;

el.onmousedown = function(e) {
  isDrawing = true;
  ctx.moveTo(e.clientX, e.clientY);
};
el.onmousemove = function(e) {
  if (isDrawing) {
    var radgrad = ctx.createRadialGradient(
      e.clientX,e.clientY,10,e.clientX,e.clientY,20);
    
    radgrad.addColorStop(0, '#000');
    radgrad.addColorStop(0.5, 'rgba(0,0,0,0.5)');
    radgrad.addColorStop(1, 'rgba(0,0,0,0)');
    ctx.fillStyle = radgrad;
    
    ctx.fillRect(e.clientX-20, e.clientY-20, 40, 40);
  }
};
el.onmouseup = function() {
  isDrawing = false;
};

但是如图所示,渐变笔触有个很明显的问题。我们的做法是给鼠标移动区域填充圆形渐变,但当鼠标滑动过快时,会出现不连贯点的轨迹,而不是边缘光滑的直线。

解决这个问题的办法可以是当两个落笔点间距过大时,自动用额外的点去填充之间的间距。

function distanceBetween(point1, point2) {
  return Math.sqrt(Math.pow(point2.x - point1.x, 2) + Math.pow(point2.y - point1.y, 2));
}
function angleBetween(point1, point2) {
  return Math.atan2( point2.x - point1.x, point2.y - point1.y );
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');
ctx.lineJoin = ctx.lineCap = 'round';

var isDrawing, lastPoint;

el.onmousedown = function(e) {
  isDrawing = true;
  lastPoint = { x: e.clientX, y: e.clientY };
};

el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  var currentPoint = { x: e.clientX, y: e.clientY };
  var dist = distanceBetween(lastPoint, currentPoint);
  var angle = angleBetween(lastPoint, currentPoint);
  
  for (var i = 0; i < dist; i+=5) {
    
    x = lastPoint.x + (Math.sin(angle) * i);
    y = lastPoint.y + (Math.cos(angle) * i);
    
    var radgrad = ctx.createRadialGradient(x,y,10,x,y,20);
    
    radgrad.addColorStop(0, '#000');
    radgrad.addColorStop(0.5, 'rgba(0,0,0,0.5)');
    radgrad.addColorStop(1, 'rgba(0,0,0,0)');
    
    ctx.fillStyle = radgrad;
     ctx.fillRect(x-20, y-20, 40, 40);
  }
  
  lastPoint = currentPoint;
};

el.onmouseup = function() {
  isDrawing = false;
};

终于得到一条顺滑的曲线啦!

你也许留意到了上例的一个小改动。我们只存了路径的最后一个点,而不是整条路径上的所有点。每次连线时,会从上一个点连到当前的最新点,以此来取得两点间距。如果间距过大,则在其中填充更多点。这样做的好处是可以不用每次都存下所有points数组!

贝塞尔曲线

请铭记这个概念,与其在两点间连直线,不如用贝塞尔曲线。它会让路径显得更为自然。做法是将直线替换为quadraticCurveTo,并将两点间的中点作为控制点:

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};

el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  points.push({ x: e.clientX, y: e.clientY });

  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  
  var p1 = points[0];
  var p2 = points[1];
  
  ctx.beginPath();
  ctx.moveTo(p1.x, p1.y);
  console.log(points);

  for (var i = 1, len = points.length; i < len; i++) {
    // we pick the point between pi+1 & pi+2 as the
    // end point and p1 as our control point
    var midPoint = midPointBtw(p1, p2);
    ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y);
    p1 = points[i];
    p2 = points[i+1];
  }
  // Draw last line as a straight line while
  // we wait for the next point to be able to calculate
  // the bezier control point
  ctx.lineTo(p1.x, p1.y);
  ctx.stroke();
};

目前为止,你已有绘制基础,知道如何画顺滑流畅的曲线了。接下来我们做点更好玩的~

笔刷效果,毛边效果,手绘效果

笔刷工具的小诀窍之一是用图片填充笔迹。我是通过这篇文章知道的,通过填充路径的方式,能制造出多种可能性。

el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  var currentPoint = { x: e.clientX, y: e.clientY };
  var dist = distanceBetween(lastPoint, currentPoint);
  var angle = angleBetween(lastPoint, currentPoint);
  
  for (var i = 0; i < dist; i++) {
    x = lastPoint.x + (Math.sin(angle) * i) - 25;
    y = lastPoint.y + (Math.cos(angle) * i) - 25;
    ctx.drawImage(img, x, y);
  }
  
  lastPoint = currentPoint;
};

根据填充图片,我们可以制造不同特色的笔刷。如上图就是一个厚笔刷。

毛边效果(反转笔画)

每次用图片填充路径的时候,都随机旋转图片,可以得到很有趣的效果,类似下图的毛边/花环效果:

el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  var currentPoint = { x: e.clientX, y: e.clientY };
  var dist = distanceBetween(lastPoint, currentPoint);
  var angle = angleBetween(lastPoint, currentPoint);
  
  for (var i = 0; i < dist; i++) {
    x = lastPoint.x + (Math.sin(angle) * i);
    y = lastPoint.y + (Math.cos(angle) * i);
    ctx.save();
    ctx.translate(x, y);
    ctx.scale(0.5, 0.5);
    ctx.rotate(Math.PI * 180 / getRandomInt(0, 180));
    ctx.drawImage(img, 0, 0);
    ctx.restore();
  }
  
  lastPoint = currentPoint;
};

手绘效果(随机宽度)

要想模拟手绘效果,那么生成不定的路径宽度就行了。我们依然使用moveTo+lineTo的老办法,只不过每次连线时都改变线条宽度:

...
for (var i = 1; i < points.length; i++) {
    ctx.beginPath();
    ctx.moveTo(points[i-1].x, points[i-1].y);
    ctx.lineWidth = points[i].width;
    ctx.lineTo(points[i].x, points[i].y);
    ctx.stroke();
  }
 

不过要记得,自定义的线条宽度可不能差距太大喔。

手绘效果#2(多线条)

手绘效果的另一种实现是模拟多线条。我们会在连线旁边多加两条线(下文命名为“附线”),不过位置当然会有点偏移啦。做法是在原点(绿色点)附近选两个随机点(蓝点)并连线,这样就在原线条附近得到另外两条附线。是不是完美模拟了笔尖分叉的效果!

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineWidth = 1;
ctx.lineJoin = ctx.lineCap = 'round';
ctx.strokeStyle = 'purple';

var isDrawing, lastPoint;

el.onmousedown = function(e) {
  isDrawing = true;
  lastPoint = { x: e.clientX, y: e.clientY };
};

el.onmousemove = function(e) {
  if (!isDrawing) return;

  ctx.beginPath();
  
  ctx.moveTo(lastPoint.x - getRandomInt(0, 2), lastPoint.y - getRandomInt(0, 2));
  ctx.lineTo(e.clientX - getRandomInt(0, 2), e.clientY - getRandomInt(0, 2));
  ctx.stroke();
  
  ctx.moveTo(lastPoint.x, lastPoint.y);
  ctx.lineTo(e.clientX, e.clientY);
  ctx.stroke();
  
  ctx.moveTo(lastPoint.x + getRandomInt(0, 2), lastPoint.y + getRandomInt(0, 2));
  ctx.lineTo(e.clientX + getRandomInt(0, 2), e.clientY + getRandomInt(0, 2));
  ctx.stroke();
    
  lastPoint = { x: e.clientX, y: e.clientY };
};

el.onmouseup = function() {
  isDrawing = false;
};

厚笔刷效果

你可以利用“多笔触”效果发明多种变体。如下图,我们我们增加线条宽度,并且让附线在原线条基础上偏移一点点,就能模拟厚笔刷效果。精髓是转折部分的空白区域!

横截面笔刷效果

如果我们使用多条附线,并偏移小一点,就能模拟到类似记号笔的横截面笔刷效果。这样无需使用图片填充路径,笔划会天然有偏移的效果~

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineWidth = 3;
ctx.lineJoin = ctx.lineCap = 'round';

var isDrawing, lastPoint;

el.onmousedown = function(e) {
  isDrawing = true;
  lastPoint = { x: e.clientX, y: e.clientY };
};

el.onmousemove = function(e) {
  if (!isDrawing) return;

  ctx.beginPath();
  
  ctx.globalAlpha = 1;
  ctx.moveTo(lastPoint.x, lastPoint.y);
  ctx.lineTo(e.clientX, e.clientY);
  ctx.stroke();
  
  ctx.moveTo(lastPoint.x - 4, lastPoint.y - 4);
  ctx.lineTo(e.clientX - 4, e.clientY - 4);
  ctx.stroke();
  
  ctx.moveTo(lastPoint.x - 2, lastPoint.y - 2);
  ctx.lineTo(e.clientX - 2, e.clientY - 2);
  ctx.stroke();
  
  ctx.moveTo(lastPoint.x + 2, lastPoint.y + 2);
  ctx.lineTo(e.clientX + 2, e.clientY + 2);
  ctx.stroke();
  
  ctx.moveTo(lastPoint.x + 4, lastPoint.y + 4);
  ctx.lineTo(e.clientX + 4, e.clientY + 4);
  ctx.stroke();
    
  lastPoint = { x: e.clientX, y: e.clientY };
};

el.onmouseup = function() {
  isDrawing = false;
};

带透明度的横截面笔刷

如果我们在上个效果的基础上给每条附线越来越重的透明度,我们就能得到下图的有趣效果:

多重线

直线练习得够多的啦,我们能否将上文介绍的几种技巧应用于贝塞尔曲线上呢?当然。同样只需将每条曲线在原线的基础上偏移一点:

function midPointBtw(p1, p2) {
  return {
    x: p1.x + (p2.x - p1.x) / 2,
    y: p1.y + (p2.y - p1.y) / 2
  };
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineWidth = 1;
ctx.lineJoin = ctx.lineCap = 'round';

var isDrawing, points = [ ];

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};

el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  points.push({ x: e.clientX, y: e.clientY });
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
   
  stroke(offsetPoints(-4));
  stroke(offsetPoints(-2));
  stroke(points);
  stroke(offsetPoints(2));
  stroke(offsetPoints(4));
};

function offsetPoints(val) {
  var offsetPoints = [ ];
  for (var i = 0; i < points.length; i++) {
    offsetPoints.push({ 
      x: points[i].x + val,
      y: points[i].y + val
    });
  }
  return offsetPoints;
}

function stroke(points) {
  var p1 = points[0];
  var p2 = points[1];
  
  ctx.beginPath();
  ctx.moveTo(p1.x, p1.y);

  for (var i = 1, len = points.length; i < len; i++) {
    // we pick the point between pi+1 & pi+2 as the
    // end point and p1 as our control point
    var midPoint = midPointBtw(p1, p2);
    ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y);
    p1 = points[i];
    p2 = points[i+1];
  }
  // Draw last line as a straight line while
  // we wait for the next point to be able to calculate
  // the bezier control point
  ctx.lineTo(p1.x, p1.y);
  ctx.stroke();
}

el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

带透明度的多重线

亦可以给每条线依次增加透明度,颇为优雅。

function midPointBtw(p1, p2) {
  return {
    x: p1.x + (p2.x - p1.x) / 2,
    y: p1.y + (p2.y - p1.y) / 2
  };
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineWidth = 1;
ctx.lineJoin = ctx.lineCap = 'round';

var isDrawing, points = [ ];

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};

el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  points.push({ x: e.clientX, y: e.clientY });
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  
  ctx.strokeStyle = 'rgba(0,0,0,1)';
  stroke(offsetPoints(-4));
  ctx.strokeStyle = 'rgba(0,0,0,0.8)';
  stroke(offsetPoints(-2));
  ctx.strokeStyle = 'rgba(0,0,0,0.6)';
  stroke(points);
  ctx.strokeStyle = 'rgba(0,0,0,0.4)';
  stroke(offsetPoints(2));
  ctx.strokeStyle = 'rgba(0,0,0,0.2)';
  stroke(offsetPoints(4));
};

function offsetPoints(val) {
  var offsetPoints = [ ];
  for (var i = 0; i < points.length; i++) {
    offsetPoints.push({ 
      x: points[i].x + val,
      y: points[i].y + val
    });
  }
  return offsetPoints;
}

function stroke(points) {
  var p1 = points[0];
  var p2 = points[1];
  
  ctx.beginPath();
  ctx.moveTo(p1.x, p1.y);

  for (var i = 1, len = points.length; i < len; i++) {
    // we pick the point between pi+1 & pi+2 as the
    // end point and p1 as our control point
    var midPoint = midPointBtw(p1, p2);
    ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y);
    p1 = points[i];
    p2 = points[i+1];
  }
  // Draw last line as a straight line while
  // we wait for the next point to be able to calculate
  // the bezier control point
  ctx.lineTo(p1.x, p1.y);
  ctx.stroke();
}

el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

印花篇

基础效果

既然我们已经学会了如何画线和曲线,实现印花笔刷就更容易啦!我们只需在鼠标路径上每个点的坐标上画出某种图形,以下就是红色圈圈的效果:

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineJoin = ctx.lineCap = 'round';
ctx.fillStyle = 'red';

var isDrawing, points = [ ], radius = 15;

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};
el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  points.push({ x: e.clientX, y: e.clientY });
  
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  for (var i = 0; i < points.length; i++) {
    ctx.beginPath();
    ctx.arc(points[i].x, points[i].y, radius, false, Math.PI * 2, false);
    ctx.fill();
    ctx.stroke();
  }
};
el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

轨迹效果

上图也有几个点间隔得太远的问题,同样可以通过填充中间点来解决。以下会生成有趣的轨迹或管道效果。你可以控制点间间隔,从而控制轨迹密度。

See the Pen Ictqs by Juriy Zaytsev (@kangax) on CodePen.

随机半径和透明度

还可以在原来的配方上加点料,给每个印花随机做点修改。比方说,随机改改印花的半径和透明度。

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineJoin = ctx.lineCap = 'round';
ctx.fillStyle = 'red';

var isDrawing, points = [ ], radius = 15;

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ 
    x: e.clientX, 
    y: e.clientY,
    radius: getRandomInt(10, 30),
    opacity: Math.random()
  });
};
el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  points.push({ 
    x: e.clientX, 
    y: e.clientY,
    radius: getRandomInt(5, 20),
    opacity: Math.random()
  });
  
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  for (var i = 0; i < points.length; i++) {
    ctx.beginPath();
    ctx.globalAlpha = points[i].opacity;
    ctx.arc(
      points[i].x, points[i].y, points[i].radius, 
      false, Math.PI * 2, false);
    ctx.fill();
  }
};
el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

图形

既然是印花,那印花的形状也可以随心所欲。下图就是由五角星形状形成的印花:

function drawStar(x, y) {
  var length = 15;
  ctx.save();
  ctx.translate(x, y);
  ctx.beginPath();
  ctx.rotate((Math.PI * 1 / 10));
  for (var i = 5; i--;) {
    ctx.lineTo(0, length);
    ctx.translate(0, length);
    ctx.rotate((Math.PI * 2 / 10));
    ctx.lineTo(0, -length);
    ctx.translate(0, -length);
    ctx.rotate(-(Math.PI * 6 / 10));
  }
  ctx.lineTo(0, length);
  ctx.closePath();
  ctx.stroke();
  ctx.restore();
}

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineJoin = ctx.lineCap = 'round';
ctx.fillStyle = 'red';

var isDrawing, points = [ ], radius = 15;

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};
el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  points.push({ x: e.clientX, y: e.clientY });
  
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  for (var i = 0; i < points.length; i++) {
    drawStar(points[i].x, points[i].y);
  }
};
el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

旋转图形

同样是五角星,如果让它们随机旋转起来,就更显自然。

See the Pen Cspre by Juriy Zaytsev (@kangax) on CodePen.

随机一切

如果我们将…大小,角度,透明度,颜色甚至粗细都随机起来,结果也超级绚烂!

function drawStar(options) {
  var length = 15;
  ctx.save();
  ctx.translate(options.x, options.y);
  ctx.beginPath();
  ctx.globalAlpha = options.opacity;
  ctx.rotate(Math.PI / 180 * options.angle);
  ctx.scale(options.scale, options.scale);
  ctx.strokeStyle = options.color;
  ctx.lineWidth = options.width;
  for (var i = 5; i--;) {
    ctx.lineTo(0, length);
    ctx.translate(0, length);
    ctx.rotate((Math.PI * 2 / 10));
    ctx.lineTo(0, -length);
    ctx.translate(0, -length);
    ctx.rotate(-(Math.PI * 6 / 10));
  }
  ctx.lineTo(0, length);
  ctx.closePath();
  ctx.stroke();
  ctx.restore();
}

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

var isDrawing, points = [ ], radius = 15;

function addRandomPoint(e) {
  points.push({ 
    x: e.clientX, 
    y: e.clientY, 
    angle: getRandomInt(0, 180),
    width: getRandomInt(1,10),
    opacity: Math.random(),
    scale: getRandomInt(1, 20) / 10,
    color: ('rgb('+getRandomInt(0,255)+','+getRandomInt(0,255)+','+getRandomInt(0,255)+')')
  });
}

el.onmousedown = function(e) {
  isDrawing = true;
  addRandomPoint(e);
};
el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  addRandomPoint(e);
  
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  for (var i = 0; i < points.length; i++) {
    drawStar(points[i]);
  }
};
el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

彩色像素点

不必拘泥于形状。就在移动笔触附近随机散落彩色像素点,也很可爱哟!颜色和定位都可以是随机的!

function drawPixels(x, y) {
  for (var i = -10; i < 10; i+= 4) {
    for (var j = -10; j < 10; j+= 4) {
      if (Math.random() > 0.5) {
        ctx.fillStyle = ['red', 'orange', 'yellow', 'green', 
                         'light-blue', 'blue', 'purple'][getRandomInt(0,6)];
        ctx.fillRect(x+i, y+j, 4, 4);
      }
    }
  }
}

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineJoin = ctx.lineCap = 'round';
var isDrawing, lastPoint;

el.onmousedown = function(e) {
  isDrawing = true;
  lastPoint = { x: e.clientX, y: e.clientY };
};
el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  drawPixels(e.clientX, e.clientY);
  
  lastPoint = { x: e.clientX, y: e.clientY };
};
el.onmouseup = function() {
  isDrawing = false;
};

图案笔刷

我们尝试了印章效果,现在来看看另一种截然不同但也妙趣横生的技巧—图案笔刷。我们可以利用canvas的createPatternapi来填充路径。以下就是一个简单的点点图案笔刷。

点点
function midPointBtw(p1, p2) {
  return {
    x: p1.x + (p2.x - p1.x) / 2,
    y: p1.y + (p2.y - p1.y) / 2
  };
}
function getPattern() {
  var patternCanvas = document.createElement('canvas'),
      dotWidth = 20,
      dotDistance = 5,
      patternCtx = patternCanvas.getContext('2d');

  patternCanvas.width = patternCanvas.height = dotWidth + dotDistance;

  patternCtx.fillStyle = 'red';
  patternCtx.beginPath();
  patternCtx.arc(dotWidth / 2, dotWidth / 2, dotWidth / 2, 0, Math.PI * 2, false);
  patternCtx.closePath();
  patternCtx.fill();
  return ctx.createPattern(patternCanvas, 'repeat');
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineWidth = 25;
ctx.lineJoin = ctx.lineCap = 'round';
ctx.strokeStyle = getPattern();

var isDrawing, points = [ ];

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};

el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  points.push({ x: e.clientX, y: e.clientY });

  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  
  var p1 = points[0];
  var p2 = points[1];
  
  ctx.beginPath();
  ctx.moveTo(p1.x, p1.y);

  for (var i = 1, len = points.length; i < len; i++) {
    var midPoint = midPointBtw(p1, p2);
    ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y);
    p1 = points[i];
    p2 = points[i+1];
  }
  ctx.lineTo(p1.x, p1.y);
  ctx.stroke();
};

el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

留意这里的图案生成方式。我们先初始化了一张迷你canvas,在上边画了圈圈,然后把那张canvas当成图案绘制到真正被我们用来画的canvas上。当然也可以直接用圈圈图片,但是使用圈圈canvas的美妙之处就在于可以随心所欲的改造它呀。我们可以使用动态图案,改变圈圈的颜色或是半径。

条纹

基于上述例子,你也可以创造点自己的图案啦,比如横向条纹。

function midPointBtw(p1, p2) {
  return {
    x: p1.x + (p2.x - p1.x) / 2,
    y: p1.y + (p2.y - p1.y) / 2
  };
}
function getPattern() {
  var patternCanvas = document.createElement('canvas'),
      dotWidth = 20,
      dotDistance = 5,
      ctx = patternCanvas.getContext('2d');

  patternCanvas.width = patternCanvas.height = 10;
  ctx.strokeStyle = 'green';
  ctx.lineWidth = 5;
  ctx.beginPath();
  ctx.moveTo(0, 5);
  ctx.lineTo(10, 5);
  ctx.closePath();
  ctx.stroke();
  return ctx.createPattern(patternCanvas, 'repeat');
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineWidth = 25;
ctx.lineJoin = ctx.lineCap = 'round';
ctx.strokeStyle = getPattern();

var isDrawing, points = [ ];

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};

el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  points.push({ x: e.clientX, y: e.clientY });

  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  
  var p1 = points[0];
  var p2 = points[1];
  
  ctx.beginPath();
  ctx.moveTo(p1.x, p1.y);

  for (var i = 1, len = points.length; i < len; i++) {
    var midPoint = midPointBtw(p1, p2);
    ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y);
    p1 = points[i];
    p2 = points[i+1];
  }
  ctx.lineTo(p1.x, p1.y);
  ctx.stroke();
};

el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

#####双色条纹

…或者是纵向双色条纹。

function midPointBtw(p1, p2) {
  return {
    x: p1.x + (p2.x - p1.x) / 2,
    y: p1.y + (p2.y - p1.y) / 2
  };
}
function getPattern() {
  var patternCanvas = document.createElement('canvas'),
      dotWidth = 20,
      dotDistance = 5,
      ctx = patternCanvas.getContext('2d');

  patternCanvas.width = 10; patternCanvas.height = 20;
  ctx.fillStyle = 'black';
  ctx.fillRect(0, 0, 5, 20);
  ctx.fillStyle = 'gold';
  ctx.fillRect(5, 0, 10, 20);
  return ctx.createPattern(patternCanvas, 'repeat');
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineWidth = 25;
ctx.lineJoin = ctx.lineCap = 'round';
ctx.strokeStyle = getPattern();

var isDrawing, points = [ ];

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};

el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  points.push({ x: e.clientX, y: e.clientY });

  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  
  var p1 = points[0];
  var p2 = points[1];
  
  ctx.beginPath();
  ctx.moveTo(p1.x, p1.y);

  for (var i = 1, len = points.length; i < len; i++) {
    var midPoint = midPointBtw(p1, p2);
    ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y);
    p1 = points[i];
    p2 = points[i+1];
  }
  ctx.lineTo(p1.x, p1.y);
  ctx.stroke();
};

el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

彩虹

…或者是有不同颜色的多重线(我喜欢这个图案!)。一切皆有可能!

function midPointBtw(p1, p2) {
  return {
    x: p1.x + (p2.x - p1.x) / 2,
    y: p1.y + (p2.y - p1.y) / 2
  };
}
function getPattern() {
  var patternCanvas = document.createElement('canvas'),
      dotWidth = 20,
      dotDistance = 5,
      ctx = patternCanvas.getContext('2d');

  patternCanvas.width = 35; patternCanvas.height = 20;
  ctx.fillStyle = 'red';
  ctx.fillRect(0, 0, 5, 20);
  ctx.fillStyle = 'orange';
  ctx.fillRect(5, 0, 10, 20);
  ctx.fillStyle = 'yellow';
  ctx.fillRect(10, 0, 15, 20);
  ctx.fillStyle = 'green';
  ctx.fillRect(15, 0, 20, 20);
  ctx.fillStyle = 'lightblue';
  ctx.fillRect(20, 0, 25, 20);
  ctx.fillStyle = 'blue';
  ctx.fillRect(25, 0, 30, 20);
  ctx.fillStyle = 'purple';
  ctx.fillRect(30, 0, 35, 20);
  return ctx.createPattern(patternCanvas, 'repeat');
}

var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineWidth = 25;
ctx.lineJoin = ctx.lineCap = 'round';
ctx.strokeStyle = getPattern();

var isDrawing, points = [ ];

el.onmousedown = function(e) {
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};

el.onmousemove = function(e) {
  if (!isDrawing) return;
  
  points.push({ x: e.clientX, y: e.clientY });

  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  
  var p1 = points[0];
  var p2 = points[1];
  
  ctx.beginPath();
  ctx.moveTo(p1.x, p1.y);

  for (var i = 1, len = points.length; i < len; i++) {
    var midPoint = midPointBtw(p1, p2);
    ctx.quadraticCurveTo(p1.x, p1.y, midPoint.x, midPoint.y);
    p1 = points[i];
    p2 = points[i+1];
  }
  ctx.lineTo(p1.x, p1.y);
  ctx.stroke();
};

el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

图片

最后,再给张基于图片填充贝塞尔路径的例子。唯一改变的是传给createPattern的是张图片。

喷枪

怎么能漏了喷枪效果呢?也有几种实现它的方式。比如在笔触点落点旁边填充像素点。填充半径越大,效果更厚重。填充像素点越多,则更密集。

var el = document.getElementById('c');
var ctx = el.getContext('2d');
var isDrawing;
var density = 50;

function getRandomInt(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}

el.onmousedown = function(e) {
  isDrawing = true;
  ctx.lineWidth = 10;
  ctx.lineJoin = ctx.lineCap = 'round';
  ctx.moveTo(e.clientX, e.clientY);
};
el.onmousemove = function(e) {
  if (isDrawing) {
    for (var i = density; i--; ) {
      var radius = 20;
      var offsetX = getRandomInt(-radius, radius);
      var offsetY = getRandomInt(-radius, radius);
      ctx.fillRect(e.clientX + offsetX, e.clientY + offsetY, 1, 1);
    }
  }
};
el.onmouseup = function() {
  isDrawing = false;
};

连续喷枪

你可能留意到上述方法和真实喷枪效果间还是有点差距的。真实喷枪是持续不断的喷,而不是只有在鼠标/笔刷滑动的时候才喷。我们可以在鼠标按压某个区域时,通过特定间隔时间给该区域进行喷墨绘制。这样,”喷枪“在某区域停留时间更长,得到的喷墨也重。

See the Pen Craxn by Juriy Zaytsev (@kangax) on CodePen.

圆形区域连续喷枪

其实上图的喷枪还有提升空间。真实喷枪效果的绘制区域是圆形而不是矩形,所以我们也可以将分配区域改为圆形区域。

邻点相连

将毗邻的点连起来的概念由zefrank的Scribble和doob先生的Harmony(注: 这两链接近乎丢失在历史的长河里了…)普及开来。其理念是,将绘制路径上的相近点连起来。这会创造出一种素描涂抹或是网状折叠效果(注:也是我觉得最6的效果了!)。

所有点相连

初始做法可以是在第一个普通连线例子的基础上增添额外笔划。针对路径上的每个点,再将其和前某个点连起来:

el.onmousemove = function(e) {
  if (!isDrawing) return;

  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  points.push({ x: e.clientX, y: e.clientY });

  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  for (var i = 1; i < points.length; i++) {
    ctx.lineTo(points[i].x, points[i].y);
    var nearPoint = points[i-5];
    if (nearPoint) {
      ctx.moveTo(nearPoint.x, nearPoint.y);
      ctx.lineTo(points[i].x, points[i].y);
    }
  }
  ctx.stroke();
};

el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

给额外连起来的线加点透明度或是阴影,可以使它们变得更具现实风格。

相邻点相连

See the Pen EjivI by Juriy Zaytsev (@kangax) on CodePen.
var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineWidth = 1;
ctx.lineJoin = ctx.lineCap = 'round';

var isDrawing, points = [ ];

el.onmousedown = function(e) {
  points = [ ];
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};

el.onmousemove = function(e) {
  if (!isDrawing) return;

  //ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  points.push({ x: e.clientX, y: e.clientY });

  ctx.beginPath();
  ctx.moveTo(points[points.length - 2].x, points[points.length - 2].y);
  ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
  ctx.stroke();
  
  for (var i = 0, len = points.length; i < len; i++) {
    dx = points[i].x - points[points.length-1].x;
    dy = points[i].y - points[points.length-1].y;
    d = dx * dx + dy * dy;

    if (d < 1000) {
      ctx.beginPath();
      ctx.strokeStyle = 'rgba(0,0,0,0.3)';
      ctx.moveTo( points[points.length-1].x + (dx * 0.2), points[points.length-1].y + (dy * 0.2));
      ctx.lineTo( points[i].x - (dx * 0.2), points[i].y - (dy * 0.2));
      ctx.stroke();
    }
  }
};

el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

这部分的关键代码是:

var lastPoint = points[points.length-1];

  for (var i = 0, len = points.length; i < len; i++) {
    dx = points[i].x - lastPoint.x;
    dy = points[i].y - lastPoint.y;
    d = dx * dx + dy * dy;

    if (d < 1000) {
      ctx.beginPath();
      ctx.strokeStyle = 'rgba(0,0,0,0.3)';
      ctx.moveTo(lastPoint.x + (dx * 0.2), lastPoint.y + (dy * 0.2));
      ctx.lineTo(points[i].x - (dx * 0.2), points[i].y - (dy * 0.2));
      ctx.stroke();
    }
  }

这里发生了些什么!看起来很复杂,其实道理是很简单的喔~

当画一条线时,我们会比较当前点与所有点的距离。如果距离小于某个数值(比如例子中的1000)即相邻点,那么我们就会将当前点和那一相邻点连起来。通过dx*0.2dy*0.2给连线加一点偏移。

就是这样,简单的算法制造出惊叹的效果。

毛刺边效果

给上式做一丢丢修改,使连线反向(也就是从当前点连到相邻点相对当前点的反向相邻点,阿有点拗口!)。再加点偏移,就能制造出毛刺边的效果~

See the Pen tmIuD by Juriy Zaytsev (@kangax) on CodePen.
var el = document.getElementById('c');
var ctx = el.getContext('2d');

ctx.lineWidth = 1;
ctx.lineJoin = ctx.lineCap = 'round';

var isDrawing, points = [ ];

el.onmousedown = function(e) {
  points = [ ];
  isDrawing = true;
  points.push({ x: e.clientX, y: e.clientY });
};

el.onmousemove = function(e) {
  if (!isDrawing) return;

  //ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
  points.push({ x: e.clientX, y: e.clientY });

  ctx.beginPath();
  ctx.moveTo(points[points.length - 2].x, points[points.length - 2].y);
  ctx.lineTo(points[points.length - 1].x, points[points.length - 1].y);
  ctx.stroke();
  
  for (var i = 0, len = points.length; i < len; i++) {
    dx = points[i].x - points[points.length-1].x;
    dy = points[i].y - points[points.length-1].y;
    d = dx * dx + dy * dy;

    if (d < 2000 && Math.random() > d / 2000) {
      ctx.beginPath();
      ctx.strokeStyle = 'rgba(0,0,0,0.3)';
      ctx.moveTo( points[points.length-1].x + (dx * 0.5), points[points.length-1].y + (dy * 0.5));
      ctx.lineTo( points[points.length-1].x - (dx * 0.5), points[points.length-1].y - (dy * 0.5));
      ctx.stroke();
    }
  }
};

el.onmouseup = function() {
  isDrawing = false;
  points.length = 0;
};

Lukas有一篇文章对实现相邻点相连的效果做了优秀的剖析,感兴趣的话可以一读。

所以现在你已掌握画基本图形和高端图形的技巧。不过我们在本文中也仅仅只是介绍了皮毛而已,使用canvas作画有无限的可能性,换个颜色换个透明度又是截然不同的风格。欢迎大家各自实践,开创更酷的效果!