跳到主要内容

渐变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. 线型:线宽在用户空间,虚线相位会爬

lineWidthlineDashlineDashOffset 都在 用户空间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
一开模糊就掉到 20fpsblur 半径 × 相机缩放 × 全屏
addColorStop 抛错offset 非单调或越界

权威资料

核对日期:2026-08-26