跳转到内容

变换、单位与坐标系

每个存在于世界中的实体都带有一个 Transform——它的位置、旋转与缩放。本页是其他指南 的地基:Transform 各字段的含义、“世界单位”到底是什么(设计像素)、米制从哪里出现 (物理)、Y 轴指向哪边,以及变换如何沿父子层级复合。

Transform 同时存储你写入的**局部(local)值和引擎计算出的世界(world)**值。 局部字段相对于父实体(没有父实体时相对于世界原点);每一帧变换系统沿层级向下复合, 把结果写进世界字段。

属性 类型 默认值 说明
position Vec3 {0, 0, 0} 局部位置,单位是世界单位,相对于父实体。z 用于在同一排序层内细分绘制顺序。
rotation Quat {w: 1, x: 0, y: 0, z: 0} 局部旋转,四元数表示(单位四元数 = 无旋转)。2D 下绕 Z 轴旋转——见 2D 旋转
scale Vec3 {1, 1, 1} 逐轴局部缩放(1 = 原始尺寸;负值翻转)。
worldPosition Vec3 {0, 0, 0} **只读。**最终世界空间位置,由引擎复合得出。
worldRotation Quat 单位四元数 **只读。**最终世界空间旋转。
worldScale Vec3 {1, 1, 1} **只读。**最终世界空间缩放。

写局部字段,读世界字段。世界字段是引擎的输出——写它们不会有持久效果,因为变换系统 每帧都会根据局部字段和父链重新计算。没有父实体的根实体,世界值等于局部值。

import { defineSystem, Query, Mut, Transform } from 'esengine';
const drift = defineSystem([Query(Mut(Transform))], (q) => {
for (const [entity, t] of q) {
t.position = { x: t.position.x + 1, y: t.position.y, z: t.position.z }; // write local
console.log(t.worldPosition.x); // read world
}
});

position.x/y/zrotation.zscale.x/y/z 是可动画字段(Sequencer补间可以打关键帧),而 position / rotation / scale 会被网络的状态复制同步。

  • 世界空间 Y 轴向上:+Y 朝上,+X 朝右,角度 0 指向 +X,正角度逆时针 (Math.atan2(dy, dx) 可直接得到指向目标的角度)。
  • position.z 是绘制顺序,不是透视:2D 渲染先按各渲染器的排序层 layer 粗排; 同一层内用 z 做精细排序(俯视角深度请用 y 排序层)。它不会使精灵产生 透视缩放。
  • 屏幕空间 Y 轴向下:指针坐标(Input.mouseX/mouseY、UI 命中测试)是从画布 左上角起算的像素,Y 向下增大。

CameraView 资源在两个空间之间转换(完整 API 见 相机):

import { defineSystem, Res, CameraView, Input } from 'esengine';
const pick = defineSystem([Res(CameraView), Res(Input)], (view, input) => {
// Screen px (Y-down, top-left origin) -> world units (Y-up). null if no camera.
const world = view.screenToWorld(input.mouseX, input.mouseY);
if (world) { /* world.x, world.y */ }
});

单位——设计像素、世界单位与米

Section titled “单位——设计像素、世界单位与米”

Estella 只有一种创作用空间单位:设计像素。以及一种派生单位给物理用:米。

screen px (Y-down) ── CameraView.screenToWorld ──▶ world units = design px (Y-up)
world / design px ── ÷ Canvas.pixelsPerUnit (100) ──▶ physics meters (Box2D)

**1 世界单位 = 1 设计像素。**默认设计分辨率是 1920×1080(Canvas.designResolution), 编辑器场景相机的创作默认 orthoSize = designResolution.y / 2 = 540——相机恰好看到 一个设计分辨率大小的世界。实际含义:

  • 把纹理拖进场景会按其像素尺寸生成精灵(128×128 的图片在默认缩放下就是 128×128 个世界单位)。
  • UINode 的 px 尺寸在场景相机下与设计像素 1:1 对应。
  • 玩法代码里的距离、偏移、速度都以设计像素计(px 与 px/秒)。

物理是米制的。Canvas.pixelsPerUnit(默认 100)是每物理对应的世界像素 数——它缩放 Box2D 世界和瓦片碰撞体。它不是精灵的显示缩放。需要记住的分界:

物理接口 单位
碰撞体尺寸(halfExtentsradius 等) (像素 ÷ PPU)
面向求解器的方法——力、冲量、速度、setGravity
空间查询——raycastshapeCast*overlapCircleoverlapAABB 世界像素(自动换算)
CharacterController.velocity / moveCharacter 世界像素/秒

这些默认值以常量导出,需要它们的代码不必硬编码数字:

import { DEFAULT_DESIGN_WIDTH, DEFAULT_DESIGN_HEIGHT, DEFAULT_PIXELS_PER_UNIT } from 'esengine';
DEFAULT_DESIGN_WIDTH; // 1920
DEFAULT_DESIGN_HEIGHT; // 1080
DEFAULT_PIXELS_PER_UNIT; // 100
const radiusMeters = 24 / DEFAULT_PIXELS_PER_UNIT; // a 24 px circle collider

这些常量直接读取引擎自身的 Canvas 默认值,因此不会与 C++ 数值漂移。

Transform.rotation 是四元数 {w, x, y, z}。纯 2D 旋转就是绕 Z 轴的旋转,只用到 zw 两个分量:

angle a (radians) -> { w: cos(a/2), x: 0, y: 0, z: sin(a/2) }

quat() 工厂函数构造——注意参数顺序:w 在最前 (quat(w = 1, x = 0, y = 0, z = 0)),与单位四元数默认值一致:

import { quat } from 'esengine';
const angle = Math.PI / 4; // 45° CCW
const rot = quat(Math.cos(angle / 2), 0, 0, Math.sin(angle / 2));

反向读回角度,有两个等价的辅助函数,都计算 2 * atan2(z, w):

辅助函数 签名 说明
quaternionToAngle2D (rz, rw) => number 由四元数的 z/w 求 Z 角度(弧度)。
facingFromQuat (z, w) => number 同样的数学;AI/感知模块的写法。
normalizeAngle (a) => number 把任意角度归一到 (-π, π]——用于最短转向差值。

完整示例——炮塔跟踪光标:

import {
defineSystem, defineTag, Query, Mut, Res,
Transform, CameraView, quat, facingFromQuat, normalizeAngle,
} from 'esengine';
const Turret = defineTag('Turret');
const aim = defineSystem([Query(Mut(Transform)).with(Turret), Res(CameraView)], (q, view) => {
const target = view.getWorldMousePosition();
if (!target) return;
for (const [entity, t] of q) {
const angle = Math.atan2(
target.y - t.worldPosition.y,
target.x - t.worldPosition.x,
);
t.rotation = quat(Math.cos(angle / 2), 0, 0, Math.sin(angle / 2));
// Or, to turn gradually: read the current facing and step toward the target.
const facing = facingFromQuat(t.rotation.z, t.rotation.w);
const delta = normalizeAngle(angle - facing); // shortest signed turn
}
});

想动画化旋转,TweenTarget.RotationZ弧度驱动 Z 角度(引擎每步重建四元数), Sequencer 给 rotation.z 打关键帧也是同样的语义——见 动画

实体通过 ParentChildren 组件构成一棵树,但你永远不要直接写这两个组件—— 用 world 的层级 API:

import { defineSystem, addStartupSystem, GetWorld, Transform } from 'esengine';
const build = defineSystem([GetWorld()], (world) => {
const ship = world.spawn('Ship');
world.insert(ship, Transform, { position: { x: 400, y: 300, z: 0 } });
const turret = world.spawn('Turret');
world.insert(turret, Transform, { position: { x: 0, y: 24, z: 0 } }); // 24 px above the ship
world.setParent(turret, ship);
});
addStartupSystem(build);
  • world.setParent(child, parent)——把 child 挂到(或改挂到)parent 下。 双侧维护:既写子实体的 Parent,也更新父实体的 Children 列表。防环—— 把实体挂到自己或自己的后代下会被静默忽略。
  • world.removeParent(entity)——脱离父实体,实体重新成为根。
  • world.despawn(entity)——销毁整棵子树,先子后父,不会留下孤儿继续渲染。

读取树结构时,Parent 持有 { entity }(父实体),Children 持有 { entities } (一个 Entity[])——像普通组件一样查询:

import { defineSystem, Query, Parent, Children, Transform } from 'esengine';
const followers = defineSystem([Query(Transform, Parent)], (q) => {
for (const [entity, t, parent] of q) { /* parent.entity */ }
});

每一帧变换系统从各个根开始深度优先遍历。对每个实体,先按 translate × rotate × scale(TRS)构建局部矩阵,再乘上父实体的世界矩阵:

world = parentWorld × T(position) × R(rotation) × S(scale)

所以子实体的 position 是在父实体旋转、缩放后的坐标系里度量的:父实体旋转 90° 时, 局部位于 {x: 24, y: 0} 的子实体在世界空间里出现在父实体上方 24 px 处。分解后的 结果写入 worldPosition / worldRotation / worldScale

  • 改挂父级保留局部值。setParent 不会为保持世界位姿而重算局部字段——局部为 {0, 0} 的子实体会跳到新父实体的位置。想让实体在视觉上原地不动,请在改挂前自行 把它的世界位置换算进新父实体的坐标系。
  • 绝不要直接插入 Parentworld.insert(entity, Parent, …) 只写子侧链接, 父实体的 Children 列表会失去同步——而引擎的一半(布局、渲染、销毁)都在遍历 Children。永远用 world.setParent
  • **兄弟顺序在移除后不稳定。**摘除一个子实体是对 Children 做 swap-remove,会打乱 剩余兄弟的次序。不要在 Children 顺序里编码含义;绘制顺序用 position.z 或 排序层表达。

scale 逐轴生效。{2, 2, 1} 均匀放大一倍;{2, 1, 1} 水平拉伸;负值镜像 ({-1, 1, 1} 水平翻转——不过对精灵而言,Sprite.flipX 不动变换就能做到)。

缩放通过 worldScale 沿层级向下复合:挂在 {2, 2, 1} 父实体下的子实体以自身缩放 的两倍渲染,其局部 position 也会被父实体的缩放拉伸(局部 {x: 10, y: 0} 在世界 空间落在 20 px 之外)。

  • **写局部,读世界。**把 worldPosition / worldRotation / worldScale 当作输出; 所有创作性修改都走 position / rotation / scale
  • 玩法逻辑用设计像素思考——位置、速度、距离——只在物理边界处换算 (碰撞体尺寸与求解器调用 ÷ pixelsPerUnit)。
  • world.setParent,不要手写 Parent 插入。
  • 写 Transform 的查询里Mut(...) 包裹,让变更检测和渲染器看到更新。
  • position.z 只用于同一排序层内的先后细分;粗粒度排序用排序层 (或 y 排序层)。
  • 组件——组件如何声明与存储数据。
  • 相机——orthoSize、跟随与屏幕 ↔ 世界转换。
  • 精灵与渲染——排序层、y 排序与绘制顺序。
  • 物理——单位制中以米为单位的那一侧。
  • 数学辅助——在规范类型上做向量与旋转数学。