1. 先从最常用的阴影四件套说起
1.1 shadowBlur:阴影的“扩散范围”到底是什么
很多人第一次在HTML5 Canvas里调阴影,都是照着网上的代码抄,抄完发现阴影要么没有、要么糊成一团。其实Canvas的阴影系统非常简单,核心就四个属性:shadowBlur、shadowColor、shadowOffsetX、shadowOffsetY。只要把这四个参数吃透,外阴影和内阴影基本就等于解决了一半。
shadowBlur这个属性特别容易误解。我最早以为它是阴影的“不透明度”或者“大小”,其实都不是。它的本质是高斯模糊的半径,单位是像素,数值越大,阴影边缘就越柔和、扩散范围越广。注意一个关键点:shadowBlur为0的时候,如果你同时没有设置偏移量,阴影几乎看不见,因为完全不模糊、完全不偏的阴影会被原始图形完全盖住。很多新手说“我怎么设置了阴影还是不显示”,十有八九就是栽在这里。
还有一个更隐蔽的坑:shadowBlur的值不是越大越好。数值设到50以上,在低分辨率的Canvas里很容易出现明显的颗粒感或边缘锯齿,尤其在文字或细线图形上特别明显。我一般会把shadowBlur控制在5到30之间,做光晕或者特殊效果时才会上到50以上。另外shadowBlur本质上是一个卷积模糊运算,值越大计算量越大,如果一帧里绘制了大量带阴影的图形,帧率会肉眼可见地往下掉,这块后面我会专门说。
1.2 偏移量、颜色与三者的配合
shadowOffsetX和shadowOffsetY控制阴影相对原始图形的位移。正负方向很好记:shadowOffsetX为正,阴影往右偏;为负往左偏。shadowOffsetY为正,阴影往下偏;为负往上偏。注意这里不是“光源方向”,而是“阴影方向”——阴影往哪个方向跑,光源就在相反方向。这个逻辑和很多图片处理软件里正好相反,我见过不少刚上手的人把光源方向当成偏移方向,结果做出来的投影怎么看怎么别扭。
shadowColor就是阴影的颜色,支持标准CSS颜色值,也支持带透明度的写法,比如rgba(0, 0, 0, 0.3)。这里有一个很多人忽略的细节:如果你不设置shadowColor,默认值是rgba(0,0,0,0),也就是完全透明的黑色,所以阴影一样看不见。很多“为什么阴影不显示”的问题,根源就在这儿。还有一点,shadowColor的透明度会影响整个阴影的浓淡,而不是模糊范围。想要阴影浅一点,可以直接调整透明度的alpha值,这比把shadowBlur调大更精准、更省性能。
说句题外话,有人拿富士相机拍照后抱怨“高光阴影是反着的”,其实和Canvas里的道理一样——你做后期调整时,高光和阴影的方向、范围如果和光源方向反了,画面就会透着一种说不出的“假”。Canvas的阴影偏移方向选错了,也会有类似的“光源错乱感”。所以调整阴影前,先想清楚你的假想光源是在左上还是右上,这个习惯能帮你省下大量微调时间。
2. 外阴影的核心玩法:把“图层投影”调出质感
2.1 用参数组合模拟常规控件投影
外阴影最常见的应用场景,就是给按钮、卡片、弹窗这些UI元素做“浮起来”的效果。用一个简单的圆形按钮来演示最直观。
const canvas = document.getElementById('myCanvas'); const ctx = canvas.getContext('2d'); // 开启外阴影 ctx.shadowOffsetX = 4; ctx.shadowOffsetY = 4; ctx.shadowBlur = 12; ctx.shadowColor = 'rgba(0, 0, 0, 0.3)'; // 绘制按钮底 ctx.fillStyle = '#4A90D9'; ctx.beginPath(); ctx.arc(120, 120, 50, 0, Math.PI * 2); ctx.fill(); // 绘制完主体,取消阴影 ctx.shadowColor = 'rgba(0, 0, 0, 0)';这段代码里的关键操作是:绘制完主体后立刻把shadowColor设回透明,否则后续所有绘制都会带上阴影。很多人习惯把阴影属性设置一次就放着不管,结果画第二张图、第三张图时阴影一直跟着,调了半天才发现是状态没重置。
针对不同需求,我整理了一套我实测过的参数组合,可以直接抄:
| 效果类型 | shadowOffsetX | shadowOffsetY | shadowBlur | shadowColor | 适用场景 |
|---|---|---|---|---|---|
| 轻投影 | 0 | 2 | 4 | rgba(0,0,0,0.15) | 卡片、小按钮 |
| 标准投影 | 3 | 5 | 10 | rgba(0,0,0,0.25) | 弹窗、浮层按钮 |
| 悬浮高投影 | 0 | 8 | 20 | rgba(0,0,0,0.3) | 强调视觉层级 |
| 发光效果 | 0 | 0 | 18 | rgba(0,200,255,0.6) | 霓虹、光晕、选中态 |
| 用户头像 | 2 | 2 | 6 | rgba(0,0,0,0.2) | 圆形头像、贴纸 |
这套组合的最大优点是:shadowBlur和偏移量保持在一个合理范围,既不会让阴影糊成一片,也不会生硬得像贴了层黑纸。你在实际项目里可以先把颜色透明度降到0.15试起,再逐步往上加,直到你觉得“看得出浮起来”但又“不脏”为止。
2.2 用软阴影给游戏场景加层次
在游戏开发里,Canvas阴影用得最多的其实是“软阴影”——也就是让物件的影子边缘不那么锐利,看起来更像真实光照下的影子。做过HTML5马里奥、格斗游戏这类项目的人应该深有体会:如果角色脚下没有阴影,跳跃落地会显得特别“飘”;如果阴影太硬,又像是踩在了一块黑纸上。
软阴影的实现方法,就是利用shadowBlur的大数值配合低透明度的shadowColor。比如一个格斗游戏的人物,我希望他在平台上有一个柔和的范围阴影,可以这样写:
// 绘制人物本体之前,先绘制一个软阴影 ctx.save(); ctx.shadowColor = 'rgba(0, 0, 0, 0.4)'; ctx.shadowBlur = 25; ctx.shadowOffsetX = 0; ctx.shadowOffsetY = 8; // 用椭圆近似绘制人物底部区域 ctx.fillStyle = '#333'; ctx.beginPath(); ctx.ellipse(player.x, ground.y, player.width * 0.6, 8, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore();这里我用的不是给角色本身加阴影,而是单独画一个椭圆阴影贴片。这样做的优势是可以在不增加主绘制负担的情况下,自由控制阴影的位置和形态。实际项目里我还会让这个椭圆的大小和透明度随着角色高度动态变化,跳得越高阴影越小、越淡,地面感一下就出来了。
还有一个性能相关的经验:不要在动画循环里直接修改shadowBlur,因为模糊算法的重算开销非常大。我在做一个HTML5小游戏时,一开始每帧都更新shadowBlur实现呼吸式光晕,结果真机测试只有20多帧。后面改成预渲染——先把带阴影的角色画到一个离屏Canvas上,每帧只是drawImage,帧率立刻回到60帧。
3. 内阴影的实现思路:三种方案对比
如果说外阴影是“原生的福利”,那么内阴影就是“自己动手的坑”。Canvas的原生阴影API只支持往外扩散,并不支持往内收缩,所以想要让图形内部出现阴影,必须另想办法。我整理过三种常用方案,各有优劣,直接对比着看。
3.1 方案一:离屏Canvas + globalCompositeOperation
这个方案的核心思路是“先画内部投影,再限制显示区域”。具体做法是:把带阴影的图形画到一个临时Canvas上,然后利用globalCompositeOperation的合成模式,只保留原图形区域内的内容,从而把“向外扩散的阴影”裁成“向内收的阴影”。
function drawInnerShadow(shapeDrawFn, radius, color) { // 创建一个离屏canvas const offCanvas = document.createElement('canvas'); offCanvas.width = canvas.width; offCanvas.height = canvas.height; const offCtx = offCanvas.getContext('2d'); // 第一步:在离屏canvas上画带阴影的形状 offCtx.shadowBlur = radius; offCtx.shadowColor = color; offCtx.shadowOffsetX = 0; offCtx.shadowOffsetY = 0; shapeDrawFn(offCtx); // 第二步:把阴影区域“向内裁切” offCtx.globalCompositeOperation = 'source-atop'; offCtx.shadowColor = 'rgba(0, 0, 0, 0)'; offCtx.shadowBlur = 0; shapeDrawFn(offCtx); // 第三步:把结果画回主canvas ctx.drawImage(offCanvas, 0, 0); }这段代码里最关键的是source-atop这个合成模式。它的作用是:新绘制的图形只出现在已有图形的区域内,区域外的部分全部丢弃。第一次调用shapeDrawFn时,阴影在形状外部扩散;第二次调用时,新的实心形状把阴影区域挤压到边缘内侧,同时外部的阴影被source-atop裁掉,最终视觉上就成了内阴影。
这个方案的好处是通用性强,圆形、矩形、任意路径都都能用,坏处是需要额外的Canvas,内存占用会翻倍。如果项目里画的东西不多,这个代价可以接受。
3.2 方案二:clip裁剪配合shadow绘制
第二个方案适合固定形状的场景,比如按钮内凹效果。思路更直接:先用clip()把绘图区域限制在形状内部,然后再画一个带阴影的偏移图形,由于绘制范围被裁剪,阴影只会在内部可见。
ctx.save(); // 先定义形状路径并裁剪 ctx.beginPath(); ctx.rect(50, 50, 200, 80); ctx.clip(); // 在裁剪区域内绘制带阴影的图形 ctx.shadowColor = 'rgba(0, 0, 0, 0.4)'; ctx.shadowBlur = 8; ctx.shadowOffsetY = -3; // 阴影向上偏移,形成凹陷顶部阴影 ctx.fillStyle = '#ddd'; ctx.fillRect(50, 50, 200, 80); ctx.restore();注意这里的shadowOffsetY设成了负数,意味着真正的阴影在形状上方。但因为clip()限制了可见范围,整个阴影只能出现在矩形内部,看起来就是顶部内凹的阴影。这个方法代码量最少,性能也比离屏Canvas方案高,但只适合形状简单、方向固定的场景。你要做任意角度的内阴影,这个方案就不太够用了。
3.3 方案三:不用阴影API的“硬调”做法
最后一种方案在某些场景下出奇地好用:直接用径向渐变或线性渐变来模拟内阴影。比如想让一个圆形变成“凹进去”的容器,用径向渐变就可以轻松实现:
const gradient = ctx.createRadialGradient(100, 100, 20, 100, 100, 60); gradient.addColorStop(0, 'rgba(0, 0, 0, 0)'); gradient.addColorStop(0.8, 'rgba(0, 0, 0, 0.05)'); gradient.addColorStop(1, 'rgba(0, 0, 0, 0.35)'); ctx.fillStyle = gradient; ctx.beginPath(); ctx.arc(100, 100, 60, 0, Math.PI * 2); ctx.fill();这样画出来的效果是一个边缘加深、中间通透的圆,视觉上就是很经典的“内凹容器”阴影。这种方式的优点是性能极高,不需要离屏Canvas,也不需要clip(),缺点是只能模拟出渐变的阴影形态,无法做任意形状的内阴影。但如果你做仪表盘、进度环、圆形头像框这类场景,它反而是最优解。
我个人的建议是:固定形状用方案三,任意形状用方案一,方案二作为前两者的补充。实际项目里通常不是只用一种,比如做一个仪表盘的凹槽时用渐变,做一个复杂徽章的内阴影时用离屏Canvas,两者配合能把效果和性能都照顾到。
4. 常见问题与排查技巧实录
4.1 我踩过的那几个坑
做Canvas阴影这几年,我在群里被问得最多的一个问题就是“为什么我的阴影看不见”。这个问题排查起来其实就三步:先看shadowColor是不是透明;再看shadowOffsetX/Y是不是都为0;最后看shadowBlur是不是太小。三个默认值合在一起就是“没有阴影”,所以很多初学的代码看着逻辑没问题,结果就是不显示。
第二个常见问题是阴影“叠得乱七八糟”。这通常是因为没有及时重置阴影状态。Canvas的上下文中,阴影属性是“粘性”的——一旦设置了,后面所有绘制都会继承,直到你改掉或save/restore。我的习惯是:所有带阴影的绘制都写成独立函数,函数开头ctx.save(),结尾ctx.restore(),中间随意折腾,外面绝对不受影响。
第三个坑比较隐蔽:在高DPI屏幕上,Canvas阴影会出现明显的模糊或锯齿。这其实不是阴影代码的问题,而是Canvas本身的像素密度没适配。解决办法是设置canvas宽高为CSS尺寸乘以window.devicePixelRatio,再通过ctx.scale(dpr, dpr)来绘制。我有一段时间没有做适配,阴影在Retina屏上比普通屏糊了一大截,排查半天才发现是这一步漏了。
第四个坑是iOS Safari上的兼容问题。某些iOS版本对shadowBlur和globalCompositeOperation的组合处理不一致,离屏Canvas方案在Android正常,在iPhone上却可能出现阴影错位。我的兼容做法是:先检测ctx.filter是否支持,如果支持就优先用ctx.filter = 'drop-shadow(...)'来实现部分常见阴影效果,不支持则退回离屏Canvas方案。实测下来兼容性稳定很多。
4.2 常见问题速查表
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 阴影完全看不到 | shadowColor默认透明或offset为0、blur过小 | 设置shadowColor为rgba(0,0,0,0.3),offset至少为1,或者blur调到5以上 |
| 阴影很脏、边缘颗粒感重 | shadowBlur过大或Canvas分辨率不足 | 降低shadowBlur,或按devicePixelRatio放大Canvas后scale |
| 后续绘制的图形都带阴影 | 没有重置shadowColor/blur | 绘制完成后设置shadowColor为rgba(0,0,0,0),或者使用save/restore |
| 内阴影裁切不对,外部也有阴影 | globalCompositeOperation设置位置错误 | 确认在绘制“内部覆盖形状”之前设置为source-atop |
| 动画帧率骤降 | 每帧频繁修改shadowBlur或绘制大量带阴影图形 | 用离屏Canvas预渲染,把带阴影图形缓存为图像 |
| iOS上阴影位置偏移 | 部分iOS版本合成模式兼容性问题 | 使用ctx.filter的drop-shadow做降级方案 |
| 阴影方向与光源相反 | offset方向理解反了 | 阴影往哪偏,光源就在相反方向;想清楚假想光源位置再设offset |
再说一个关于“软阴影”和“硬阴影”的选择技巧:做UI时阴影偏硬、偏移量稍大,界面会更利落;做游戏场景或3D感强的插画时,阴影偏软、透明度偏低,层次会更自然。不要一套参数走天下,我见过很多网页设计作业里用了过大的shadowBlur,把整个按钮搞成一团模糊,反而失去了投影的意义。
最后分享一个我常用的调试小技巧:给阴影单独加一层可视化开关。开发时在页面上放一个checkbox,勾选后把所有shadowColor临时改成半透明的红色。这样能一目了然地看到每个图形的阴影范围,排查“阴影叠在一起发暗”“阴影覆盖了不该盖的区域”这种问题非常高效。等调试完,再把颜色改回真实的黑色或深灰色。
Canvas的阴影系统就是这么一套东西,没什么高深的理论,关键是把四个参数理解透,再根据场景选择合适的内阴影方案。只要动手试上几次,相信你也会发现它其实比CSS的box-shadow更自由、更可控。