☰
Canvas四种三角形绘制:从几何推导到圆角与莱诺三角
2026/10/26 6:33:41 网站建设 项目流程

画布上画一个三角形,听起来像是入门第一课的内容,但我见过太多项目在这个"第一课"上翻车:控件里那个三角形一边长一边短、圆角三角形在圆角半径调大的时候突然变成一坨黑块、莱诺三角形转起来的时候某个角度忽然"抖"一下。这些问题的根源往往不在 Canvas API 本身,而在于动手之前没有把几何关系算清楚。这篇内容就把正三角形、切角正三角形、圆角正三角形、莱诺三角形这四种图形从参数定义、顶点推导到路径落笔完整拆一遍,重点讲清楚每一类图形"参数之间怎么换算""半径或切角量到哪儿就不能再加了""arcTo 和 arc 在什么情况下会悄悄降级"。适合已经会用 Canvas 画直线和圆弧、但一到精确几何图形就要反复试错的人,也适合想把这类图形做成可复用绘图模块的前端。

1. 三角形在 Canvas 里为什么不能靠"感觉"画

1.1 一次画歪的排查:问题出在包围盒和顶点基准

我最早写三角形的时候是用三个写死的坐标,在固定尺寸的图里看着挺正,一放到响应式容器里就露馅了——要么被拉伸变形,要么超出画布。后来才想明白,三角形这种东西的"正"是相对于某个基准定义的,而基准至少有三种:外接圆半径、边长、包围盒。你写死坐标,等于把基准固定成了"某个特定尺寸下的像素位置",一旦容器变化,基准就丢了。

排查这类问题有个很直接的方法:在图形外面套一个包围盒矩形描出来。正三角形顶点朝上时,包围盒的宽高比是 √3 : 1.5,约等于 1.1547。如果你画出来的三角形塞进这个比例的盒子里有明显空隙或者溢出,说明基准算错了。这个自检手段我用了很多年,比盯着图形看管用得多,因为它把"看起来正不正"这种主观判断换成了可量化的比例校验。

另一个常见误区是把canvas.width当成显示宽度用。设备像素比不为 1 的时候,这个值会被放大,导致所有基于它计算的几何参数整体偏大,图形就被裁掉一角。这个坑我在移动端适配里踩过不止一次。

1.2 边长、外接圆半径、内切圆半径三者的换算表

四种三角形变体的所有参数,最终都能收敛到"边长 a"这一个量上。把这张换算表记住,后面所有推导都不用现查:

参数与边长 a 的关系近似系数说明
外接圆半径 Ra / √30.5774a中心到顶点的距离
内切圆半径 ra / (2√3)0.2887a中心到边的距离,也等于 R/2
包围盒宽度a1.0000a顶点朝上时
包围盒高度(√3/2)a0.8660a顶点朝上时
面积(√3/4)a²0.4330a²正三角形本体

这张表里最关键的一行是内切圆半径 r = a/(2√3)。它不只是个几何量,后面讲圆角三角形的时候你会发现,它就是圆角半径的理论上限——超过这个值,三段圆弧就拼不成一个合法图形了。很多人调圆角半径时没有上限意识,直接给一个"看起来差不多"的数,结果在某个尺寸下图形崩掉,还以为是浏览器 bug。

反过来,如果你是从设计稿拿到包围盒尺寸(比如给定宽 200、高 173),那就先算边长:宽 200 直接就是 a = 200,高应该是 200 × 0.866 = 173.2,对得上就说明设计稿本身是标准的正三角形比例。如果高对不上,那设计稿可能是被拉伸过的,这时候要决定是保持宽还是保持高,这个决策会直接影响后面所有参数。

1.3 角度体系:y 轴向下带来的方向反转

Canvas 的坐标原点是左上角,y 轴向下增长,这个设定会带来一个反直觉的结果:数学上逆时针是角度增加,在 Canvas 里角度增加却是顺时针。这个反转本身不难理解,但它会在两个地方坑人。

第一个是arc的anticlockwise参数。很多人的直觉是"我要逆时针画弧,就传 true",但如果顶点顺序是顺时针排布的,传 true 反而会画出一段 300 度的长弧,把整个图形包成一个大饼。第二个是顶点生成公式x = cx + R*cos(θ), y = cy + R*sin(θ),因为 y 正方向朝下,视觉上角度增大是顺时针绕行,画出来的顶点顺序恰好就是顺时针,这一点在闭合路径时是好事,能保证fill()的填充规则正常工作。

我习惯在代码里统一用-Math.PI / 2作为起始角,这样第一个顶点永远在正上方,符合大多数人"三角形尖朝上"的心理预期。这个起始角在后续的动画里也很重要,如果你想让三角形旋转,直接在这个角度上加偏移就行,不需要额外做矩阵变换。

2. 正三角形:顶点算法与三种落笔方式的实测对比

2.1 三角函数法:最稳的通用解,也是我默认的选择

三角函数法的核心就三行:给定中心点和外接圆半径,用 120 度间隔取三个角度,算出三个顶点。它的好处是中心点固定,图形绕中心缩放、旋转都很自然,不需要额外的变换矩阵。

function equilateralPoints(cx, cy, R, startAngle = -Math.PI / 2) { const pts = []; for (let i = 0; i < 3; i++) { const a = startAngle + i * (Math.PI * 2 / 3); pts.push({ x: cx + R * Math.cos(a), y: cy + R * Math.sin(a) }); } return pts; } function drawTriangle(ctx, pts) { ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); ctx.lineTo(pts[1].x, pts[1].y); ctx.lineTo(pts[2].x, pts[2].y); ctx.closePath(); ctx.stroke(); // 或 fill() }

这段代码我在各种项目里复用了几十次,几乎没改过。它唯一的"隐藏成本"是Math.cos和Math.sin的调用,但每秒几千次以内的重绘完全感觉不到开销,别为这个做提前优化。真正需要注意的是R的取值来源——如果你拿到的输入是边长 a,记得先除个 √3,这个换算忘一次就会画出明显偏大的三角形。

我一般还会额外导出三个派生值:边长、包围盒宽高、内切圆半径。这样上层调用方拿到图形对象后,可以自己决定怎么摆布局,不用再回头算一遍。

2.2 旋转坐标系法:适合做动画,但要注意累积误差

另一种画法是先把坐标系平移到中心,旋转到目标角度,然后按(0, -R)、(R*√3/2, R/2)、(-R*√3/2, R/2)三个固定点画。这种写法在需要连续旋转的动画里很顺手,因为每帧只要改ctx.rotate()的参数就行,顶点坐标是常量。

function drawRotatedTriangle(ctx, cx, cy, R, angle) { ctx.save(); ctx.translate(cx, cy); ctx.rotate(angle); ctx.beginPath(); ctx.moveTo(0, -R); ctx.lineTo(R * Math.sqrt(3) / 2, R / 2); ctx.lineTo(-R * Math.sqrt(3) / 2, R / 2); ctx.closePath(); ctx.restore(); }

问题在于这套写法的状态依赖很强,save()和restore()必须严格配对。如果中途抛异常或者提前 return,旋转状态就会残留,导致后面所有绘制全歪。我在一个仪表盘项目里遇到过这种连锁失败——一个圆形进度条的数据异常,整个图表的文字全部跟着旋转了 30 度,排查了半天才发现是最内层的restore()没执行。

所以我的建议是:静态绘制用三角函数法,需要旋转的动效再考虑变换法,并且把save/restore包在try/finally里,或者干脆把旋转逻辑收敛到一个只负责变换的小函数内,绝不跨函数边界共享变换状态。

2.3 顶点手写与参数驱动的边界在哪里

有个很现实的问题:既然参数化这么好,为什么还有人写死顶点?因为有些场景下图形是"装饰性"的,比如一个提示气泡下方的小三角、一个播放按钮里的三角形图标。这类场景不需要精确到数学意义上,写死三个点反而更简单直接。

我的判断标准是:这个三角形需不需要随容器缩放、需不需要旋转、需不需要和其他图形拼接。三个答案里只要有一个是"需要",就必须走参数化路线。反过来,如果它就是一个固定尺寸的纯装饰,写死也无妨,但至少要在注释里标明"此处的顶点基于 24×24 图标网格",方便后来人改尺寸时知道该按什么比例调整。

还有一个容易忽略的点:描边宽度会影响视觉上的"正"。stroke()的线宽是沿着路径居中分布的,一半在内一半在外,对于三条边等长的正三角形来说这不影响对称性,但会让整体视觉尺寸比fill()大出lineWidth / 2。如果你在做像素对齐或者和其他元素对齐,这个差异足够让图形看起来"偏了 1px"。

3. 切角正三角形:削掉三个角之后的六边形几何

3.1 切角量 t 的取值区间与退化临界点

切角正三角形的本质是把三个顶点各切一刀,得到一个六边形。参数只有一个:切角量 t,含义是"从每个顶点出发,沿两边各退 t 的距离"。t 的合法区间是[0, a/2],两个端点对应两个极端形态。

t = 0 的时候没有切,还是正三角形。t = a/2 的时候,每条边上两个切点重合,六边形退化成三个点,形成的是中点三角形——一个倒置的正三角形,面积只有原来的四分之一。中间任何一档,六边形都是三条长边三条短边交替的形态:长边长度a - 2t,短边长度t。

面积公式也值得记一下:S = (√3/4)(a² - 3t²)。把 t 代入两个端点验证一下,t = 0 得到原来的√3a²/4,t = a/2 得到√3a²/16,正好是中点三角形的面积,说明公式没问题。这个公式在需要做"切角进度动画"的时候很有用——如果你想让面积线性变化,t 就不能线性增长,得开根号反解。

实际使用中 t 的典型取值是a * 0.1到a * 0.3,这个范围内切角的视觉效果比较舒服,既明显保留三角形轮廓,又有轻微的科技感。超过0.4a之后图形会越来越像个六边形,三角形特征基本消失了。

3.2 六个切点的生成顺序:搞错了会画成蝴蝶结

生成切点本身不难,难的是顺序。如果六个点按错误顺序连起来,会出现经典的自相交"蝴蝶结"图形,而且因为fill()默认用非零环绕规则,某些区域还会被挖空,看起来特别诡异。

正确顺序是沿着边遍历,对每条边Pi → Pj依次输出"靠近 Pi 的退点"和"靠近 Pj 的退点"。这样六个点的连接顺序就是:边1起点、边1终点、边2起点、边2终点……其中"边1终点"到"边2起点"的那一段就是切角边。

function truncatedPoints(pts, t) { const a = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); const k = Math.min(Math.max(t / a, 0), 0.5); // 夹紧到合法区间 const out = []; for (let i = 0; i < 3; i++) { const p = pts[i]; const q = pts[(i + 1) % 3]; out.push({ x: p.x + (q.x - p.x) * k, y: p.y + (q.y - p.y) * k }); out.push({ x: q.x - (q.x - p.x) * k, y: q.y - (q.y - p.y) * k }); } return out; }

代码里我用了比例 k 而不是绝对长度 t 来做插值,这样原始三角形缩放时切角量会自动跟着变。如果你希望切角是绝对尺寸(比如固定 8px),那就要用真实的退点距离去算,而且要在三边长度不等的情况下做边方向归一化——不过正三角形三边等长,这两种写法在视觉上等价,选一个自己顺手的就行。

k夹紧到[0, 0.5]这一步别省。我在一个可拖拽调整参数的交互 Demo 里忘了夹紧,用户把滑块拉到最右边的时候 t 超过 a/2,六个点顺序全乱,画面对应的六边形直接翻面了。

3.3 切角和圆角之外,切角更适合做"硬朗"的类型化描边

切角在视觉上和圆角是两种完全不同的气质。圆角柔和、亲和、适合消费类产品的卡片和按钮;切角硬朗、精确、带一点机械感,适合数据可视化里的节点标记、科幻风格 UI 的边框、以及需要强调"这是一个人工构造物"的场景。

从渲染成本看,切角切出来的是纯直线段,路径点数比圆弧少得多,描边速度更快。如果你的图形需要每帧重绘几百次(比如粒子系统里的三角标记),切角版本比圆角版本省下的开销在低端设备上是能感知到的。

还有一个实际考量:切角图形在不同缩放级别下的稳定性更好。圆弧在极端小尺寸(比如 6px 宽的图标)下会糊成一团,因为抗锯齿处理不了一条只有 1px 长的弧;而切角是直线,小尺寸下至少还能保住基本的几何轮廓。这条经验值不少,做图标系统的时候尤其管用——同一套参数,大尺寸用圆角,小尺寸自动降级成切角,视觉效果会协调很多。

4. 圆角正三角形:arcTo 的切线推导与半径上限

4.1 为什么圆角半径不能超过内切圆半径

前面提过圆角半径的上限是a/(2√3),这个结论值得展开讲讲,因为它不是凭感觉拍的,而是严格等于内切圆半径。

想象一下圆角三角形是"一个正三角形被三个圆角吃掉"的过程。圆角半径从小到大变化,三段圆弧越来越长。当三段圆弧各自的圆心角都达到 120 度、而且首尾刚好相接时,整个图形就闭合成了一个完整的圆——这个圆的半径恰好是内切圆半径,因为它是能同时与三条边相切的最大圆。再往上加,三段弧就会互相重叠、无法拼成一个简单闭合路径。

用代码验证这件事很简单:取a = 100,内切圆半径是100 / (2 × 1.732) ≈ 28.87。你把这个值作为圆角半径传进去,画出来的东西应该是一个近乎完美的圆,只有三个位置能看到极微小的视觉接缝。如果传 30,就会看到三个明显的"折角"或者图形崩坏。

const R_LIMIT = a / (2 * Math.sqrt(3)); const r = Math.min(inputRadius, R_LIMIT * 0.98); // 留 2% 余量

留余量是我后来养成的习惯。理论上等于上限值是合法的,但浮点误差会让三个切点算出来差那么一丁点,导致闭合时产生一个亚像素级的裂缝,在深色背景加粗描边的情况下能看出来。留 2% 余量之后这个问题基本消失。

4.2 从半径反推退点距离 d = r√3 的推导

用arcTo画圆角,需要知道"从角点沿边退多远开始画弧"。这个距离我记作 d,它和圆角半径 r 的关系是d = r / tan(θ/2),θ 是内角 60 度,tan(30°) = 1/√3,所以d = r√3 ≈ 1.732r。

这个推导的几何含义是:圆弧与两条边相切,圆心到两条边的垂足就是两个切点,切点、角点、圆心构成一个直角三角形,直角边是 r,另一个直角边是 d,锐角是半内角 30 度。所以tan(30°) = r / d,反解出 d。这个关系是后面所有代码的基础,记错一次整个图形就会错位。

function roundedTriangle(ctx, pts, r) { const a = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); const rLimit = a / (2 * Math.sqrt(3)); const rr = Math.min(r, rLimit * 0.98); const d = rr * Math.sqrt(3); const unit = (from, to) => { const len = Math.hypot(to.x - from.x, to.y - from.y); return { x: (to.x - from.x) / len, y: (to.y - from.y) / len }; }; // 进入第一个角的切点:从 P0 沿指向 P2 的方向退 d const u02 = unit(pts[0], pts[2]); const start = { x: pts[0].x + u02.x * d, y: pts[0].y + u02.y * d }; ctx.beginPath(); ctx.moveTo(start.x, start.y); for (let i = 0; i < 3; i++) { const cur = pts[i]; const nxt = pts[(i + 1) % 3]; const u = unit(cur, nxt); // arcTo 的第二个点只要在离开方向上即可 ctx.arcTo(cur.x, cur.y, cur.x + u.x * d, cur.y + u.y * d, rr); } ctx.closePath(); ctx.stroke(); }

注意起点是"从 P0 沿指向 P2 的方向退 d",而不是"从 P0 沿指向 P1 的方向退 d"。因为整体按 P0→P1→P2 的顺时针顺序走,进入 P0 那条弧的时候,我们是从 P2 那条边上来的,所以退点要在 P0→P2 方向的那条边上。这个方向关系是这段代码里最容易写反的一处,写反了图形会变成一个奇怪的月牙。

4.3 arcTo 的隐式连线:好用但容易踩的三个暗坑

arcTo有一行为经常被忽略:它会从当前点自动画一条直线到圆弧的起点。这条隐式连线在大多数情况下正是我们想要的(它把上一个角的弧尾和这个角的弧头连起来),但它也意味着你不能在两次arcTo之间手动加lineTo,否则会多出一条多余的线段。

第一个坑是半径非法。如果当前点到角点、角点到第二个点的距离都小于半径,浏览器不会报错,而是静默降级成一条lineTo到角点。这就是为什么有些人发现"圆角半径调大之后角反而变尖了"——其实是半径超限了,弧画不出来,退化成了直角。

第二个坑是当前点和角点重合。如果moveTo的位置正好落在角点上,第一次arcTo的行为可能不符合预期,某些引擎下会直接跳过这段弧。

第三个坑是第二个点的位置。arcTo只用第二个点来确定方向,不用它的距离,所以理论上你可以传一个很远的点。但如果你传的点恰好和角点重合,方向无法确定,行为未定义。我习惯传"角点沿边方向前进 d 距离"的点,既明确又直观。

5. 莱诺三角形:定宽曲线的绘制与宽度验证

5.1 三段圆弧的圆心与半径映射关系

莱诺三角形(也常译作勒洛三角形)的构造非常优雅:取一个正三角形,以每个顶点为圆心、边长为半径,画三段圆弧,三段弧围成的图形就是它。

这个映射关系是:弧 AB 的圆心是 C,弧 BC 的圆心是 A,弧 CA 的圆心是 B。半径统一是边长 a,不是外接圆半径 R。这一点非常容易搞混——因为莱诺三角形的三个"顶点"和原正三角形的顶点是同一个位置,但半径的来源完全不同。

它的几个关键数值记一下:周长πa(三段各 60 度弧,总长是圆周的六分之一乘以三,即半周长 πa),面积(π - √3)/2 × a² ≈ 0.7048a²,而同样边长的正三角形面积只有0.433a²,也就是说它比正三角形"胖"了六成多。宽度恒等于 a,这是它最重要的性质。

最后一个性质值得展开:不管从哪个方向量它的宽度(平行线夹住的间距),结果都是 a。正三角形做不到这一点——顶点朝上时宽 a、旋转 30 度后就变成√3a/2 ≈ 0.866a了。正是这个恒宽性让它在机械结构上有独特价值,比如可以做成恒宽钻头在方形槽里转动。

5.2 顺时针绘制时的角度递增判断与方向兜底

用arc画三段弧,最麻烦的是方向判断。三段弧必须按同一个绕行方向绘制,否则路径会自相交,填充结果不可预测。

function reuleauxPath(ctx, A, B, C) { // 保证三点在 Canvas 坐标系下是顺时针顺序(有向面积为正) const cross = (B.x - A.x) * (C.y - A.y) - (B.y - A.y) * (C.x - A.x); const pts = cross >= 0 ? [A, B, C] : [A, C, B]; const side = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y); for (let i = 0; i < 3; i++) { const from = pts[i]; const to = pts[(i + 1) % 3]; const o = pts[(i + 2) % 3]; const s = Math.atan2(from.y - o.y, from.x - o.x); const e = Math.atan2(to.y - o.y, to.x - o.x); let delta = e - s; while (delta <= 0) delta += Math.PI * 2; ctx.arc(o.x, o.y, side, s, s + delta, false); } ctx.closePath(); }

这段代码里有三个防御性设计。第一是开头的cross判断,把任意传入顺序的三个点统一成顺时针,避免因为调用方传参顺序不同导致图形翻面。第二是while (delta <= 0)这个循环,把角度差归一到(0, 2π]区间,这样不管起止角落在哪个象限,都能保证顺时针扫过正确的弧长。第三是anticlockwise固定传false,因为我们已经把方向统一到了"角度递增"。

如果省掉方向兜底,直接写ctx.arc(o.x, o.y, side, s, e, false),当 e 小于 s 的时候浏览器会沿着顺时针方向绕一大圈(2π - |delta|),画出 300 度的长弧,整个图形变成一朵花。我第一次写的时候就是这个结果,盯着屏幕愣了半天。

5.3 用投影法验证"宽度恒定",而不是靠眼睛看

恒宽这个性质很难用眼睛验证,因为视觉上它就是个圆润的三角形,你怎么看都觉得"某个方向好像窄一点"。要确认自己画对了,得用数值方法:把边界采样成点集,然后对多个方向做投影,比较每个方向的极值差。

function sampleReuleaux(A, B, C, steps = 120) { const cross = (B.x - A.x) * (C.y - A.y) - (B.y - A.y) * (C.x - A.x); const pts = cross >= 0 ? [A, B, C] : [A, C, B]; const side = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y); const samples = []; for (let i = 0; i < 3; i++) { const from = pts[i], to = pts[(i + 1) % 3], o = pts[(i + 2) % 3]; const s = Math.atan2(from.y - o.y, from.x - o.x); const e = Math.atan2(to.y - o.y, to.x - o.x); let delta = e - s; while (delta <= 0) delta += Math.PI * 2; for (let k = 0; k < steps; k++) { const t = s + delta * (k / steps); samples.push({ x: o.x + side * Math.cos(t), y: o.y + side * Math.sin(t) }); } } return samples; } function widthAlong(points, theta) { const ux = Math.cos(theta), uy = Math.sin(theta); let min = Infinity, max = -Infinity; for (const p of points) { const v = p.x * ux + p.y * uy; if (v < min) min = v; if (v > max) max = v; } return max - min; } // 验证:所有方向的宽度都应约等于 side for (let deg = 0; deg < 180; deg += 5) { const w = widthAlong(samples, deg * Math.PI / 180); console.log(deg, w.toFixed(3)); }

跑一遍你会发现所有方向的宽度都稳定在边长附近,误差在采样精度范围内(我用 120 步采样时误差大约在 0.3%)。如果某个方向的宽度明显偏离,说明绘制方向搞反了或者半径用错了。

采样步数建议至少 60 步每段弧。步数太少的时候,靠近顶点的位置采样稀疏,投影极值会算不准,出现假误差,容易误判成代码有问题。

6. 把四种图形收敛成一个绘图模块

6.1 统一的入参约定与返回值结构

四种图形各自独立实现,重复代码会很多,尤其是顶点生成和方向判断这两块。我的做法是先写一个makeTriangle(cx, cy, R, rotation)返回三个顶点,然后四种图形都基于这三个顶点二次加工。

接口上我统一成两类函数:xxxPoints()返回点集或路径参数,drawXxx(ctx, params)负责落笔。前者纯计算、无副作用,可以在布局阶段用来算尺寸、做碰撞检测;后者只做绘制,不接受任何业务逻辑。这个分离在需要预计算场景(比如把图形放进虚拟列表、需要提前知道占用面积)时特别有用。

const Shapes = { triangle: (cx, cy, R, rot) => ({ type: 'triangle', pts: equilateralPoints(cx, cy, R, rot) }), truncated: (cx, cy, R, rot, t) => ({ type: 'truncated', pts: truncatedPoints(equilateralPoints(cx, cy, R, rot), t) }), rounded: (cx, cy, R, rot, r) => ({ type: 'rounded', pts: equilateralPoints(cx, cy, R, rot), r }), reuleaux: (cx, cy, R, rot) => ({ type: 'reuleaux', pts: equilateralPoints(cx, cy, R, rot) }), };

这里有个设计上的取舍:莱诺三角形的顶点其实就是正三角形的三个顶点,所以它在数据结构上和正三角形完全一致,只是渲染方式不同。而圆角三角形虽然也只存顶点,但多了一个半径参数。切角三角形则直接存了六个点。这种"结构不统一"看起来不够优雅,但换来了渲染函数的简单——每个渲染函数只需要处理一种数据形态,不需要在内部做类型分支。

6.2 DPR 适配与描边居中补偿的实操写法

高清屏适配的标准流程就四步:读devicePixelRatio、设置物理像素尺寸、设置 CSS 尺寸、缩放上下文。但这里面有个细节容易忽略:ctx.scale(dpr, dpr)之后所有几何单位都变成 CSS 像素,而lineWidth也是按 CSS 像素解释的,所以 1px 的线在 DPR = 2 的屏幕上会被渲染成 2 个物理像素——这正是我们想要的清晰效果。

function setupCanvas(canvas, cssW, cssH) { const dpr = Math.min(window.devicePixelRatio || 1, 3); canvas.width = Math.round(cssW * dpr); canvas.height = Math.round(cssH * dpr); canvas.style.width = cssW + 'px'; canvas.style.height = cssH + 'px'; const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); return ctx; }

Math.min(dpr, 3)这个上限是经验值。有些高分屏报告 dpr = 4,按 4 倍渲染的内存占用会翻倍,而视觉提升几乎看不出来,尤其对几何图形这种大色块的场景。封顶到 3 在清晰度和内存之间比较平衡。

描边居中的补偿是这样:如果业务要求"图形的外轮廓正好贴住给定的矩形",那绘制时要把半径或顶点往里缩lineWidth / 2。因为stroke()的线是骑在路径上的,外扩了一半。反过来,如果要求"路径本身等于给定尺寸",就不用补偿,但要意识到视觉上会大一圈。这两种约定必须在模块里明确写死一种,并且写在文档注释里,否则调用方按自己的理解用,对齐就会出问题。

6.3 动画插入点:旋转莱诺三角形的正确做法

莱诺三角形有个很适合做动效的性质:它能在正方形里连续旋转,而且始终有四个点接触边界。做这个动画的时候,如果直接对路径做旋转,需要先保存状态、平移到中心、旋转、再按原始坐标绘制。

function animateReuleaux(ctx, cx, cy, R, t) { const angle = t * 0.002; const pts = equilateralPoints(0, 0, R, -Math.PI / 2 + angle); ctx.save(); ctx.translate(cx, cy); reuleauxPath(ctx, pts[0], pts[1], pts[2]); ctx.fillStyle = '#1f6feb'; ctx.fill(); ctx.restore(); }

这里我把顶点算在原点附近,然后靠translate挪到目标位置,而不是把中心点直接传给equilateralPoints。原因是reuleauxPath内部会做方向判断,如果顶点坐标里带着大数值偏移,浮点

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询