《Cocos 游戏开发入门》2.2 精灵、纹理与图集切分

本节把 drawImage 从三参讲到九参,讲清源矩形与目标矩形如何切出图集里的一帧;用纯 TypeScript 实现 Sprite 的锚点偏移与九宫格拉伸,给出图集 JSON 描述与切分函数;最后对照 Cocos Creator 3.8 的 Sprite、SpriteFrame、SpriteAtlas 与 UITransform,说明锚点与九宫格在编辑器里怎么配。

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 的矩阵结合起来,精灵的绘制流程就清楚了:

  1. 用 setTransform 设置对象的变换矩阵(位置 / 旋转 / 缩放)。
  2. 用九参 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 相机、视口与分辨率适配 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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