2.2 精灵、纹理与图集切分
2.1 结束时《方块冲刺》的主角还是一个纯色方块。能跑、能转,但没脸。这一节给它贴上真正的图——主角的奔跑帧、金币的旋转帧、平台的砖块。在游戏术语里,一张能贴上屏幕的图叫精灵(Sprite),它背后的位图数据叫纹理(Texture),而把几十张精灵打进一张大图、再按坐标切出来的做法叫图集(Atlas)。
本节把 mini2d 推进到「能贴图」:掌握
drawImage的九参形式与源矩形切图,实现锚点(pivot)偏移与九宫格(nine-slice)拉伸,设计一份图集 JSON 描述并写切分函数。最后对照 Cocos Creator 3.8 的Sprite/SpriteFrame/SpriteAtlas/UITransform,看同一件事在编辑器里怎么配。
2.2.1 从 fillRect 到 drawImage
drawImage 有三个重载,能力依次增强:
// 1) 三参:整张图,按原始尺寸画到 (dx, dy)
ctx.drawImage(image, dx, dy);
// 2) 五参:整张图,缩放到 dw × dh
ctx.drawImage(image, dx, dy, dw, dh);
// 3) 九参:从源图切一块,缩放到目标矩形
ctx.drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);
《方块冲刺》需要的是第三种。前两个参数组各四个数:
sx, sy, sw, sh:源矩形——从原图里切哪一块(source)。dx, dy, dw, dh:目标矩形——画到画布的哪个位置、多大(destination)。
s 是 source、d 是 destination,记住这一点就不会把两组参数写反。写反的典型症状是「图只出来一角」或「图被拉伸成一条」。
image 参数的类型是 CanvasImageSource,可以是 HTMLImageElement、HTMLCanvasElement、ImageBitmap 或 OffscreenCanvas。加载流程是「先拿 Image 对象、监听 onload、再放进渲染循环」:
export function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`加载失败: ${src}`));
img.src = src;
});
}
必须等到 onload 之后再画,否则图还没解码完,drawImage 画出来是空白。加载与资源管理会在第 10 章系统展开,这里先用最小版本。
2.2.2 源矩形与目标矩形的分工
源矩形决定「切什么」,目标矩形决定「画多大、画哪里」。两者分开带来一个直接好处:同一块源图可以画成不同尺寸。比如金币在图集里只有 32×32,但主菜单上要放大到 96×96,只需改 dw/dh,不用准备两份图。
把这两个矩形和 2.1 的矩阵结合起来,精灵的绘制流程就清楚了:
- 用
setTransform设置对象的变换矩阵(位置 / 旋转 / 缩放)。 - 用九参
drawImage把源矩形画到「以原点为中心」的目标矩形。
第二步里目标矩形的偏移就是锚点要处理的事,见 2.2.4。
2.2.3 图集与切分
一个关卡里可能有几百个对象,如果每个对象各持一张 PNG,就会有几百次贴图切换——每次切换都可能打断 GPU 的批处理(第 3 章详谈)。图集把多张小图打进一张大图,配合一份描述文件记录每张子图的位置,运行时只加载一张纹理、按矩形切用。
描述文件可以是一份 JSON:
{
"image": "blocks.png",
"frames": [
{ "name": "player_idle", "x": 0, "y": 0, "w": 64, "h": 64 },
{ "name": "player_run", "x": 64, "y": 0, "w": 64, "h": 64 },
{ "name": "coin", "x": 128, "y": 0, "w": 32, "h": 32 },
{ "name": "brick", "x": 160, "y": 0, "w": 32, "h": 32 }
]
}
对应的类型与切分函数:
// src/render/atlas.ts
export interface AtlasFrame {
name: string;
x: number;
y: number;
w: number;
h: number;
}
export interface AtlasData {
image: string;
frames: AtlasFrame[];
}
export function indexFrames(data: AtlasData): Map<string, AtlasFrame> {
const map = new Map<string, AtlasFrame>();
for (const frame of data.frames) {
map.set(frame.name, frame);
}
return map;
}
Map 让「按名字取帧」是 O(1),避免每帧线性查找。实战中还有两个常见细节:
- 内边距(padding):子图之间留 1–2 像素,防止相邻纹理在缩放采样时互相「渗色」(bleeding)。描述里
x/y已是含 padding 的绝对坐标,切分逻辑不用关心。 - trim(去透明边):把子图四周的透明像素裁掉,省空间。代价是记录一个
offset,绘制时补回去——Cocos 的SpriteFrame正好有offset属性做这件事,见 2.2.7。
2.2.4 锚点(pivot):旋转中心在哪
2.1 里我们把矩形画在 -w/2, -h/2,让它的中心对齐原点。但如果主角的锚点在脚底(这样旋转和缩放都绕脚下,更像站在地上),偏移就得另算。锚点用归一化坐标 (px, py) 表示,取值 0 到 1:
(0, 0):左上角。(0.5, 0.5):中心(默认)。(0.5, 1):底边中点。(1, 1):右下角。
把锚点翻译成绘制偏移,就是 -sw * px 和 -sh * py。把它接进精灵:
// src/render/sprite.ts
import { Mat2D } from '../math/mat2d';
export class Sprite {
image: CanvasImageSource;
sx: number;
sy: number;
sw: number;
sh: number;
pivotX = 0.5;
pivotY = 0.5;
constructor(image: CanvasImageSource, sx: number, sy: number, sw: number, sh: number) {
this.image = image;
this.sx = sx;
this.sy = sy;
this.sw = sw;
this.sh = sh;
}
draw(ctx: CanvasRenderingContext2D, m: Mat2D, scaleX = 1, scaleY = 1): void {
ctx.setTransform(m.a, m.b, m.c, m.d, m.tx, m.ty);
const ox = -this.sw * this.pivotX * scaleX;
const oy = -this.sh * this.pivotY * scaleY;
ctx.drawImage(
this.image,
this.sx, this.sy, this.sw, this.sh,
ox, oy, this.sw * scaleX, this.sh * scaleY,
);
}
}
一个常见的困惑是「锚点到底影响谁」。它只影响绘制时的偏移,不影响矩阵本身——对象的 position 永远是锚点所在的那个点。所以把主角锚点设成 (0.5, 1) 后,position 指的是它的脚底,position.y = 0 就是「站在地平线上」,非常直观。
2.2.5 九宫格:可拉伸的 UI 底板
UI 里的对话框、按钮底图,往往要按内容长度拉伸。直接拉伸会拉变形(圆角变成椭圆)。九宫格(nine-slice)把一张图切成 3×3 九块,四个角保持原尺寸,四条边只在一个方向拉伸,中心块两个方向都拉伸:
// src/render/nine-slice.ts
export interface Insets {
left: number;
right: number;
top: number;
bottom: number;
}
export function drawNineSlice(
ctx: CanvasRenderingContext2D,
image: CanvasImageSource,
sx: number, sy: number, sw: number, sh: number,
insets: Insets,
dx: number, dy: number, dw: number, dh: number,
): void {
const { left, right, top, bottom } = insets;
const cw = sw - left - right; // 源中心宽
const ch = sh - top - bottom; // 源中心高
const cdx = dw - left - right; // 目标中心宽
const cdy = dh - top - bottom; // 目标中心高
// 左上角(不拉伸)
ctx.drawImage(image, sx, sy, left, top, dx, dy, left, top);
// 顶边(横向拉伸)
ctx.drawImage(image, sx + left, sy, cw, top, dx + left, dy, cdx, top);
// 右上角
ctx.drawImage(image, sx + sw - right, sy, right, top, dx + dw - right, dy, right, top);
// 左中、中心、右中、底边、四角同理,共九次 drawImage
}
九宫格的核心约束是目标尺寸不能小于两条边之和(dw >= left + right),否则中心宽为负,drawImage 会把图反向拉伸。生产代码里应对 cdx/cdy 做一次 Math.max(0, ...) 保护。
2.2.6 把精灵接进 mini2d
把 Sprite 挂到节点上,每帧按节点矩阵绘制即可:
// src/scene/sprite-node.ts
import { Mat2D } from '../math/mat2d';
import { Sprite } from '../render/sprite';
export class SpriteNode {
x = 0;
y = 0;
angle = 0;
scale = 1;
sprite: Sprite;
private readonly matrix = new Mat2D();
constructor(sprite: Sprite) {
this.sprite = sprite;
}
render(ctx: CanvasRenderingContext2D): void {
this.matrix
.identity()
.translate(this.x, this.y)
.rotate(this.angle)
.scale(this.scale, this.scale);
this.sprite.draw(ctx, this.matrix, this.scale, this.scale);
}
}
注意这里 matrix 是复用的实例,每帧 identity() 复位再叠变换——避免每帧 new 一个对象,减少 GC 压力。这个「对象池式」的小习惯在 60 FPS 下会累积成可观的收益。
2.2.7 Cocos 对照:Sprite、SpriteFrame 与 UITransform
Cocos Creator 3.8 把「精灵」拆成两个概念:Sprite 是组件(挂在节点上,负责渲染),SpriteFrame 是资源(描述「用哪张纹理的哪块矩形」)。SpriteFrame 对应我们手写的 AtlasFrame + Sprite 的组合。在编辑器里拖一张图到 Sprite.spriteFrame 上,引擎自动生成 SpriteFrame。
脚本里配置一个九宫格金币:
// assets/scripts/CoinView.ts
import { _decorator, Component, Sprite, SpriteFrame, SpriteAtlas, UITransform } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('CoinView')
export class CoinView extends Component {
@property(SpriteFrame)
idleFrame: SpriteFrame | null = null;
@property(SpriteAtlas)
uiAtlas: SpriteAtlas | null = null;
start() {
const sprite = this.getComponent(Sprite);
if (!sprite) return;
sprite.spriteFrame = this.uiAtlas
? this.uiAtlas.getSpriteFrame('coin')
: this.idleFrame;
if (sprite.spriteFrame) {
sprite.spriteFrame.insetLeft = 8;
sprite.spriteFrame.insetRight = 8;
sprite.spriteFrame.insetTop = 8;
sprite.spriteFrame.insetBottom = 8;
}
sprite.type = Sprite.Type.SLICED;
const ui = this.getComponent(UITransform);
if (ui) {
ui.setAnchorPoint(0.5, 0.5);
ui.setContentSize(48, 48);
}
}
}
几个关键对应,逐个对照 mini2d:
- 锚点在 Cocos 里由
UITransform管,不在Sprite上。ui.setAnchorPoint(0.5, 1)等价于我们的pivotY = 1。默认是(0.5, 0.5)。 - 尺寸用
ui.setContentSize(w, h);它会覆盖精灵的原始尺寸。九宫格拉伸时,目标尺寸就是这个contentSize。 - 九宫格通过
SpriteFrame的insetLeft/Right/Top/Bottom四个值声明边框宽度,再把sprite.type设为Sprite.Type.SLICED(枚举值1;普通图用SIMPLE,平铺用TILED)。四个 inset 就是我们手写的Insets。 - 图集由
SpriteAtlas资源承载,atlas.getSpriteFrame('coin')按名字取帧,正好对应indexFrames(...).get('coin')。 - trim 偏移由
SpriteFrame.offset记录,绘制时自动补回,不用手写。
一句话总结:mini2d 把「纹理 + 矩形 + 锚点」塞进一个 Sprite 类;Cocos 把它们拆成 Sprite(组件)+ SpriteFrame(资源)+ UITransform(尺寸与锚点)三个协作对象。拆分的好处是资源可以复用、可在编辑器里可视化配置,代价是新手容易搞不清「锚点到底改哪个组件」。
下一节我们给世界装上一台相机——讲世界坐标怎么映射到屏幕坐标、视口矩形、缩放,以及横竖屏与高分屏适配。
阅读导航:上一节:2.1 Canvas2D 绘制与坐标变换 · 下一节:2.3 相机、视口与分辨率适配 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。