跳转到内容

精灵与渲染

Sprite 是 2D 渲染的主力:一个带 Sprite 组件的实体在世界里绘制一个带纹理的四边形。要绘制 不带纹理的实心图元,有 ShapeRenderer。一切都经由 相机 绘制, 而绘制顺序由排序层控制。

一个精灵需要 Transform(在哪)和 Sprite(画什么)。通过 Assets 资源加载一张纹理,把它的句柄放到精灵上:

import { defineSystem, Commands, Res, Query, Mut, Transform, Sprite, Assets } from 'esengine';
const spawnPlayer = defineSystem([Commands(), Res(Assets)], async (cmds, assets) => {
const tex = await assets.loadTexture('textures/player.png'); // { handle, width, height }
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(Sprite, { texture: tex.handle, size: { x: tex.width, y: tex.height } });
});

sprite.texture 是一个纹理句柄,而不是路径——你从加载器(assets.loadTexture(ref))拿到它。 在编辑器里你不用写这个:从内容浏览器把一张图拖到实体上,或在 Details 里设精灵的 texture 字段。

字段 默认 说明
texture 0 加载器给的纹理句柄(0 = 无纹理,画白色四边形)。
color {1,1,1,1} 乘进纹理的着色(白 = 不变)。
size {100,100} 渲染尺寸,世界单位。
pivot {0.5,0.5} 精灵旋转/缩放所绕的轴心点(0–1)。
layer 0 排序层——控制绘制顺序(见下)。
flipX / flipY false 水平 / 垂直镜像。
material 0 自定义材质句柄(0 = 默认精灵着色器)。
lit false 用平面法线接收 2D 光照——让精灵变“受光”的一键路径(自定义 material 会覆盖它)。
uvOffset / uvScale {0,0} / {1,1} 要显示的纹理子矩形(用于图集 / 滚动)。
tileSize / tileSpacing {0,0} 把纹理在精灵上平铺重复(0 = 不平铺)。
parallax {1,1} 视差滚动系数(见下)。
enabled true 不移除也能隐藏组件。

color 是叠加在纹理上的 RGBA 乘子。白({1,1,1,1})原样绘制;给它染色,或降低 alpha 让它淡出:

sprite.color = { r: 1, g: 0.4, b: 0.4, a: 1 }; // 泛红
sprite.color = { r: 1, g: 1, b: 1, a: 0.5 }; // 50% 透明

color(和 size)都可动画——在 Sequencer 里给它们打关键帧 做闪烁和淡入淡出。

size 是四边形的世界单位尺寸,与纹理的像素尺寸无关——设成 { tex.width, tex.height } 得到 1:1,或设成别的值来缩放。Transform 上的缩放会再乘上去。pivot 是精灵旋转/缩放所绕的归一化 轴心(0–1):{0.5, 0.5} 居中,{0, 0} 是左下角,{0.5, 0} 钉住底部中心(适合站在地面上的角色)。

同一个精灵绕三个不同轴心旋转——轴心点(黄点)保持不动、四边形绕它摆动=原地旋转,角点=甩出去,底部中心=钉住脚下

世界单位就是设计像素。把纹理拖进视口会生成一个尺寸等于纹理像素尺寸的精灵 (size = { tex.width, tex.height }),而编辑器的相机按“一个世界单位 = 一个设计像素” 创作(orthoSize = designHeight / 2)。因此 Sprite.size 的单位是设计像素,pivot 是相对该尺寸的归一化比例。世界坐标 Y 轴朝上——屏幕向上是正 Y——所以 pivot: {0, 0}左下角,不是左上角。

当精灵重叠时,绘制顺序决定谁在上面。顺序由排序层(sprite.layer)决定——一个你在 项目设置 → 渲染里定义的命名层列表,于是检视器显示 BackgroundDefaultForeground 等 下拉,而不是裸数字。较低的层先画(在后面)。

左,更高的层画在前面。右 Y-排序层上,屏幕上更靠下的精灵画在更靠上的前面。

sprite.layer = 0; // 例如 "Background"
sprite.layer = 2; // 例如 "Foreground" —— 画在上面

俯视视角游戏在 Project Settings → Rendering → Y-sorted layers 勾选某个层后,该层上的 精灵、图形与文本都按世界 Y 坐标排序绘制:屏幕上越靠下的实体画在越上层,角色走到树下方时 自然显示在树前——无需手动调 layer 或 z。

  • flipX / flipY 翻转精灵——例如让角色朝左或朝右,而不用另一张纹理。
  • 图集:把 uvOffsetuvScale 设为某个单元格的归一化矩形,显示打包图集里的一格。(逐帧动画 请用 Animator 驱动这些,而非手动。)
  • 滚动:随时间改 uvOffset 来滚动纹理(水面、传送带),并设 tileSize 把它在大精灵上重复平铺。

纹理的采样方式按纹理句柄设置,而非按精灵:

同一张 16×16 纹理放大 保留硬像素边缘,Linear 把它们混成平滑渐变

同一张小纹理放大约 15×。Nearest(最近邻) 保留硬的纹素边缘(像素画);Linear(线性) 在纹素间插值,得到平滑外观(默认)。

枚举 取值 含义
TextureFilter NearestLinear Nearest = 纹素边缘生硬(像素画);Linear = 平滑插值(默认)。
TextureWrap RepeatClampToEdgeMirroredRepeat 0–1 之外的 UV 采到什么——平铺重复,还是拉伸边缘像素。
import { TextureFilter, TextureWrap, setTextureFilter, setTextureWrap, setTextureParams } from 'esengine';
setTextureFilter(tex.handle, TextureFilter.Nearest); // 像素画保持锐利
setTextureWrap(tex.handle, TextureWrap.Repeat); // 0–1 之外平铺
// 上面两个便捷函数各自会把*另一组*参数重置为默认值
// (ClampToEdge / Linear)。要同时设置过滤和环绕,用完整形式:
setTextureParams(tex.handle, TextureFilter.Nearest, TextureFilter.Nearest,
TextureWrap.Repeat, TextureWrap.Repeat);

setTextureParams(textureId, minFilter, magFilter, wrapS, wrapT) 是完整接口—— 缩小/放大过滤分开,环绕逐轴设置。tileSize 平铺和 UV 滚动效果要无缝,靠的就是 Repeat

各环绕模式对 0–1 之外 UV 的采样 平铺纹理,Clamp to edge 拉伸边缘像素,Mirror 每隔一块翻转

各环绕模式如何处理 0–1 之外的 UV(中间那块 = 纹理本身):Repeat 平铺,Clamp to edge 拉伸边缘像素,Mirror 每隔一块镜像翻转。

parallax 缩放一个精灵相对相机的移动量:1 随世界移动,0 锁定到相机(固定背景板),中间的值 滚动更慢以产生纵深。把几张精灵分别设为 0.20.50.8,就得到经典的视差背景。

sprite.material 赋值,用你自己的着色器绘制精灵——染色、溶解、扭曲——见 材质与着色器

要让精灵响应 Light2D 光源,最简单的路径是设 sprite.lit = true——无需自定义材质 (用平面法线受光)。需要法线贴图或完全自定义的观感时,改用 Lit-2D 材质。两种方式都见 2D 光照与阴影

最常见的逐精灵效果不用写着色器——SpriteFilter 直接构建一个现成材质,赋给 sprite.material:

import { SpriteFilter } from 'esengine';
sprite.material = SpriteFilter.createOutline({ color: { r: 1, g: 1, b: 1, a: 1 }, width: 2 });
sprite.material = SpriteFilter.createGlow(); // 暖色描边预设
sprite.material = SpriteFilter.createDropShadow({ offsetX: 3, offsetY: 3, blur: 2 });

setOutlineColor / setOutlineWidth / setShadowOffset / setShadowBlur 实时调一个已生效的滤镜(选中脉冲、受击闪白)。每次调用都创建一个材质——观感相同 的精灵共享同一个句柄;纹理不是 512×512 时传 texelSize: { x: 1/texWidth, y: 1/texHeight } 以获得精确的像素宽度。

要画实心圆、胶囊和圆角矩形——占位、进度条、调试覆盖——用 ShapeRenderer 而不是纹理 (ShapeType.CircleCapsuleRoundedRect):

import { ShapeRenderer, ShapeType } from 'esengine';
cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(ShapeRenderer, {
shapeType: ShapeType.RoundedRect,
size: { x: 200, y: 80 },
cornerRadius: 12,
color: { r: 0.2, g: 0.6, b: 1, a: 1 },
});

ShapeRendererSprite 共享排序 layercolor 约定。

TrailRenderer 沿实体最近的路径画一条世界空间条带——刀光、冲刺、弹道 残影。加上组件、移动实体即可;位置历史由引擎记录,锥形收窄也替你渲染:

cmds.spawn()
.insert(Transform, { position: { x: 0, y: 0, z: 0 } })
.insert(TrailRenderer, { time: 0.4, startWidth: 24, blendMode: 1 }); // 加法发光
字段 默认 说明
time 0.5 每个拖尾点存活的秒数,之后从尾部淡出。
minVertexDistance 5 记录新点所需的最小移动距离(0 = 每帧记录)。
emitting true false 冻结发射——条带脱离实体、原地淡出。
startWidth / endWidth 20 / 0 头 / 尾的条带宽度(0 收窄成线)。
startColor / endColor 白 / 白 α 0 头到尾插值;尾部 alpha 0 即淡出。
texture 0 沿条带采样的纹理(U 从头到尾);0 = 仅顶点色。
blendMode 0 0 正常、1 加法(发光)、2 乘法。
layer / material 0 / 0 常规排序层 / 自定义材质。

Trail 资源(Res(Trail))提供 clear(entity),立刻丢弃某实体的历史——比如 传送时,免得拖尾划过整张地图。

BitmapText 用预烘焙的位图字体渲染世界空间文字——伤害数字、名牌、复古 UI。把 font 指向一个 BMFont 资产(.fnt 文本或 .bmfont JSON),设置 text 即可:

字段 默认 说明
text '' 要绘制的字符串。
font 0 位图字体资产(.fnt / .bmfont)。
fontSize 1 相对字体原生尺寸的缩放。
align Left Left / Center / Right
spacing 0 逐字符的额外间距。
color {1,1,1,1} 着色——可动画(闪一下伤害数字)。
layer / parallax 0 / {1,1} Sprite 相同的排序层 / 视差语义。

屏幕空间的 UI 文字(布局、换行、SDF 锐度)请用 UI 的 Text 控件——见 UI

“这次点击落在这个精灵上了吗?“分两步:把屏幕点转到世界空间,再对精灵的矩形做测试。 第一步由 CameraView 资源完成(见相机);第二步 SDK 导出了感知轴心的点测试:

函数 测试
pointInWorldRect(px, py, worldX, worldY, worldW, worldH, pivotX, pivotY) 由位置 + 轴心放置的轴对齐矩形(未旋转精灵的精确占位)。
pointInOBB(…, rotationZ, rotationW) 同一矩形加旋转——传变换四元数的 z/w;未旋转时退化为矩形测试。
pointInHitArea(px, py, area) 自定义 HitAreaShape——rectcirclepolygon
import { defineSystem, Query, Res, CameraView, Input, Transform, Sprite, pointInOBB } from 'esengine';
const clickSprites = defineSystem(
[Query(Transform, Sprite), Res(CameraView), Res(Input)],
(q, view, input) => {
if (!input.isMouseButtonPressed(0)) return;
const p = view.screenToWorld(input.mouseX, input.mouseY);
if (!p) return;
for (const [entity, t, s] of q) {
const w = s.size.x * t.worldScale.x, h = s.size.y * t.worldScale.y;
if (pointInOBB(p.x, p.y, t.worldPosition.x, t.worldPosition.y,
w, h, s.pivot.x, s.pivot.y,
t.worldRotation.z, t.worldRotation.w)) {
console.log('hit', entity);
}
}
});

对可点击区域不是矩形的精灵——不规则角色、环形按钮——用精灵本地坐标描述一个 HitAreaShape,再测试本地化后的点:

import { pointInHitArea, type HitAreaShape } from 'esengine';
const hull: HitAreaShape = { type: 'polygon', points: [0, 0, 64, 0, 64, 48, 32, 64, 0, 48] };
// 未旋转的精灵,减去它的原点即可本地化:
const hit = pointInHitArea(p.x - t.worldPosition.x, p.y - t.worldPosition.y, hull);

screenToWorld 也有独立导出(接收逆 view-projection 矩阵和视口矩形),供自己管理 相机数学的代码使用——CameraView 资源就是同一个函数、替你填好了当前相机。至于 交互式 UI,别手写这些:Interactable + UIEvents 会替你做拾取(见 UI)。

CacheAsBitmap 支持把实体上昂贵的内容渲染一次进离屏纹理,之后只画这一个 四边形——经典的 cache-as-bitmap 交换:用 GPU 内存(width × height RGBA)换每帧 绘制开销。当内容每帧都画很贵(大量矢量路径、很多形状或文字)但很少变化时划算; 内容每帧都变则毫无收益——你每次都得重渲缓存,多付一次贴图开销。

字段 默认 说明
enabled true 该实体的缓存是否生效。
dirty true 设为 true 请求重新渲染缓存。
width / height 256 缓存纹理的像素尺寸。

这个组件是记账的一半——SDK 不内置自动遍历场景缓存子树的系统。刷新由你用导出的 辅助函数驱动:逐实体缓存仓库(getCacheForEntitysetCacheForEntityremoveCacheForEntityclearAllCaches)加 CacheBitmap——一个轻薄的渲染目标 包装(createbeginDraw/endDrawresizerelease):

import { CacheBitmap, getCacheForEntity, setCacheForEntity, Draw } from 'esengine';
// 刷新(仅在 dirty 时):
let cache = getCacheForEntity(entity);
if (!cache) {
cache = CacheBitmap.create(512, 256);
setCacheForEntity(entity, cache);
}
CacheBitmap.beginDraw(cache, viewProjection); // 一个框住内容的正交矩阵
// … 用 Draw / Graphics 把昂贵内容画一次 …
CacheBitmap.endDraw();
// 此后每帧:画一个贴图四边形,而不是整套重绘。
Draw.texture(pos, { x: 512, y: 256 }, cache.textureId);

cache.textureId 是普通的纹理句柄——也可以直接当 Sprite.texture 用。底层就是一个 不带深度缓冲的 RenderTexture

  • 创建 → 精灵 添加一个精灵实体,或从内容浏览器把图拖进视口,生成一个已带纹理的精灵。
  • 上面每个字段都能在 Details 里编辑;精灵、相机、光源在视口里显示为 gizmo。见 编辑器