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 场景图与节点树 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。