渐变Pattern线型与滤镜
纯色 fillStyle 只覆盖展示型 UI。生产里真正会卡、会脏、会对不齐的是 渐变坐标系、pattern 重复、虚线相位、以及 filter 的中间缓冲。
1. 渐变活在用户空间,跟着 CTM 走
createLinearGradient(x0,y0,x1,y1) 的坐标是 创建时的用户空间数字,不是「物体局部永远钉死」。创建后你再 scale/rotate,渐变的端点会跟着当前 CTM 变。
因此不要在节点局部坐标里创建一次渐变、然后换 CTM 去 fill——端点会漂。正确做法:
export function fillWithLocalGradient(
ctx: CanvasRenderingContext2D,
path: Path2D,
world: DOMMatrix,
from: DOMPointInit,
to: DOMPointInit,
stops: readonly { offset: number; color: string }[],
): void {
ctx.save();
ctx.setTransform(world);
const gradient = ctx.createLinearGradient(
from.x ?? 0,
from.y ?? 0,
to.x ?? 0,
to.y ?? 0,
);
for (const stop of stops) {
gradient.addColorStop(stop.offset, stop.color);
}
ctx.fillStyle = gradient;
ctx.fill(path);
ctx.restore();
}
createRadialGradient / createConicGradient 同理:在 即将 fill 的那个 CTM 下创建。
addColorStop 的 offset 必须 ∈ [0,1] 且单调,否则抛 IndexSizeError。动态主题不要用字符串拼 offset。
不适用:每帧对一万个节点各 createLinearGradient。渐变对象本身可缓存,但 只在 CTM 与端点不变时。相机每帧在变,视口级背景渐变按 CSS 像素建,不要按世界坐标建。
2. Pattern:重复单元和变换是两套
export function createWorldPattern(
ctx: CanvasRenderingContext2D,
bitmap: ImageBitmap,
repetition: "repeat" | "repeat-x" | "repeat-y" | "no-repeat",
patternLocal: DOMMatrix,
): CanvasPattern {
const pattern = ctx.createPattern(bitmap, repetition);
if (!pattern) {
throw new Error("createPattern failed");
}
pattern.setTransform(patternLocal);
return pattern;
}
pattern.setTransform 作用于 pattern 空间 → 用户空间。它 不 替代 CTM。常见错误:地板砖要铺世界坐标,却把相机 scale 乘进 pattern 矩阵,一缩放就砖块跟着「另算一遍」。
约定:
- pattern 矩阵只描述「一块砖在节点局部里多大」
- 相机 / 节点 world 只走
ctx.setTransform no-repeat的图如果要比源图小,用drawImage而不是 pattern
跨源脏图创建 pattern 仍会 taint。见 污染 CORS。
3. 线型:线宽在用户空间,虚线相位会爬
lineWidth、lineDash、lineDashOffset 都在 用户空间。scale(2) 后 1px 线变成 2 设备像素粗(再乘 DPR)。想要「屏幕上恒定 1 物理像素」的网格线,线宽应设为 1 / camera.scale,并在 backing 像素中心对齐,见 DPR。
行军蚁选区:
export function strokeMarchingAnts(
ctx: CanvasRenderingContext2D,
path: Path2D,
nowMs: number,
): void {
const offset = (nowMs / 80) % 16;
ctx.save();
ctx.setLineDash([6, 4]);
ctx.lineDashOffset = -offset;
ctx.strokeStyle = "#111";
ctx.lineWidth = 1;
ctx.stroke(path);
ctx.strokeStyle = "#fff";
ctx.lineDashOffset = -offset + 5;
ctx.stroke(path);
ctx.restore();
}
setLineDash([]) 才能清虚线。漏清会让下一条「实线」变成虚线。走 withState。
lineJoin: "miter" + 尖角 + 大 miterLimit 会产生极长尖刺,命中用 isPointInStroke 时尖刺也算。UI 描边优先 bevel / round。
4. filter:整次绘制的离屏缓冲
ctx.filter = "blur(8px)" 不是 CSS 滤镜那种子树合成。它会为 这次 fill/drawImage 建中间位图。blur 半径按用户空间,再被 CTM 放大——相机 4x 时 blur(8px) 实际打在巨大缓冲上,移动端直接卡死。
| 做法 | 适用 | 不适用 |
|---|---|---|
静态阴影预烘焙成 ImageBitmap | 图标、卡片、戳印 | 每帧形状都变 |
动态 shadowBlur | 单次高亮、小面积 | 全场景投影 |
ctx.filter 链 | 导出时离线处理 | 交互热路径 |
工作 canvas + willReadFrequently 自己卷积 | 要读像素的滤镜编辑器 | 纯展示动画 |
shadow* 和 filter 叠加时成本接近相乘。生产里二选一。
5. 失败形态
| 症状 | 原因 |
|---|---|
| 渐变跟着相机「滑」 | 在错误 CTM 下创建 gradient |
| 砖块缩放双重缩放 | pattern 矩阵又乘了 camera.scale |
| 实线变虚线 | setLineDash 未 restore |
| 一开模糊就掉到 20fps | blur 半径 × 相机缩放 × 全屏 |
addColorStop 抛错 | offset 非单调或越界 |
权威资料
核对日期:2026-08-26