Skip to content

H5 与 Canvas 2D 渲染基础 ​

Category ​

client / Web 前端。节点定位:Web 游戏渲染的最小地基——帧从哪来、画一笔的成本在哪、什么时候该缓存;WebGL 与引擎的前置课。

Definition ​

Canvas 2D 是 Web 上最轻量的游戏渲染入口:一个画布元素加一段帧循环,就能跑起完整的 2D 游戏。它不解决资源管理、场景组织和物理模拟,但它是理解一切更重的渲染方案(WebGL、引擎)之前必须先建立的地基——帧从哪里来、画一笔的成本在哪里、什么时候该缓存。

Problem ​

Canvas 2D 上手最轻,也因此最容易被低估:DPR 适配、帧循环里的绘制成本、文本与图像的缓存策略——欠账会在「低分屏验收全绿、上线当天集中反馈」时爆发。渲染排查的第一课:先看「画了多少次」,再看「每次多贵」。

画布、坐标系与设备像素比 ​

Canvas 有两套尺寸:画布内部的像素尺寸(width/height 属性)和它在页面里的显示尺寸(CSS 尺寸)。二者不一致时浏览器会拉伸画面。在高分屏上,如果画布像素尺寸只等于 CSS 尺寸,一个画布像素要覆盖多个物理像素,画面就会发虚——这是「Canvas 画出来模糊」的最常见根因。标准做法是按设备像素比(devicePixelRatio)放大画布,再用坐标变换把绘制逻辑保持在一套尺寸里:

js
function fitCanvas(cv) {
  const dpr = window.devicePixelRatio || 1
  const { width, height } = cv.getBoundingClientRect()
  cv.width = Math.round(width * dpr)
  cv.height = Math.round(height * dpr)
  cv.getContext('2d').scale(dpr, dpr)
}

之后所有绘制逻辑都按 CSS 像素坐标书写,由 scale 负责映射到物理像素。坐标系原点在左上角,y 轴向下——与数学课本和多数游戏引擎的「y 向上」相反,做物理模拟时最容易在这里翻车。

帧循环:requestAnimationFrame ​

浏览器用 requestAnimationFrame(rAF)驱动渲染:它在每帧绘制前回调,屏幕刷新多快就调用多快(常见 60Hz,高刷屏 120Hz 或更高),页面切后台时自动暂停。帧循环里必须用时间差(delta time)驱动运动,而不是「每帧走固定像素」——后者在高刷屏上会等比例变快:

html
<canvas id="cv" width="480" height="270" style="width:100%"></canvas>
<script>
  const cv = document.getElementById('cv')
  const ctx = cv.getContext('2d')
  let x = 0
  let last = performance.now()
  function frame(now) {
    const dt = (now - last) / 1000
    last = now
    x = (x + 120 * dt) % cv.width // 每秒 120 像素,与帧率无关
    ctx.clearRect(0, 0, cv.width, cv.height)
    ctx.fillRect(x, cv.height / 2 - 10, 20, 20)
    requestAnimationFrame(frame)
  }
  requestAnimationFrame(frame)
</script>

把这段保存为 HTML 文件直接打开即可运行。delta time 驱动、rAF 驱动、每帧先清后画——这三件事是所有 Web 游戏(包括用引擎做的)运行时的最小骨架。

绘制成本与批处理 ​

Canvas 2D 的每次绘制都有固定开销,成本大头通常不在「画了多大」,而在「切换了几次状态」:换填充色、换字体、换变换矩阵、换裁剪区,每一次切换都可能让浏览器打断内部的批处理。降低成本的手段按收益排序:

  • 精灵图集(atlas):把小图合进一张大图,drawImage 按坐标取用——减少纹理来源切换,这是 2D 游戏资源组织的默认形态
  • 离屏缓存:把不变化的复杂组合(静态背景、一段带描边的文字)预渲染到看不见的画布上,主循环里一次 drawImage 贴上
  • 脏矩形:只重绘发生变化的最小区域,适合界面为主、动得少的画面;全屏滚动的游戏用不上
  • 减少状态切换:按填充色、按图集分组绘制,而不是按对象顺序乱序画

Canvas 2D 没有显式的 DrawCall 概念,但「状态切换贵、批合便宜」的直觉与 WebGL 一脉相承(见下一页)。

文本与图像 ​

fillText 直接绘制文本方便但偏慢:字体光栅化、换行、描边都发生在每次调用里。UI 里频繁出现的固定文案(数值标签、按钮文字)应该预渲染成位图或字体图集。图像资源要注意解码时机——drawImage 一个尚未解码完成的 <img> 会带来首帧卡顿,资源创建后先画一次离屏画布(或用 createImageBitmap)可以提前消化解码成本。

常见误区 ​

  • 每帧全量重绘一切:画面元素多时先想清楚「什么变了」,脏区、分层画布(背景层/实体层/UI 层分开的多个 canvas 叠放)比逐项抠性能更有效
  • 忽略设备像素比:开发机是低分屏时看不出问题,到用户手机上整体发虚
  • 在帧循环里创建对象:每帧新建数组、闭包、临时画布会加重 GC,表现为规律性的掉帧抖动
  • 用 setInterval 做帧循环:它与屏幕刷新无关、切后台不停、间隔不保证,帧循环只应该用 rAF
  • delta time 直接用于物理积分:帧间时长波动大时模拟不稳定,规则运算应改用固定时步(见本章 06 页)

实战案例:一次「低分屏验收全绿,上线当天 58 条反馈」的 Canvas 渲染排查 ​

案例为教学示例(非摘自真实项目),数字经过取整简化。它把本页「画布、坐标系与设备像素比」「帧循环:requestAnimationFrame」「绘制成本与批处理」「文本与图像」「常见误区」用到深水区:一次 Canvas 2D 自行开发 H5 游戏的灰度事故,把「帧从哪里来、画一笔的成本在哪里、什么时候该缓存」三问的欠账一次结清。

背景与现象:某团队自行开发 Canvas 2D 的 H5 游戏灰度上线,开发机(低分屏、60Hz)验收全绿,上线当天收到两类反馈:画面发虚 37 条、「新手机上角色跑得快」21 条;团战场景(200 个实体)还有每 2 秒一次的规律性掉帧,帧率掉到 28。排查发现三件事:画布像素尺寸直接等于 CSS 尺寸,没有任何设备像素比处理;帧循环里每帧走固定像素——角色每帧走 4 像素,没有 delta time;绘制按对象顺序乱序全量重绘,每帧还对 40 个数值标签各调一次 fillText,帧循环里每帧新建临时数组与闭包。

第一步:现象与口径——先分清「谁在变快」和「谁在发虚」 ​

口径统一:以「同一设备、同一场景」为复测基准,把两类反馈分别算成账,先记两笔:

  • 速度账:每帧 4 像素 × 60Hz = 240px/s、× 120Hz = 480px/s,正好 2 倍——与「新手机跑得快」的反馈分布一致(集中在高刷机型)
  • 清晰账:画布像素 = CSS 尺寸,在 DPR 3 的屏幕上 1 个画布像素摊到 3 × 3 = 9 个物理像素——发虚

速度账是刷新率账,清晰账是尺寸账;掉帧是第三本账——绘制与 GC。三本账互相独立、各有各的修法。

第二步:分层归因——从两类反馈到渲染四本账 ​

text
尺寸账   画布像素 = CSS 尺寸,无 DPR 处理——DPR 3 的屏上 1 画布像素摊 9 个物理像素
速度账   每帧固定 4 像素:60Hz 240px/s、120Hz 480px/s——高刷屏等比例变快
绘制账   200 实体乱序全量重绘、每帧 40 次 fillText,帧内状态切换上百次
抖动账   帧循环每帧新建数组与闭包,每 2 秒一次 GC,帧率掉到 28

账本收束:2 倍速是速度账,9 倍摊像素是尺寸账,28 帧是绘制账加抖动账——四本账同一根源:帧从哪里来、画一笔的成本在哪里、什么时候该缓存,这三问立项时一问都没问过。

第三步:根因——地基三问,一问没问 ​

对照本页「画布、坐标系与设备像素比」:如果画布像素尺寸只等于 CSS 尺寸,一个画布像素要覆盖多个物理像素,画面就会发虚——本作逐字应验,而按设备像素比(devicePixelRatio)放大画布,再用坐标变换把绘制逻辑保持在一套尺寸里的标准做法一行没写。对照「帧循环:requestAnimationFrame」:本页要求帧循环里必须用时间差(delta time)驱动运动,而不是「每帧走固定像素」,本作恰是后者;delta time 驱动、rAF 驱动、每帧先清后画三件最小骨架,只有先清后画做了。对照「绘制成本与批处理」:本页说得直接——成本大头通常不在「画了多大」,而在「切换了几次状态」;而本作 120 张散图乱序画、静态背景每帧重画、40 个固定文案每帧重新光栅化,把小图合进一张大图(精灵图集)、把不变化的复杂组合(静态背景、一段带描边的文字)预渲染到看不见的画布上(离屏缓存)、「按填充色、按图集分组绘制,而不是按对象顺序乱序画」(减少状态切换)三个手段一个没用;脏矩形只重绘发生变化的最小区域,但全屏滚动的玩法本就用不上,不算欠账。对照「文本与图像」:UI 里频繁出现的固定文案(数值标签、按钮文字)应该预渲染成位图或字体图集,没做;图像也没预解码——首次进战斗 drawImage 未解码资源,首帧停 300ms,这本可以提前消化解码成本。「常见误区」五条里前三条原样命中:每帧全量重绘;忽略设备像素比——开发机是低分屏时看不出问题,到用户手机上整体发虚,正是本次的验收方式;在帧循环里创建对象——每帧新建数组、闭包、临时画布会加重 GC,表现为规律性的掉帧抖动,与每 2 秒一掉完全吻合。

第四步:处置与回填——三本账分头结清 ​

修法分两段。应急:T+0 尺寸与速度收口——fitCanvas 三行上齐(画布按 DPR 放大加 scale,绘制统一按 CSS 像素书写),运动改 delta time、语义定为每秒 240 像素,复测 60Hz 与 120Hz 同为 240px/s、速度差 0;T+1 绘制止血——120 张散图合 1 张图集,静态背景预渲染离屏画布,40 个固定文案标签预渲染成位图(fillText 每帧 40 → 0 次),按图集与图层分组排序绘制,帧内状态切换从 110 次降到 8 次,团战帧率从 28 回到 56;帧循环对象外提复用,每 2 秒一次的 GC 抖动归 0;图像改 createImageBitmap 预解码,进战斗首帧停顿从 300ms 压到 16ms 以内。制度一步:把「DPR 处理、delta time、图集与离屏缓存、帧循环零新建、固定文案预渲染」写进 H5 渲染 checklist 进代码评审门禁。复核:三台复测机(60Hz 中端机、120Hz 高刷机、DPR 3 机型)速度一致 240px/s(差 0)、发虚与变速反馈 0 条、团战稳定 56 帧、2 分钟内 GC 掉帧 0 次;后续 2 个新玩法模块按 checklist 开发,0 例复发。

回填清单:

落点动作回填处
DPR 收口fitCanvas 按 DPR 放大画布,绘制统一 CSS 像素坐标画布、坐标系与设备像素比
帧循环骨架delta time 驱动运动,rAF 驱动、每帧先清后画帧循环:requestAnimationFrame
绘制批合散图合图集、静态层离屏缓存、按图集分组绘制绘制成本与批处理
文本与解码固定文案预渲染位图,图像 createImageBitmap 预解码文本与图像

案例的三个教训 ​

  1. 开发机验收全绿不等于玩家设备全绿——低分屏 60Hz 看不出的两件事(DPR、刷新率),上线当天以 37 条发虚加 21 条变速反馈到账。
  2. 渲染成本在切换不在面积——200 个实体乱序画与 1 张图集分组画,画面一模一样,帧率差一倍(28 对 56)。
  3. 帧循环里每样东西都要问「每帧都做吗」——每帧新建的对象变成每 2 秒一次的 GC 抖动,每帧重画的 40 个标签变成 40 次无谓光栅化。

Used By ​

H5 小游戏与营销互动页的渲染实现;WebGL/引擎项目的性能基线理解(帧循环与合批思想同构);低分屏/高分屏适配排查。

关系链:坐标系与 DPR → 帧循环(rAF + delta time)→ 绘制成本与批处理 → 文本与图像缓存。上游:无(本子树入口)。下游:WebGL 管线(web/02)、客户端内部分层(web/06,规则运算用固定时步)。

Reference ​

Canvas/WebGL 规范以 MDN 与 W3C 文档为准;本页其余为工程经验归纳;页内实战案例为教学示例(数字取整简化)。

游戏知识体系