WebGL Tutorial
and more

渐变色

撰写时间:2024-04-22

修订时间:2024-04-30

概述

Canvas 2D中的渐变色共分为线性渐变、辐射渐变及色轮渐变,共3种。

线性渐变

基本原理

线性渐变是一条直线的渐变。看下面的代码及其效果:

const gradient = ctx.createLinearGradient(0, 0, 200, 0); gradient.addColorStop(0, 'blue'); gradient.addColorStop(1, 'yellow'); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 200, 100);

从上面的代码及其运行效果可知,要使用线性渐变,需有4步骤:

  1. 通过调用createLinearGradient来指定一条直线的起点与终点两个坐标,代表了线性渐变的方向
  2. 通过调用addColorStop,在渐变路径相应的百分比位置(值域为[0.0, 1.0]),添加相应的颜色
  3. 将渐变色赋值于ctxfillStyle属性
  4. 填充图形

上面的代码,线性渐变的方向为从(0, 0)(200, 0)。渐变颜色,从位于0%位置的蓝色,渐变到位于100%位置的黄色。然后,将此渐变色填充0, 0, 200, 100的矩形。

标注渐变色的元素

function Point(x, y) { return {x:x, y:y}; } const gradentFromPos = Point(0, 0); const gradentToPos = Point(200, 0); const gradient = ctx.createLinearGradient(gradentFromPos.x, gradentFromPos.y, gradentToPos.x, gradentToPos.y); const colorStops = [ {pos: 0, color: 'red'}, {pos: 0.5, color: 'green'}, {pos: 1, color: 'blue'} ]; colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 200, 100); drawHelpers(); function drawHelpers() { colorStops.forEach(stop => { let pt = getPointOnLine(gradentFromPos, gradentToPos, stop.pos); plotPoint(pt, 5); }); } function getPointOnLine(srcPt, dstPt, offset) { return Point(srcPt.x + (dstPt.x - srcPt.x) * offset, srcPt.y + (dstPt.y - srcPt.y) * offset); } function plotPoint(point, r, strokeColor = 'gray') { ctx.beginPath(); ctx.arc(point.x, point.y, r, 0, Math.PI * 2); ctx.strokeStyle = strokeColor; ctx.stroke(); }

colorStops包装了在不同的位置应填充什么颜色:

const colorStops = [ {pos: 0, color: 'red'}, {pos: 0.5, color: 'green'}, {pos: 1, color: 'blue'} ];

这次,我们依序设置了红、绿、蓝三种填充颜色。因为中间色相会自动添加,因此虽然我们只指定了三种颜色,但色彩一下了丰富了许多。

为清晰地看到我们所显式指定的颜色,上面的代码还在各个渐变点以小圆圈进行了标注。

我们可以通过以hsl的方式来指定颜色,以方便均衡地添加渐变色:

const colorStops = [ {pos: 0, color: 'hsl(0, 100%, 50%)'}, {pos: 0.25, color: 'hsl(90, 100%, 50%)'}, {pos: 0.5, color: 'hsl(180, 100%, 50%)'}, {pos: 0.75, color: 'hsl(270, 100%, 50%)'}, {pos: 1, color: 'hsl(360, 100%, 50%)'} ];

效果如下:

function Point(x, y) { return {x:x, y:y}; } const gradentFromPos = Point(0, 0); const gradentToPos = Point(200, 0); const gradient = ctx.createLinearGradient(gradentFromPos.x, gradentFromPos.y, gradentToPos.x, gradentToPos.y); const colorStops = [ {pos: 0, color: 'hsl(0, 100%, 50%)'}, {pos: 0.25, color: 'hsl(90, 100%, 50%)'}, {pos: 0.5, color: 'hsl(180, 100%, 50%)'}, {pos: 0.75, color: 'hsl(270, 100%, 50%)'}, {pos: 1, color: 'hsl(360, 100%, 50%)'} ]; colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 200, 100); drawHelpers(); function drawHelpers() { colorStops.forEach(stop => { let pt = getPointOnLine(gradentFromPos, gradentToPos, stop.pos); plotPoint(pt, 5); }); } function getPointOnLine(srcPt, dstPt, offset) { return Point(srcPt.x + (dstPt.x - srcPt.x) * offset, srcPt.y + (dstPt.y - srcPt.y) * offset); } function plotPoint(point, r, strokeColor = 'gray') { ctx.beginPath(); ctx.arc(point.x, point.y, r, 0, Math.PI * 2); ctx.strokeStyle = strokeColor; ctx.stroke(); }

现在,色轮上整个色域中的颜色都在上面了。

addColorStop类似于动画中的添加关键帧,然后再自动生成插值。但关键渐变色应尽可能少取,太多了反倒不好看。

渐变色可脱离填充区域

上面的代码,渐变色的起点、终点,及其渐变方向,均与我们所要填充的区域完全一致。但渐变色可脱离填充区域。

只需重新定义渐变色的起点与终点,便可得到截然不同的效果:

const gradentFromPos = Point(0, 0); const gradentToPos = Point(200, 150);
function Point(x, y) { return {x:x, y:y}; } const gradentFromPos = Point(0, 0); const gradentToPos = Point(200, 150); const gradient = ctx.createLinearGradient(gradentFromPos.x, gradentFromPos.y, gradentToPos.x, gradentToPos.y); const colorStops = [ {pos: 0, color: 'hsl(0, 100%, 50%)'}, {pos: 0.25, color: 'hsl(90, 100%, 50%)'}, {pos: 0.5, color: 'hsl(180, 100%, 50%)'}, {pos: 0.75, color: 'hsl(270, 100%, 50%)'}, {pos: 1, color: 'hsl(360, 100%, 50%)'} ]; colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 200, 100); drawHelpers(); function drawHelpers() { drawLine(gradentFromPos, gradentToPos); colorStops.forEach(stop => { let pt = getPointOnLine(gradentFromPos, gradentToPos, stop.pos); plotPoint(pt, 5); }); } function getPointOnLine(srcPt, dstPt, offset) { return Point(srcPt.x + (dstPt.x - srcPt.x) * offset, srcPt.y + (dstPt.y - srcPt.y) * offset); } function plotPoint(point, r, strokeColor = 'gray') { ctx.beginPath(); ctx.arc(point.x, point.y, r, 0, Math.PI * 2); ctx.strokeStyle = strokeColor; ctx.stroke(); } function drawLine(pt1, pt2) { ctx.beginPath(); ctx.moveTo(pt1.x, pt1.y); ctx.lineTo(pt2.x, pt2.y); ctx.strokeStyle = 'gray'; ctx.stroke(); }

金属渐变色

金属的特点是有高光及阴影,下面通过渐变色来模拟出金属的特点。

function Point(x, y) { return {x:x, y:y}; } const gradentFromPos = Point(0, 0); const gradentToPos = Point(200, 0); const gradient = ctx.createLinearGradient(gradentFromPos.x, gradentFromPos.y, gradentToPos.x, gradentToPos.y); let hue = 30; let saturation = '35%'; const colorStops = [ {pos: 0, color: `hsl(${hue}, ${saturation}, 25%)`}, {pos: 0.3, color: `hsl(${hue}, ${saturation}, 65%)`}, {pos: 0.75, color: `hsl(${hue}, ${saturation}, 20%)`}, {pos: 0.9, color: `hsl(${hue}, ${saturation}, 15%)`}, {pos: 1, color: `hsl(${hue}, ${saturation}, 20%)`} ]; colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.fillStyle = gradient; ctx.fillRect(0, 0, 200, 100); drawHelpers(); function drawHelpers() { drawLine(gradentFromPos, gradentToPos); colorStops.forEach(stop => { let pt = getPointOnLine(gradentFromPos, gradentToPos, stop.pos); plotPoint(pt, 5); }); } function getPointOnLine(srcPt, dstPt, offset) { return Point(srcPt.x + (dstPt.x - srcPt.x) * offset, srcPt.y + (dstPt.y - srcPt.y) * offset); } function plotPoint(point, r, strokeColor = 'gray') { ctx.beginPath(); ctx.arc(point.x, point.y, r, 0, Math.PI * 2); ctx.strokeStyle = strokeColor; ctx.stroke(); } function drawLine(pt1, pt2) { ctx.beginPath(); ctx.moveTo(pt1.x, pt1.y); ctx.lineTo(pt2.x, pt2.y); ctx.strokeStyle = 'gray'; ctx.stroke(); }

共打了5个关键渐变色。第一个为正常的亮度25%,然后是高光65%,然后慢慢恢复到正常亮度值以下的20%,并准备开始进入阴影区。接着设置最黑的阴影区域15%。最后,恢复较正常的亮度20%,以模拟出受环境光影响的特点。这样,青铜的特点就出来了。

我们还通过hue来存储色相值,saturation来存储颜色饱和值,这样这两个值可以独立地变化了。试着改变这两值,看看可得到哪些渐变效果。

辐射渐变

基本用法

function Point(x, y) { return {x:x, y:y}; } let orgPoint = Point(100, 100); let innerR = 0; let outerR = 70; const gradient = ctx.createRadialGradient(orgPoint.x, orgPoint.y, innerR, orgPoint.x, orgPoint.y, outerR); const colorStops = [ {pos: 0, color: `red`}, {pos: 0.5, color: `green`}, {pos: 1, color: `blue`} ]; colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.beginPath(); ctx.arc(orgPoint.x, orgPoint.y, outerR, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill();

createRadialGradient用以实现辐射渐变,它接受6个参数,每三个参数定义了一个圆心坐标的x, y坐标及半径。

上面代码实现了在一个同心圆内从内到外的辐射渐变。

内圆半径可以为0,这样,便在在各个辐射方向上均匀地分布了3种颜色值。我们发现,中间的绿色的面积很小,明显形成了一个绿色的圆环。这是因为内圆与外圆半径的差值较小,没有较多的空间来展示平缓的颜色过渡。

一个发亮的黄色球体

既然空间有限,我们不宜添加大多的关键渐变值,我们这回只保留两个关键渐变值,看看效果。

function Point(x, y) { return {x:x, y:y}; } let orgPoint = Point(100, 100); let innerR = 0; let outerR = 70; const gradient = ctx.createRadialGradient(orgPoint.x, orgPoint.y, innerR, orgPoint.x, orgPoint.y, outerR); const colorStops = [ {pos: 0, color: `yellow`}, {pos: 1, color: `orange`} ]; colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.beginPath(); ctx.arc(orgPoint.x, orgPoint.y, outerR, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill();

很漂亮的一个发光的黄色球体便出来了。这是因为在色轮中,黄色与橙色属于相似色,两个比较接近的相似色参与辐射渐变,效果很不错。

调整内圆的圆心

既然空间有限,我们不宜添加太多的关键渐变值,我们这回只保留两个关键渐变值,看看效果。

function Point(x, y) { return {x:x, y:y}; } let orgPoint = Point(100, 100); let innerR = 0; let outerR = 70; const gradient = ctx.createRadialGradient(orgPoint.x + 30, orgPoint.y - 40, innerR, orgPoint.x, orgPoint.y, outerR); const colorStops = [ {pos: 0, color: `hsl(120, 20%, 80%)`}, {pos: 1, color: `hsl(150, 50%, 50%)`} ]; colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.beginPath(); ctx.arc(orgPoint.x, orgPoint.y, outerR, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill();

通过调整内圆的圆心,我们可以将高光位置移到其他地方。这里也是使用另一组相似色,同时降低高光的饱和度,并提升其亮度:

const colorStops = [ {pos: 0, color: `hsl(120, 20%, 80%)`}, // high lighting {pos: 1, color: `hsl(150, 50%, 50%)`} ];

非常完美的一个青色球体。

调整内圆的半径

如果内圆半径不为0,则内圆的整个范围均以第一个填充颜色来填充,然后,内圆之外的区域再参与渐变。利用这个特点,我们可以大幅地增加内圆半径,然后,再稍微调整关键渐变色,便可得到一个很漂亮的、晶莹剔透的按钮的效果。

function Point(x, y) { return {x:x, y:y}; } let orgPoint = Point(100, 100); let innerR = 50; let outerR = 70; const gradient = ctx.createRadialGradient(orgPoint.x, orgPoint.y, innerR, orgPoint.x, orgPoint.y, outerR); const colorStops = [ {pos: 0, color: `red`}, {pos: 0.5, color: `hsl(0, 20%, 50%)`}, {pos: 1, color: `gray`} ]; colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.beginPath(); ctx.arc(orgPoint.x, orgPoint.y, outerR, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill();

Conic渐变

基本用法

function Point(x, y) { return {x:x, y:y}; } let orgPoint = Point(100, 65); let radius = 65; let startAngle = 0; let startRadian = Math.PI / 180 * startAngle; const gradient = ctx.createConicGradient(startRadian, orgPoint.x, orgPoint.y); const colorStops = [ {pos: 0, color: 'red'}, {pos: 0.5, color: 'white'} ]; colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.beginPath(); ctx.arc(orgPoint.x, orgPoint.y, radius, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill();

Conic渐变在指定的坐标位置,从指定的角度(弧度制)开始,顺时针旋转进行渐变。上面开始角度为0o,位于笛卡尔坐标系X轴的正半轴上,且只在00.5的位置分别打上白色与红色这两个关键渐变色。

需注意,与前面两种渐变不同的是,addColorStop方法中用[0, 1]的小数点来表示位置的参数,在Conic渐变中不再是直线的百分比,而是圆周度数360o的百分比。因此,位于0.5的位置代表在圆周上180o的位置,其它位置依此类推。

上面的代码:

const colorStops = [ {pos: 0, color: 'red'}, {pos: 0.5, color: 'white'} ];

分别在0的位置打上红色,在0.5的位置打上白色这两个关键渐变色。观察图像,感觉有些怪异。在圆的下半部,从右边的红色顺时针渐变为左边的白色,没有问题。但圆的上半部,只有一种白色,并未从白色渐变为红色。

与上面两种渐变不同的是,Conic渐变从圆周上的0o渐变到360o,它是首尾相连的。或者说,它应从0o的红色渐变到180o的白色,再从180o的白色渐变到360o的红色。而上面的代码,在180o之后,我们并未在360o的位置再次打上红色,因此就造成圆的上半部分只有一种白色了。因此正确的代码应为:

const colorStops = [ {pos: 0, color: 'red'}, {pos: 0.5, color: 'white'}, {pos: 1, color: 'red'} ];

效果如下:

function Point(x, y) { return {x:x, y:y}; } let orgPoint = Point(100, 65); let radius = 65; let startAngle = 0; let startRadian = Math.PI / 180 * startAngle; const gradient = ctx.createConicGradient(startRadian, orgPoint.x, orgPoint.y); const colorStops = [ {pos: 0, color: 'red'}, {pos: 0.5, color: 'white'}, {pos: 1, color: 'red'} ]; colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.beginPath(); ctx.arc(orgPoint.x, orgPoint.y, radius, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill();

这回是真正的红白圆周渐变了。

总结一下:如果我们要实现首尾相连的圆周渐变效果,必须在第1帧及最后一帧的位置都打上相同的渐变色。

标准的色轮

function Point(x, y) { return {x:x, y:y}; } let orgPoint = Point(100, 65); let radius = 65; const COLOR_BLOCK_NUM = 3; let posStep = 1 / COLOR_BLOCK_NUM; let circleStep = 360 / COLOR_BLOCK_NUM; const gradient = ctx.createConicGradient(0, orgPoint.x, orgPoint.y); let colorStops = []; let currPos = 0; let currHue = 0; for (let i = 1; i <= COLOR_BLOCK_NUM; i++) { currPos = (i - 1) * posStep; currHue = (i - 1) * circleStep; colorStops.push({pos: currPos, color: `hsl(${currHue}, 100%, 50%)`}); } colorStops.push({pos: 1, color: `hsl(360, 100%, 50%)`}); colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.beginPath(); ctx.arc(orgPoint.x, orgPoint.y, radius, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill();

尝试将COLOR_BLOCK_NUM的值改为不同的值,并观察相应的效果。

我们发现,在能被360o整除的整数中,当COLOR_BLOCK_NUM指定为12时,即可显示出非常精细的渐变色。若再增大其值,例如改为24或更大,其效果与12完全一样。因此,COLOR_BLOCK_NUM的值最大可设为12即可。

如果我们希望将此色轮中间部分挖出一个圆洞来,则只需将同心圆的内圆与外圆分别按不同的旋转方向进行旋转后再设置裁剪就行了:

// setting clipping area ctx.beginPath(); ctx.arc(orgPoint.x, orgPoint.y, radius - 35, 0, Math.PI * 2, true); ctx.arc(orgPoint.x, orgPoint.y, radius, 0, Math.PI * 2, false); ctx.clip(); ctx.beginPath(); ctx.arc(orgPoint.x, orgPoint.y, radius, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill();

效果如下:

function Point(x, y) { return {x:x, y:y}; } let orgPoint = Point(100, 65); let radius = 65; const COLOR_BLOCK_NUM = 12; let posStep = 1 / COLOR_BLOCK_NUM; let circleStep = 360 / COLOR_BLOCK_NUM; const gradient = ctx.createConicGradient(0, orgPoint.x, orgPoint.y); let colorStops = []; let currPos = 0; let currHue = 0; for (let i = 1; i <= COLOR_BLOCK_NUM; i++) { currPos = (i - 1) * posStep; currHue = (i - 1) * circleStep; colorStops.push({pos: currPos, color: `hsl(${currHue}, 100%, 50%)`}); } colorStops.push({pos: 1, color: `hsl(360, 100%, 50%)`}); colorStops.forEach(stop => { gradient.addColorStop(stop.pos, stop.color); }); ctx.beginPath(); ctx.arc(orgPoint.x, orgPoint.y, radius - 35, 0, Math.PI * 2, true); ctx.arc(orgPoint.x, orgPoint.y, radius, 0, Math.PI * 2, false); ctx.clip(); ctx.beginPath(); ctx.arc(orgPoint.x, orgPoint.y, radius, 0, Math.PI * 2); ctx.fillStyle = gradient; ctx.fill();

参考资源

  1. HTML5 Canvas Element