《Cocos 游戏开发入门》2.3 相机、视口与分辨率适配

本节讲清世界坐标到屏幕坐标的映射:手写 Camera2D 的 worldToScreen 与 y 轴翻转,实现视口矩形与相机跟随;给出横竖屏适配的 showAll / noBorder 两种缩放策略,并处理设备像素比 DPR 带来的模糊问题;最后对照 Cocos Creator 3.8 的 Camera、Canvas、view、screen 与 ResolutionPolicy。

2.3 相机、视口与分辨率适配

前两节我们把「世界里的东西」画出来了:方块能转、精灵能贴图。但《方块冲刺》是一个横向跑酷——关卡长度远超一屏,主角一路向右跑,镜头得跟着走。这就需要一个相机:它决定「世界的哪一块被看到」,以及「这一块怎么铺到屏幕上」。

本节把 mini2d 推进到「有镜头」:手写 Camera2D 的世界坐标到屏幕坐标映射,处理 y 轴翻转,实现视口矩形与相机跟随;讲清横竖屏适配的 showAll / noBorder 两种策略,并解决高分屏 DPR 导致的模糊;最后对照 Cocos Creator 3.8 的 Camera / Canvas / view / screen / ResolutionPolicy。

2.3.1 世界坐标、相机与视口

先把三个概念分清,后面就不会混:

  • 世界坐标:对象在游戏世界里的绝对位置。主角跑到关卡第 3000 像素处,它的世界 x 就是 3000。
  • 相机(Camera):一个观察者,有自己的位置(x, y)和缩放(zoom)。相机中心对准的世界点,会显示在屏幕中心。
  • 视口(Viewport):相机「看到」的世界矩形,也指屏幕上用来显示游戏的那块区域。二者尺寸在概念上对应,只是坐标空间不同。

相机的本质是一句话:把世界坐标平移、缩放、翻转到屏幕坐标系。平移量由相机位置决定,缩放量由 zoom 决定,翻转来自「世界 y 向上、屏幕 y 向下」这个差异。

2.3.2 世界坐标到屏幕坐标的映射

新建 src/render/camera.ts:

// src/render/camera.ts
export class Camera2D {
  x = 0;
  y = 0;
  zoom = 1;
  viewportWidth = 0;
  viewportHeight = 0;

  worldToScreen(wx: number, wy: number): { x: number; y: number } {
    return {
      x: (wx - this.x) * this.zoom + this.viewportWidth / 2,
      y: this.viewportHeight / 2 - (wy - this.y) * this.zoom,
    };
  }

  screenToWorld(sx: number, sy: number): { x: number; y: number } {
    return {
      x: (sx - this.viewportWidth / 2) / this.zoom + this.x,
      y: (this.viewportHeight / 2 - sy) / this.zoom + this.y,
    };
  }
}

逐项拆解 worldToScreen:

  • wx - this.x:把世界坐标平移到「相对相机」的坐标。相机右移,世界就向左移,符合直觉。
  • * this.zoom:按相机缩放放大或缩小。zoom = 2 时世界被放大两倍。
  • + viewportWidth / 2:把原点从屏幕中心搬到 Canvas 的左上角。
  • y 那一项多了一个负号:viewportHeight / 2 - (...) * zoom。这个负号就是 y 轴翻转——世界 y 增大(向上),屏幕 y 减小(向上),与 Canvas 的 y 向下对齐。

screenToWorld 是它的逆运算,处理鼠标 / 触摸时要用(第 5 章)。正逆变换必须成对实现且互为反函数,否则点击判定会整体偏移,这类 bug 极难靠肉眼发现,建议写单测:

// tests/camera.test.ts
import { describe, it, expect } from 'vitest';
import { Camera2D } from '../src/render/camera';

it('worldToScreen 与 screenToWorld 互为逆', () => {
  const cam = new Camera2D();
  cam.x = 300;
  cam.y = 120;
  cam.zoom = 1.5;
  cam.viewportWidth = 960;
  cam.viewportHeight = 640;

  const s = cam.worldToScreen(450, 200);
  const w = cam.screenToWorld(s.x, s.y);
  expect(w.x).toBeCloseTo(450);
  expect(w.y).toBeCloseTo(200);
});

2.3.3 视口矩形与相机跟随

「相机跟随主角」不是每帧把相机瞬移到主角身上——那样镜头会跟着主角的每一次微抖一起抖。常见做法是加平滑(lerp)和死区(dead zone):主角在屏幕中间一小块区域内移动时相机不动,越过边界才慢慢追上。

export function followCamera(
  cam: Camera2D,
  targetX: number,
  dt: number,
  smooth = 6,
): void {
  const desired = targetX - cam.viewportWidth / (2 * cam.zoom);
  const t = Math.min(1, smooth * dt);
  cam.x += (desired - cam.x) * t;
}

t = Math.min(1, smooth * dt) 保证平滑量随帧率归一,第 4 章会详细推导「为什么必须乘 dt」。跑酷还常加一条规则:相机只向右、不向左,避免玩家往回走时镜头倒退。实现上就是 cam.x = Math.max(cam.x, desired)。

视口矩形是相机看到的世界的边界,取景 / 裁剪 / 判断「对象是否可见」都要用它:

export function getViewportRect(cam: Camera2D): {
  left: number; right: number; top: number; bottom: number;
} {
  const halfW = cam.viewportWidth / (2 * cam.zoom);
  const halfH = cam.viewportHeight / (2 * cam.zoom);
  return {
    left: cam.x - halfW,
    right: cam.x + halfW,
    top: cam.y + halfH,
    bottom: cam.y - halfH,
  };
}

注意 top > bottom(因为世界 y 向上),与屏幕坐标里 top < bottom 正好相反。写剔除逻辑时别把比较方向搞反。第 3 章会用这个矩形做视锥剔除,只画屏幕内的对象。

2.3.4 缩放与横竖屏适配

手机屏幕比例千差万别:16:9、19.5:9、4:3、还有平板。设计稿只有一套尺寸(比如 960×640),要适配到所有屏幕,本质是选一个缩放策略:

策略缩放公式效果代价
完整显示(showAll)min(sx, sy)整张设计稿都可见可能出现黑边(letterbox)
无黑边(noBorder)max(sx, sy)铺满屏幕无黑边超出部分被裁掉
export function computeFitScale(
  designW: number,
  designH: number,
  screenW: number,
  screenH: number,
  policy: 'showAll' | 'noBorder',
): number {
  const sx = screenW / designW;
  const sy = screenH / designH;
  return policy === 'showAll' ? Math.min(sx, sy) : Math.max(sx, sy);
}

《方块冲刺》横版跑酷选 showAll:宁可上下留黑边,也不能让玩家看不到脚下的平台或头顶的障碍。如果做成竖屏休闲玩法,通常也选 showAll,但设计稿改用竖向尺寸。选好策略后,把 fitScale 乘进相机缩放即可:

export function applyFit(cam: Camera2D, scale: number): void {
  // viewport 保持逻辑屏幕尺寸,适配缩放全部交给 zoom
  cam.zoom = scale;
}

(这里 viewport 指的是逻辑屏幕尺寸,zoom 承担适配缩放;两者分工要清晰,否则会出现「缩放了两次」。)

2.3.5 设备像素比 DPR 与清晰度

在高分屏(Retina、多数手机)上,CSS 像素和物理像素不是 1:1。如果画布按 CSS 尺寸设置,浏览器会把它拉伸到物理像素,图就糊了。正确做法是让画布的后备存储等于物理像素,再用 CSS 尺寸约束显示大小:

export function resizeCanvas(
  canvas: HTMLCanvasElement,
  dpr: number,
): { cssWidth: number; cssHeight: number } {
  const cssWidth = canvas.clientWidth;
  const cssHeight = canvas.clientHeight;
  canvas.width = Math.round(cssWidth * dpr);
  canvas.height = Math.round(cssHeight * dpr);
  canvas.style.width = `${cssWidth}px`;
  canvas.style.height = `${cssHeight}px`;
  return { cssWidth, cssHeight };
}

这里有个和 2.1 呼应的坑:2.1 里精灵绘制时调用了 ctx.setTransform(m.a, ...),它会整体覆盖当前变换。如果你在 resizeCanvas 里用 ctx.setTransform(dpr, 0, 0, dpr, 0, 0) 设置 DPR 缩放,随后第一个精灵的 setTransform 就把它冲掉了,DPR 失效、图又糊回去。

正确做法是把 DPR 折进相机矩阵:worldToScreen 出来的屏幕坐标再乘 dpr,或者让渲染器用一个「DPR × 相机 × 节点」的复合矩阵。原则是每帧只有一个 setTransform 决定最终矩阵,不要指望 setTransform 能累积。这也是为什么 2.1 建议「自己算矩阵」而不是依赖 Canvas 的状态栈。

2.3.6 把相机接进 mini2d

把相机放进渲染循环,每帧先更新跟随、算出视口矩形,再逐个绘制:

// src/render/render-loop.ts
import { Camera2D, followCamera, getViewportRect } from './camera';

export function renderFrame(
  ctx: CanvasRenderingContext2D,
  cam: Camera2D,
  targetX: number,
  dt: number,
  drawAll: (ctx: CanvasRenderingContext2D) => void,
): void {
  followCamera(cam, targetX, dt);
  const view = getViewportRect(cam);
  ctx.clearRect(0, 0, cam.viewportWidth, cam.viewportHeight);
  ctx.save();
  drawAll(ctx);
  ctx.restore();
  void view; // 后续用 view 做视锥剔除
}

真实的 drawAll 会先按 view 过滤掉不可见的对象,再对可见对象调用 2.2 的 Sprite.draw,并把相机变换乘进每个节点的矩阵。相机一旦就位,《方块冲刺》的镜头就活了:主角在屏幕中间偏左的位置跑,世界从右向左滚动。

2.3.7 Cocos 对照:Camera、Canvas、view 与 ResolutionPolicy

Cocos Creator 3.8 里,相机和分辨率适配由几个全局单例 + 组件协作完成,不需要你手写矩阵。一个设置设计分辨率与适配策略的启动脚本:

// assets/scripts/ResolutionBoot.ts
import { _decorator, Component, view, screen, ResolutionPolicy, Camera } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('ResolutionBoot')
export class ResolutionBoot extends Component {
  @property(Camera)
  mainCamera: Camera | null = null;

  start() {
    view.setDesignResolutionSize(960, 640, ResolutionPolicy.SHOW_ALL);

    const design = view.getDesignResolutionSize();
    const visible = view.getVisibleSize();
    const frame = screen.windowSize;
    const dpr = screen.devicePixelRatio;

    console.log(`设计 ${design.width}x${design.height}`);
    console.log(`可见 ${visible.width}x${visible.height}`);
    console.log(`窗口 ${frame.width}x${frame.height}, DPR ${dpr}`);

    if (this.mainCamera) {
      this.mainCamera.orthoHeight = visible.height / 2;
    }
  }
}

逐项对照我们手写的那套:

  • view 是全局单例(export const view: View),管设计分辨率与可见区域。view.setDesignResolutionSize(w, h, policy) 等价于我们 2.3.4 选设计尺寸 + 缩放策略;view.getVisibleSize() 就是适配后真正可见的尺寸。
  • ResolutionPolicy 是一组常量,和我们表格里的策略一一对应:SHOW_ALL(完整显示,可能黑边)、NO_BORDER(无黑边,可能裁剪)、EXACT_FIT(强行拉伸)、FIXED_WIDTH / FIXED_HEIGHT(固定一边)。横版跑酷选 SHOW_ALL,与 mini2d 一致。
  • screen 也是全局单例(export const screen: Screen)。screen.windowSize 给窗口物理尺寸,screen.devicePixelRatio 给 DPR——对应我们 2.3.5 的 dpr 参数,Cocos 内部已经帮你把它折进渲染管线,你不用再手动 setTransform。
  • Camera 是组件。2D 用正交投影,orthoHeight 决定相机竖向能看多高(单位是设计像素),横向范围由屏幕宽高比推出——正好对应我们 viewportHeight / (2 * zoom) 那半高。
  • Canvas 是 UI 根节点组件(export class Canvas extends RenderRoot2D),它自动把自己的 UITransform 尺寸对齐到 view 的可见尺寸,alignCanvasWithScreen 控制这个行为。UI 挂在 Canvas 下就能自动适配。

一句话总结:mini2d 里相机是一个你亲手维护位置与缩放的对象,适配是你自己算的缩放系数;Cocos 里适配由 view + ResolutionPolicy 统一声明,相机是 Camera 组件、UI 根是 Canvas 组件,DPR 由引擎透明处理。理解了映射公式,你在 Cocos 里调 orthoHeight 或换 ResolutionPolicy 时就知道底层发生了什么,而不是碰运气试参数。

到这里,渲染的第一层地基——从像素、矩阵、精灵到相机——就铺完了。下一节进入渲染第二层:当对象有父子关系、有前后遮挡、有成千上万个时,怎么组织场景图、怎么决定绘制顺序、怎么把 draw call 压下来。

阅读导航:上一节:2.2 精灵、纹理与图集切分 · 下一节:3.1 场景图与节点树 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画