【譯】canvas筆觸魔法師

DearVikki發表於2018-12-15

阿最近發現的一篇超好文!前一年自己曾有開發網頁手繪板,如果當時有看見它就好啦!文末的兩個超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筆觸魔法師

在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筆觸魔法師

帶陰影的順滑邊緣

現在拐角處的線條鋸齒沒那麼嚴重啦。但是線條主幹部分還是有鋸齒,由於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;
};
複製程式碼

【譯】canvas筆觸魔法師

只需加上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;
};
複製程式碼

【譯】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) {
    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;
};
複製程式碼

【譯】canvas筆觸魔法師

但是如圖所示,漸變筆觸有個很明顯的問題。我們的做法是給滑鼠移動區域填充圓形漸變,但當滑鼠滑動過快時,會出現不連貫點的軌跡,而不是邊緣光滑的直線。

解決這個問題的辦法可以是當兩個落筆點間距過大時,自動用額外的點去填充之間的間距。

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;
};
複製程式碼

【譯】canvas筆觸魔法師

終於得到一條順滑的曲線啦!

你也許留意到了上例的一個小改動。我們只存了路徑的最後一個點,而不是整條路徑上的所有點。每次連線時,會從上一個點連到當前的最新點,以此來取得兩點間距。如果間距過大,則在其中填充更多點。這樣做的好處是可以不用每次都存下所有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();
};
複製程式碼

【譯】canvas筆觸魔法師

目前為止,你已有繪製基礎,知道如何畫順滑流暢的曲線了。接下來我們做點更好玩的~

筆刷效果,毛邊效果,手繪效果

筆刷工具的小訣竅之一是用圖片填充筆跡。我是通過這篇文章知道的,通過填充路徑的方式,能製造出多種可能性。

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;
};
複製程式碼

【譯】canvas筆觸魔法師

根據填充圖片,我們可以製造不同特色的筆刷。如上圖就是一個厚筆刷。

毛邊效果(反轉筆畫)

每次用圖片填充路徑的時候,都隨機旋轉圖片,可以得到很有趣的效果,類似下圖的毛邊/花環效果:

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;
};
複製程式碼

【譯】canvas筆觸魔法師

手繪效果(隨機寬度)

要想模擬手繪效果,那麼生成不定的路徑寬度就行了。我們依然使用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();
  }
 
複製程式碼

【譯】canvas筆觸魔法師

不過要記得,自定義的線條寬度可不能差距太大喔。

手繪效果#2(多線條)

手繪效果的另一種實現是模擬多線條。我們會在連線旁邊多加兩條線(下文命名為“附線”),不過位置當然會有點偏移啦。做法是在原點(綠色點)附近選兩個隨機點(藍點)並連線,這樣就在原線條附近得到另外兩條附線。是不是完美模擬了筆尖分叉的效果!

【譯】canvas筆觸魔法師

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;
};
複製程式碼

【譯】canvas筆觸魔法師

厚筆刷效果

你可以利用“多筆觸”效果發明多種變體。如下圖,我們我們增加線條寬度,並且讓附線在原線條基礎上偏移一點點,就能模擬厚筆刷效果。精髓是轉折部分的空白區域!

【譯】canvas筆觸魔法師

橫截面筆刷效果

如果我們使用多條附線,並偏移小一點,就能模擬到類似記號筆的橫截面筆刷效果。這樣無需使用圖片填充路徑,筆劃會天然有偏移的效果~

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;
};
複製程式碼

【譯】canvas筆觸魔法師

帶透明度的橫截面筆刷

如果我們在上個效果的基礎上給每條附線越來越重的透明度,我們就能得到下圖的有趣效果:

【譯】canvas筆觸魔法師

多重線

直線練習得夠多的啦,我們能否將上文介紹的幾種技巧應用於貝塞爾曲線上呢?當然。同樣只需將每條曲線在原線的基礎上偏移一點:

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;
};
複製程式碼

【譯】canvas筆觸魔法師

帶透明度的多重線

亦可以給每條線依次增加透明度,頗為優雅。

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;
};
複製程式碼

【譯】canvas筆觸魔法師

印花篇

基礎效果

既然我們已經學會了如何畫線和曲線,實現印花筆刷就更容易啦!我們只需在滑鼠路徑上每個點的座標上畫出某種圖形,以下就是紅色圈圈的效果:

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;
};
複製程式碼

【譯】canvas筆觸魔法師

軌跡效果

上圖也有幾個點間隔得太遠的問題,同樣可以通過填充中間點來解決。以下會生成有趣的軌跡或管道效果。你可以控制點間間隔,從而控制軌跡密度。

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

【譯】canvas筆觸魔法師

隨機半徑和透明度

還可以在原來的配方上加點料,給每個印花隨機做點修改。比方說,隨機改改印花的半徑和透明度。

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;
};
複製程式碼

【譯】canvas筆觸魔法師

圖形

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

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;
};
複製程式碼

【譯】canvas筆觸魔法師

旋轉圖形

同樣是五角星,如果讓它們隨機旋轉起來,就更顯自然。

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

【譯】canvas筆觸魔法師

隨機一切

如果我們將…大小,角度,透明度,顏色甚至粗細都隨機起來,結果也超級絢爛!

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;
};
複製程式碼

【譯】canvas筆觸魔法師

彩色畫素點

不必拘泥於形狀。就在移動筆觸附近隨機散落彩色畫素點,也很可愛喲!顏色和定位都可以是隨機的!

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筆觸魔法師

圖案筆刷

我們嘗試了印章效果,現在來看看另一種截然不同但也妙趣橫生的技巧—圖案筆刷。我們可以利用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上。當然也可以直接用圈圈圖片,但是使用圈圈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;
};
複製程式碼

【譯】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 = 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;
};
複製程式碼

【譯】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 = 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;
};
複製程式碼

【譯】canvas筆觸魔法師

圖片

最後,再給張基於圖片填充貝塞爾路徑的例子。唯一改變的是傳給createPattern的是張圖片。

【譯】canvas筆觸魔法師

噴槍

怎麼能漏了噴槍效果呢?也有幾種實現它的方式。比如在筆觸點落點旁邊填充畫素點。填充半徑越大,效果更厚重。填充畫素點越多,則更密集。

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;
};
複製程式碼

【譯】canvas筆觸魔法師

連續噴槍

你可能留意到上述方法和真實噴槍效果間還是有點差距的。真實噴槍是持續不斷的噴,而不是隻有在滑鼠/筆刷滑動的時候才噴。我們可以在滑鼠按壓某個區域時,通過特定間隔時間給該區域進行噴墨繪製。這樣,”噴槍“在某區域停留時間更長,得到的噴墨也重。

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

【譯】canvas筆觸魔法師

圓形區域連續噴槍

其實上圖的噴槍還有提升空間。真實噴槍效果的繪製區域是圓形而不是矩形,所以我們也可以將分配區域改為圓形區域。

【譯】canvas筆觸魔法師

鄰點相連

將毗鄰的點連起來的概念由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;
};
複製程式碼

【譯】canvas筆觸魔法師

給額外連起來的線加點透明度或是陰影,可以使它們變得更具現實風格。

相鄰點相連

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;
};
複製程式碼

【譯】canvas筆觸魔法師

這部分的關鍵程式碼是:

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給連線加一點偏移。

【譯】canvas筆觸魔法師

就是這樣,簡單的演算法制造出驚歎的效果。

毛刺邊效果

給上式做一丟丟修改,使連線反向(也就是從當前點連到相鄰點相對當前點的反向相鄰點,阿有點拗口!)。再加點偏移,就能製造出毛刺邊的效果~

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;
};
複製程式碼

【譯】canvas筆觸魔法師

Lukas有一篇文章對實現相鄰點相連的效果做了優秀的剖析,感興趣的話可以一讀。

所以現在你已掌握畫基本圖形和高階圖形的技巧。不過我們在本文中也僅僅只是介紹了皮毛而已,使用canvas作畫有無限的可能性,換個顏色換個透明度又是截然不同的風格。歡迎大家各自實踐,開創更酷的效果!

相關文章