22 mins
4478 words
Loading views
Canvas 2D 深度解读:fishwallpaper 的程序化绘制与 2D 降级渲染(可复用技巧手册)

深入拆解 fishwallpaper 的全部 Canvas 2D 代码:同轴双渲染器的降级设计、drawImage 切片条带让鱼弯起来、白模染色与负 scale 镜像、逐像素四层缓冲的程序化贴图、四种合成模式的实际用法、精灵元数据约定与三层缓存纪律,最后给出 12 条可直接复用的速查清单。

上一篇《把一方池塘装进四千行代码》拆解了 fishwallpaper 的整体架构,渲染部分着墨在 WebGL2 管线上。但这个项目里真正的 Canvas 2D 功夫藏在另一处:当浏览器不支持 WebGL2 时,整个池塘要靠一套 2D 渲染器继续跑;七种锦鲤、乌龟、蝴蝶、螃蟹的贴图,全部是用 Canvas 2D 代码逐像素画出来的——没有任何图片素材。本文聚焦这条 2D 线路,把它整理成一份以后写项目时可以随手翻的参考手册。本文封面就是最好的证明:它是我调用项目自己的绘制函数、在浏览器里现场画出来的。


📑 目录h2


一、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

  1. 降级渲染器同构接口:WebGL 与 2D 两后端共用一份小 API,业务层零感知;
  2. 图层队列:绘制指令先入队、按层级统一消费,渲染顺序成为数据而非调用顺序;
  3. 切片条带:drawImage 九参形式切源图 + 逐段 translate/rotate,画一切”沿路径弯的东西”,段间补缝用亚像素外扩;
  4. 白模 + 染色:部件画一次白色版,source-atop 填色染色,按色缓存;
  5. destination-out 雕刻:先铺膜再擦缝,透明度层次比逐根画更自然;
  6. 负 scale 镜像:scale(-s, s) 配合旋转,一张图画左右两侧;
  7. 解析轮廓函数:把形体(如 halfWidth)写成纯函数,贴图、部件挂点、受光三处共享同一份真值;
  8. 逐像素四层缓冲:albedo / shade / spec / 形状层分开算、手动 alpha-over 合成,Canvas 里的”延迟渲染”;
  9. 场函数花纹:fbm 减阈值出斑块,把场平移一点做差分就能得到”硬边扇贝化”的鳞缘效果;
  10. Voronoi 距离差:d2 - d1 一招画盾片缝、焦散光网、细胞纹理,CPU GPU 通吃;
  11. 缓存纪律:语义完整键 + 上游贵缓存久 + out 复用画布 + willReadFrequently + 热路径零分配;
  12. 2D 烘焙数据纹理:色块即数据,Canvas 画遮罩图喂 GPU,一个函数驱动多条渲染特性。

十一、结语h2

拆完这条 2D 线路,我最大的感受是:Canvas 2D 常被认为”性能不行、只能画图表”,但 fishwallpaper 展示了它的两个高端玩法——当软件渲染器(逐像素算出以假乱真的锦鲤)和当 GPU 的数据烘焙器(一张 2D 色块图驱动整套水面交互)。配合切片条带、白模染色这些经典技巧,“不支持 WebGL”不再是功能悬崖,只是一次和平的降级。下一篇再写图形学时,我会把这套 2D 武器直接带进自己的项目里。

最后再看一眼本文的封面:八条鱼、一只龟、一只蝶、一只蜻蜓、一只螃蟹,全部出自项目代码在我浏览器里的一次现场绘制——这就是”零素材”四个字的含金量。

参考与引用h2

  1. 本文的姊妹篇:把一方池塘装进四千行代码:fishwallpaper 技术方案拆解
  2. moli-xia, fishwallpaper(碧池观鱼), GitHub. github.com/moli-xia/fishwallpaper
  3. 本文代码节选出自该项目 art.js(程序化贴图)、scene.js(drawKoi2D 与浮水物遮罩)、gl.js(create2D 与 drawStrip2D),引用仅作技术评述。
  4. MDN Web Docs, Canvas API / ImageData / 全局合成模式: developer.mozilla.org

Author: 夜猫子Ai手记
Post: Canvas 2D 深度解读:fishwallpaper 的程序化绘制与 2D 降级渲染(可复用技巧手册)

Comments