上一篇《把一方池塘装进四千行代码》拆解了 fishwallpaper 的整体架构,渲染部分着墨在 WebGL2 管线上。但这个项目里真正的 Canvas 2D 功夫藏在另一处:当浏览器不支持 WebGL2 时,整个池塘要靠一套 2D 渲染器继续跑;七种锦鲤、乌龟、蝴蝶、螃蟹的贴图,全部是用 Canvas 2D 代码逐像素画出来的——没有任何图片素材。本文聚焦这条 2D 线路,把它整理成一份以后写项目时可以随手翻的参考手册。本文封面就是最好的证明:它是我调用项目自己的绘制函数、在浏览器里现场画出来的。
📑 目录h2
- 一、Canvas 2D 在项目里的三个角色
- 二、同轴双渲染器:降级不换接口
- 三、让鱼弯起来:切片绘制
- 四、一套鳍画八条鱼:白模染色与镜像
- 五、把画布当软件渲染器:逐像素程序化贴图
- 六、四种合成模式就是四件武器
- 七、精灵元数据约定与通用绘制函数
- 八、缓存与性能纪律
- 九、给 GPU 打工:2D 画布烘焙数据纹理
- 十、可复用清单:十二条速查
- 十一、结语
- 参考与引用
一、Canvas 2D 在项目里的三个角色h2
| 角色 | 所在文件 | 产出 |
|---|---|---|
| ① WebGL 的降级渲染器 | gl.js 的 create2D | 无 WebGL2 时用 2D 画布继续渲染整个池塘 |
| ② 程序化贴图绘制 | art.js | 锦鲤(逐片鳞)、龟、蝶、蜻蜓、螃蟹、道具的全部贴图 |
| ③ UI 与预览绘制 | scene.js / app.js | 对话框里的锦鲤预览、列表缩略图、名字标签层 |
一个有意思的事实:整个项目用到的 Canvas 合成模式只有四种——source-over、source-atop、destination-out、lighter。后面会看到每种都用在刀刃上。
二、同轴双渲染器:降级不换接口h2
gl.js 入口只有十几行:先试 WebGL2,失败就退回 2D,两个后端暴露完全相同的 API:
function createRenderer(canvas, bedImage, sprites, onLost, floatMask) { try { const r = createGL(canvas, bedImage, sprites, onLost, floatMask); if (r) return Object.assign(r, { canvas }); } catch (e) { console.warn('...using 2D', e); } let target = canvas, r = create2D(target, bedImage, sprites); if (!r) { target = canvas.cloneNode(); canvas.replaceWith(target); r = create2D(target, bedImage, sprites); } return r && Object.assign(r, { canvas: target });}上层(scene.js、app.js)调用 renderer.sprite(...)、renderer.fish(...) 时根本不知道自己在跟谁说话。2D 后端不是缩水版重写,而是一套策略替换:
sprite()/fish()不再喂 GPU,而是把绘制指令推入图层队列;floater()是空函数——浮水荷叶本来就从底图 CSS 背景里露出来,2D 模式让它们静止即可;- 阴影图层(
shadow、airShadow)整体跳过,2D 模式接受”没有影子”; drop()(涟漪)退化成描边圆环扩张动画。
图层队列是 2D 后端的骨架,7 个数组对应 7 个绘制层级,渲染时按序消费:
const LAYERS = ['shadow', 'under', 'floaters', 'surface', 'airShadow', 'air', 'glow'];// sprite() 只是把记录入队,并不真正绘制sprite(layer, def, x, y, angle, sx, sy, color) { queue[layer].push(['s', def, x, y, angle, sx, sy, color[3]]); },render(time, dt) { for (const l of LAYERS) { ctx.globalCompositeOperation = l === 'glow' ? 'lighter' : 'source-over'; for (const q of queue[l]) { /* save / translate / rotate / drawImage / restore */ } queue[l].length = 0; // 消费即清空 }}可复用点:做任何”多后端”渲染(WebGL / 2D / SVG / 服务端导出),核心不是两个实现,而是一份足够小、能力对齐的接口。每一级降级都是”效果减半、接口不变”,业务层零分支。
三、让鱼弯起来:切片绘制h2
锦鲤贴图是一张水平直图(鱼头朝右)。WebGL 路径把它作为纹理贴在脊柱三角条带上;2D 路径没有纹理坐标,怎么让直图贴着弯曲的脊柱游动?答案是按脊柱段切片:16 段脊柱,每段从贴图上裁下对应的一竖条,独立旋转后画到位。
function drawStrip2D(ctx, img, pose, s, cx, cy, k) { const n = BODY.segments, hh = BODY.half * s * k, ppu = img.width / BODY.width; for (let i = 0; i < n; i++) { const ax = ..., ay = ..., bx = ..., by = ...; // 第 i 段两端(脊柱点 i 到 i+1),绕(cx,cy)按 k 缩放 const len = Math.hypot(bx - ax, by - ay); // 这一段在屏幕上的实际长度 const sx = (BODY.nose - (i + 1) * SEG_UNITS - BODY.left) * ppu; // 源图上这一条的左边缘 const head = i === 0 ? 2 : 0; ctx.save(); ctx.translate(bx, by); // 定位到段的尾端 ctx.rotate(Math.atan2(ay - by, ax - bx)); // 转向头的方向 ctx.drawImage(img, sx, 0, (SEG_UNITS + head) * ppu, img.height, 0, -hh, len + head * s * k + .6, hh * 2); // 拉伸到段长 if (i === n - 1) ctx.drawImage(img, 0, 0, 2 * ppu, img.height, -2 * s * k, -hh, 2 * s * k + .4, hh * 2); // 尾尖单独封口 ctx.restore(); }}几个值得咀嚼的细节:
- 源矩形与目标矩形各取所需:源图按身体坐标(
nose - i*SEG)等分,目标长度取两脊柱点的实际距离——身体弯曲时各段被自然拉伸压缩; - 旋转方向是”从尾端指向头端”(
atan2(ay-by, ax-bx)),因为源条带的尾侧边缘对齐段的尾端; - 第一段多裁 2 个单位(
head = 2):贴图在鱼吻之外还留了 2 个单位的圆润轮廓,目标长度同步加长把它画出来,否则吻部会平掉; - 最后一段之后单独画尾尖:源图最左侧 2 个单位是脊柱终点之外的尾巴,从段尾向后补一刀(目标 x 为负);
.6和.4的补缝:相邻两段各自旋转,折点处会有亚像素缝隙,长度上多画一点点正好盖住。
同一条鱼、同一张贴图,只改变行波相位,就能得到完整的游动周期:
可复用点:这是 2D 画布上画”会弯的东西”的通用解法——蛇、龙、绳子、藤蔓、触手、坦克履带,一切”沿路径的纹理”都是这一招:切源图 + 逐段变换。段数是画质与开销的旋钮。
四、一套鳍画八条鱼:白模染色与镜像h2
七种锦鲤加青鲢,每种鳍的颜色都不同,但鳍只画了一次。finSprite 画一张纯白半透明的鳍(Path2D 贝塞尔勾形,线性渐变从根部不透明到梢部半透明),然后用 destination-out 雕刻:
ctx.globalCompositeOperation = 'destination-out'; // 擦除for (let k = 0; k < 11; k++) { // 11 刀擦出鳍条之间的缝 ctx.beginPath(); ctx.moveTo(.5, 0); ctx.lineTo(Math.cos(a) * 19, Math.sin(a) * 19); ctx.strokeStyle = 'rgba(0,0,0,.2)'; ctx.stroke();}先画整片鳍膜,再用半透明笔”擦”出射线状的缝——比一根根画鳍条自然得多,因为擦除保留了膜的透明度层次。之后按调色板染色:
function tintedFin(def, color, solid = false) { const key = def.canvas.width + color + solid; if (!tintedFins.has(key)) { const c = document.createElement('canvas'); c.width = def.canvas.width; c.height = def.canvas.height; const x = c.getContext('2d'); x.drawImage(def.canvas, 0, 0); x.globalCompositeOperation = 'source-atop'; // 只染进鳍的 alpha,背景保持透明 x.globalAlpha = solid ? 1 : .75; x.fillStyle = color; x.fillRect(0, 0, c.width, c.height); tintedFins.set(key, c); } return tintedFins.get(key);}source-atop 在这里就是染色的万能钥匙:新像素只落在已有像素上,白色鳍瞬间变成任何花色的鳍,边缘 alpha 完整保留,结果按颜色缓存。
鳍怎么挂到鱼身上?drawKoi2D 用了两个巧妙的量:鳍根位置取 halfWidth()(身体轮廓函数)定出体侧的半宽点;鳍的基准朝向从脊柱法线算出(atan2(-nx, ny)),再按呼吸张角偏转。左右两侧的鳍不是两张图,而是同一张用负 scale 镜像:
ctx.save();ctx.translate(px + nx * hw * side, py + ny * hw * side); // 鳍根:脊柱点 + 法线方向 × 半宽ctx.rotate(heading + side * (Math.PI - spread)); // 基准朝向 ± 张角ctx.scale(s * .85 * size / finDef.ppu, side * s * .85 * size / finDef.ppu); // side 为 -1 即垂直镜像ctx.drawImage(fin, -finDef.px * fin.width, -finDef.py * fin.height);ctx.restore();负 scale 镜像比 drawImage 的负宽高翻转更通用:它能与任意旋转、缩放自由组合,是 2D 精灵镜像的标准姿势。最后注意绘制顺序——先画两对鳍,再画身体条带,鳍根自然被身体盖住,接缝消失。
可复用点:部件复用 = 白模一次 + source-atop 染色 + 按色缓存;镜像永远用负 scale;遮挡关系靠绘制顺序而不是裁剪。
五、把画布当软件渲染器:逐像素程序化贴图h2
art.js 最硬核的部分:锦鲤贴图不是”画”出来的,是算出来的。fishLayers 为每条鱼分配四层像素缓冲——albedo(ImageData)、shade、spec(两个 Float32Array)、fin(ImageData)——然后双重循环扫过每一像素:
- 圆柱光照:
nz = √(1 - v²)(v 是身体横向归一坐标)当法线朝向,算出明暗shade与高光spec; - 花纹:
patternFor返回一个有符号距离式的场函数,红、黑两个通道,fbm 噪声减阈值得到斑块; - 鳞片:
scaleAt在错位晶格上找”覆盖此点、且最靠鱼头方向的那一片”,输出到共享的SC对象(避免每像素分配对象); - 每片鳞三个结构:前缘压进前一片鳞影里的鳞袋(pocket)、游离缘内侧一条受光亮带、边缘一道细鳞缘线——全是围绕
SC.dx和SC.r的 smoothstep 区间。
然后 fishSprite 把四层手动 alpha-over 合成成一个 ImageData:
for (let i = 0, j = 0; i < W * H; i++, j += 4) { const ba = alb[j + 3] / 255, fa = fin[j + 3] / 255, a = ba + fa * (1 - ba); if (a <= 0) continue; const k = fa * (1 - ba); // fin 在身体之上的覆盖量 o[j] = (Math.min(255, alb[j] * sh + sp) * ba + fin[j] * k) / a; o[j + 3] = a * 255;}这就是”2D 画布里的延迟渲染”:颜色、明暗、高光分开算,最后一次性合成。为什么不用路径和渐变硬画?因为鳞片的错落、斑纹的渗透这类有机质感,逐像素数学远比矢量工具直接;而眼睛、鳃盖线这类清晰几何,收尾时再回到路径:
ctx.save(); ctx.scale(ppu, ppu); ctx.translate(-BODY.left, BODY.half); // 换算回身体单位ctx.globalCompositeOperation = 'source-atop'; // 细节只许画在身体内// 鳃盖弧线 quadraticCurveTo、鼻孔小椭圆、头部径向渐变高光……const ring = ctx.createRadialGradient(ex, ey, .15, ex, ey, 1.45); // 眼睛:五段渐变ring.addColorStop(0, '#0f1413'); ring.addColorStop(.62, '#b8aa7e');ring.addColorStop(.85, 'rgba(120,110,90,.35)'); ring.addColorStop(1, 'rgba(120,110,90,0)');龟壳走的也是同一条逐像素路线:十三个盾片中心做 Voronoi——到最近与次近中心的距离差 d2 - d1 就是盾片缝,缝上叠 sin 生长环、放射纹,边缘用椭圆极坐标加锯齿。和上一篇 GPU 焦散的 Voronoi 边缘是同一个数学,一个跑在 GPU,一个跑在 CPU。龟的四肢则回到矢量路线:路径填充 + clip 内画条纹 + 一层”上暗下亮中间亮”的线性渐变蒙版,两笔就有圆柱体积感。
可复用点:Canvas 2D 有两副面孔——矢量绘图 API 和像素缓冲 API。硬表面几何用前者,有机纹理用后者;ImageData 手动合成给了你着色器级别的控制力,代价只是几行 alpha 混合算术。
六、四种合成模式就是四件武器h2
全项目用到的合成模式与其用途:
| 模式 | 语义 | 项目中的实例 |
|---|---|---|
source-over | 直接叠加 | 所有常规绘制 |
source-atop | 只画在已有像素上 | 手绘花纹印上鱼身、鱼眼鳃盖限定体内、白鳍染色 |
destination-out | 擦除已有像素 | 鳍膜上雕刻鳍条缝隙 |
lighter | 加法发光 | 2D 后端的萤火虫 glow 层 |
尤其 source-atop 值得记住:任何”把 A 印到 B 上且不出界”的需求(贴纸、纹身、染色、局部滤镜),它一行搞定,免维护裁剪路径。
七、精灵元数据约定与通用绘制函数h2
项目里所有小精灵(鳍、龟壳、蝶翅、蟹螯、鱼食、雪花)都遵守同一个数据结构约定:
{ canvas, ppu, px, py }ppu:画布分辨率与逻辑单位的比(绘制时用 4~6 倍超采样,运行时缩回);px, py:锚点在图内的比例位置(鳍根 0/21、龟壳中心 0.5/0.5、雨柱底端 1)。
任何消费方都能用同一个四行函数正确放置它们:
function put(def, x, y, ang, sx, sy) { ctx.save(); ctx.translate(x, y); ctx.rotate(ang); ctx.scale(sx / def.ppu, sy / def.ppu); // 别忘了除以 ppu! ctx.drawImage(def.canvas, -def.px * def.canvas.width, -def.py * def.canvas.height); ctx.restore();}一个现身说法的教训:本文的标本图鉴第一版里乌龟肥成了小山——因为我第一次写这个函数时忘了除以 ppu,精灵被放大了四倍。元数据约定的意义就在于此:锚点和密度是数据的一部分,调用方永远不用猜。
八、缓存与性能纪律h2
程序化绘制很贵(一张锦鲤贴图要扫近 27 万像素),项目的缓存纪律值得整段抄走:
- 三层缓存:
fishLayers(像素层,上限 90,FIFO 淘汰)→spriteCache(合成后的精灵画布,LRU 120)→tintedFins(染过色的鳍,按色缓存)。越靠上游越贵,缓存越久; - 缓存键是完整语义:
品种:调色板:种子:花纹哈希:ppu——任何输入变了都自动重算,任何输入不变都必然命中; - 画布复用:
fishSprite(f, ppu, out)接受out画布,尺寸匹配就不新建——预览面板每帧重绘时零分配; willReadFrequently: true:需要getImageData的画布声明这个提示,Chrome 会把画布留在 CPU 侧,读写快一个量级;- 热路径零分配:
scaleAt把结果写进模块级共享的SC对象,fishPose复用调用方传入的Float32Array——每帧 60 次调用的函数里连一个对象都不新建; putImageData的特殊性:它不受变换、合成模式、globalAlpha 影响,是纯粹的”整面替换”——项目只在”把算好的像素铺到画布”这个语义上用它,其余全走 drawImage;- 2D 后端把 DPR 压到 1.5:降级模式本来就在弱设备上跑,清晰度让位于帧率。
九、给 GPU 打工:2D 画布烘焙数据纹理h2
scene.js 里还有一个容易被忽略的 2D 用法:floatMask 用 Canvas 2D 画了一张给 WebGL 用的数据图。它把底图上所有浮水物(荷叶、花、石头)画成半分辨率的色块,颜色就是数据——红通道表示浮于水面、绿通道表示向池底投影、蓝通道表示贴水会产生湿线:
ctx.fillStyle = kind === 'r' ? 'rgb(215,0,215)' // 石头:红+蓝 : stalk ? 'rgb(255,255,0)' // 茎上的花:红+绿,不产湿线 : 'rgb(255,255,255)'; // 贴水荷叶:三通道全开ctx.beginPath(); ctx.ellipse(x * k, y * k, rx * g * k, ry * g * k, rot, 0, TAU); ctx.fill();这张 canvas 被上传为 WebGL 纹理后,同时驱动三件事:涟漪遇到这些区域被清零反弹(红)、池底接住它们的影子(绿)、水线处出现一道湿痕(蓝)。花苞画之前还偷偷放大 1.1 倍,把尖角也包进遮罩。
可复用点:Canvas 2D 是最顺手的”数据烘焙工具”——遮挡图、高度图、流程图、碰撞图,先用 2D API 画舒服了,再作为纹理喂给 GPU 管线,比手写顶点或程序化生成省一个数量级的代码。
十、可复用清单:十二条速查h2
- 降级渲染器同构接口:WebGL 与 2D 两后端共用一份小 API,业务层零感知;
- 图层队列:绘制指令先入队、按层级统一消费,渲染顺序成为数据而非调用顺序;
- 切片条带:
drawImage九参形式切源图 + 逐段translate/rotate,画一切”沿路径弯的东西”,段间补缝用亚像素外扩; - 白模 + 染色:部件画一次白色版,
source-atop填色染色,按色缓存; destination-out雕刻:先铺膜再擦缝,透明度层次比逐根画更自然;- 负 scale 镜像:
scale(-s, s)配合旋转,一张图画左右两侧; - 解析轮廓函数:把形体(如
halfWidth)写成纯函数,贴图、部件挂点、受光三处共享同一份真值; - 逐像素四层缓冲:albedo / shade / spec / 形状层分开算、手动 alpha-over 合成,Canvas 里的”延迟渲染”;
- 场函数花纹:fbm 减阈值出斑块,把场平移一点做差分就能得到”硬边扇贝化”的鳞缘效果;
- Voronoi 距离差:
d2 - d1一招画盾片缝、焦散光网、细胞纹理,CPU GPU 通吃; - 缓存纪律:语义完整键 + 上游贵缓存久 +
out复用画布 +willReadFrequently+ 热路径零分配; - 2D 烘焙数据纹理:色块即数据,Canvas 画遮罩图喂 GPU,一个函数驱动多条渲染特性。
十一、结语h2
拆完这条 2D 线路,我最大的感受是:Canvas 2D 常被认为”性能不行、只能画图表”,但 fishwallpaper 展示了它的两个高端玩法——当软件渲染器(逐像素算出以假乱真的锦鲤)和当 GPU 的数据烘焙器(一张 2D 色块图驱动整套水面交互)。配合切片条带、白模染色这些经典技巧,“不支持 WebGL”不再是功能悬崖,只是一次和平的降级。下一篇再写图形学时,我会把这套 2D 武器直接带进自己的项目里。
最后再看一眼本文的封面:八条鱼、一只龟、一只蝶、一只蜻蜓、一只螃蟹,全部出自项目代码在我浏览器里的一次现场绘制——这就是”零素材”四个字的含金量。
参考与引用h2
- 本文的姊妹篇:把一方池塘装进四千行代码:fishwallpaper 技术方案拆解
- moli-xia, fishwallpaper(碧池观鱼), GitHub. github.com/moli-xia/fishwallpaper
- 本文代码节选出自该项目
art.js(程序化贴图)、scene.js(drawKoi2D 与浮水物遮罩)、gl.js(create2D 与 drawStrip2D),引用仅作技术评述。 - MDN Web Docs, Canvas API / ImageData / 全局合成模式: developer.mozilla.org
Comments