上一篇:网格 Part 1 | 下一篇:二进制网格格式 | 返回目录
快速导航
目录
- 简介
- 学习目标
- 变换基础
- 什么是变换
- 变换组件
- 变换矩阵
- 局部空间与世界空间
- 坐标空间层级
- 空间变换
- 矩阵组合顺序
- 父子关系系统
- 场景图结构
- 父子层级
- 变换传播
- Transform数据结构
- Transform组件定义
- Transform API
- 内存布局
- 矩阵计算与缓存
- 局部矩阵计算
- 世界矩阵计算
- Dirty标记优化
- 场景层级更新
- 深度优先遍历
- 递归更新
- 更新策略
- 实际应用场景
- 常见问题
- 练习
简介
在之前的教程中,我们实现了网格系统,可以加载复杂的 3D 模型。但每个网格都是独立的,无法建立层级关系。在实际游戏开发中,我们经常需要父子关系 (Parent-Child Hierarchy):
- 角色的手臂跟随身体移动
- 车轮随车辆旋转
- UI 元素相对于父容器定位
- 相机跟随玩家移动
本教程将实现变换系统 (Transform System) 和父子关系 (Parenting),这是构建复杂场景图 (Scene Graph) 的基础。
graph TBsubgraph "Scene Hierarchy 场景层级"Root[Root Node
根节点]Character[Character
角色
position: 0,0,0
rotation: 0°]Body[Body
身体
local: 0,0,0]Head[Head
头部
local: 0,2,0]Arm[Arm
手臂
local: 1,1.5,0
rotation: 45°]Hand[Hand
手
local: 0,-1,0]Vehicle[Vehicle
车辆
position: 10,0,0]Wheel1[Wheel FL
前左轮
local: -1,0,1]Wheel2[Wheel FR
前右轮
local: 1,0,1]endRoot --> CharacterRoot --> VehicleCharacter --> BodyCharacter --> HeadCharacter --> ArmArm --> HandVehicle --> Wheel1Vehicle --> Wheel2style Root fill:#ffa500style Character fill:#4a90e2style Vehicle fill:#4a90e2style Arm fill:#50c878style Hand fill:#50c878
核心概念:
变换传播 (Transform Propagation):
┌─────────────────────────────┐
│ Parent Transform │
│ Position: (5, 0, 0) │
│ Rotation: 45° │
│ Scale: (1, 1, 1) │
└──────────────┬──────────────┘│ 变换传播▼
┌─────────────────────────────┐
│ Child Local Transform │
│ Position: (2, 0, 0) │
│ (相对于父节点) │
└──────────────┬──────────────┘│ 组合矩阵▼
┌─────────────────────────────┐
│ Child World Transform │
│ Position: (5, 0, 0) + rotate(2,0,0, 45°) │
│ ≈ (6.41, 1.41, 0) │
│ Rotation: 45° │
└─────────────────────────────┘
关键公式:world_matrix = parent_world_matrix × local_matrix
学习目标
| 目标 | 描述 |
|---|---|
| 理解变换组件 | 掌握位置、旋转、缩放三要素 |
| 区分坐标空间 | 理解局部空间和世界空间的区别 |
| 实现父子关系 | 构建场景图的层级结构 |
| 变换矩阵传播 | 实现从父节点到子节点的矩阵传播 |
| 优化矩阵计算 | 使用 dirty 标记避免重复计算 |
变换基础
什么是变换
变换 (Transform) 描述了物体在 3D 空间中的位置、旋转和缩放:
变换的三要素:
┌──────────────────────────────┐
│ 1. Position (位置) │
│ • vec3: (x, y, z) │
│ • 物体在空间中的位置 │
│ • 例: (5, 0, -10) │
└──────────────────────────────┘
┌──────────────────────────────┐
│ 2. Rotation (旋转) │
│ • quat: (x, y, z, w) │
│ • 物体的朝向 │
│ • 例: (0, 0.707, 0, 0.707)│
│ ↑ 绕 Y 轴旋转 90° │
└──────────────────────────────┘
┌──────────────────────────────┐
│ 3. Scale (缩放) │
│ • vec3: (sx, sy, sz) │
│ • 物体的大小 │
│ • 例: (2, 1, 1) 宽度翻倍 │
└──────────────────────────────┘
可视化:
原始立方体 (单位变换):┌───┐╱│ ╱│╱ │ ╱ │┌───┐ ││ │ ││ │ │└───┘ │
Position: (0, 0, 0)
Rotation: (0, 0, 0, 1)
Scale: (1, 1, 1)
应用位置变换 (5, 0, 0):┌───┐╱│ ╱│╱ │ ╱ │┌───┐ │ ← 向右移动 5 个单位│ │ ││ │ │└───┘ │
应用旋转 (绕 Y 轴 45°):┌───┐╱│ ╱│╱ │ ╱ │ ← 旋转 45°┌───┐ ││ │╱│ │└───┘
应用缩放 (2, 1, 1):┌────────┐╱│ ╱│╱ │ ╱ │ ← 宽度翻倍┌────────┐ ││ │ ││ │ │└────────┘ │
变换组件
变换组件包含局部和世界两种状态:
// engine/src/core/transform.h
typedef struct transform {
// ========== 局部变换 (相对于父节点) ==========
vec3 position; // 局部位置
quat rotation; // 局部旋转 (四元数)
vec3 scale; // 局部缩放
// ========== 世界变换 (场景全局坐标) ==========
// 这些值在父子关系中自动计算
// ========== 变换矩阵缓存 ==========
mat4 local; // 局部变换矩阵 (TRS 组合)
mat4 world; // 世界变换矩阵 (parent.world × local)
// ========== 父子关系 ==========
struct transform* parent; // 父节点指针 (NULL 表示根节点)
// ========== 优化标记 ==========
b8 is_dirty; // 标记变换是否需要重新计算
} transform;
字段说明:
| 字段 | 类型 | 说明 |
|---|---|---|
position | vec3 | 局部位置 (相对于父节点的偏移) |
rotation | quat | 局部旋转 (四元数,避免万向锁) |
scale | vec3 | 局部缩放 (可以非均匀缩放) |
local | mat4 | 局部变换矩阵 (从 TRS 计算) |
world | mat4 | 世界变换矩阵 (结合父节点) |
parent | transform* | 父节点指针 |
is_dirty | b8 | Dirty 标记 (优化计算) |
变换矩阵
变换矩阵是一个 4x4 矩阵,用于表示 3D 变换:
变换矩阵组成:
┌─────────────────────────────────────┐
│ 4x4 Transform Matrix │
│ │
│ ┌─────────────┬──────────┐ │
│ │ Rotation │ Position │ ← 右侧列是位置
│ │ + Scale │ │ │
│ │ (3x3) │ (3x1) │ │
│ ├─────────────┼──────────┤ │
│ │ 0 0 0 │ 1 │ ← 底部行固定
│ └─────────────┴──────────┘ │
│ │
│ 示例 (单位矩阵): │
│ ┌ ┐ │
│ │ 1 0 0 0 │ │
│ │ 0 1 0 0 │ │
│ │ 0 0 1 0 │ │
│ │ 0 0 0 1 │ │
│ └ ┘ │
└─────────────────────────────────────┘
局部变换矩阵计算 (TRS 组合):
// 计算局部变换矩阵
// 顺序: Scale → Rotate → Translate
mat4 transform_get_local(const transform* t) {
// 1. 从四元数创建旋转矩阵
mat4 rotation_matrix = quat_to_mat4(t->rotation);
// 2. 应用缩放
rotation_matrix.data[0] *= t->scale.x; // 第1列 × scale.x
rotation_matrix.data[1] *= t->scale.x;
rotation_matrix.data[2] *= t->scale.x;
rotation_matrix.data[4] *= t->scale.y; // 第2列 × scale.y
rotation_matrix.data[5] *= t->scale.y;
rotation_matrix.data[6] *= t->scale.y;
rotation_matrix.data[8] *= t->scale.z; // 第3列 × scale.z
rotation_matrix.data[9] *= t->scale.z;
rotation_matrix.data[10] *= t->scale.z;
// 3. 设置位置 (第4列)
rotation_matrix.data[12] = t->position.x;
rotation_matrix.data[13] = t->position.y;
rotation_matrix.data[14] = t->position.z;
return rotation_matrix;
}
TRS 顺序的重要性:
正确顺序: Scale → Rotate → Translate
┌────────────────────────────────────┐
│ 1. Scale (缩放) │
│ ┌─┐ → ┌──┐ │
│ └─┘ └──┘ │
└──────────┬─────────────────────────┘│▼
┌────────────────────────────────────┐
│ 2. Rotate (旋转) │
│ ┌──┐ → ╱──╲ │
│ └──┘ ╲──╱ │
└──────────┬─────────────────────────┘│▼
┌────────────────────────────────────┐
│ 3. Translate (平移) │
│ ╱──╲ → ╱──╲ │
│ ╲──╱ ╲──╱ │
└────────────────────────────────────┘
错误顺序: Translate → Rotate → Scale╱──╲ ╱────╲╲──╱ → ╲────╱ ← 旋转中心错误!↑ 先移动 ↑ 绕原点旋转,不是绕自身
局部空间与世界空间
坐标空间层级
3D 场景中有多个坐标空间:
坐标空间层级:
┌─────────────────────────────────────┐
│ Model/Local Space (模型/局部空间) │
│ • 模型本身的坐标系 │
│ • 原点通常在模型中心 │
│ • 例: 角色的手臂在身体坐标系中 │
└──────────────┬──────────────────────┘│ Local Matrix▼
┌─────────────────────────────────────┐
│ World Space (世界空间) │
│ • 场景的全局坐标系 │
│ • 所有物体在同一坐标系中 │
│ • 例: 角色在地图中的绝对位置 │
└──────────────┬──────────────────────┘│ View Matrix▼
┌─────────────────────────────────────┐
│ View Space (视图空间) │
│ • 相对于相机的坐标系 │
└──────────────┬──────────────────────┘│ Projection Matrix▼
┌─────────────────────────────────────┐
│ Clip Space (裁剪空间) │
│ • 归一化设备坐标 (NDC) │
└─────────────────────────────────────┘
局部空间示例:
角色的局部空间:↑ Y (上)│┌────┼────┐ ← 头部 (local: 0, 2, 0)│ │ │
───┼────●────┼─→ X (右)│ 身体 ││ │ │└────┼────┘╱│╲╱ │ ╲ ← 手臂 (local: 1, 1, 0)│Z (前)
• 头部相对于身体中心向上 2 个单位
• 手臂相对于身体中心向右 1 个单位,向上 1 个单位
世界空间示例:
场景的世界空间:↑ Y (上)│●────┼──── ← Character (world: 5, 0, 0)│● ← Tree (world: 10, 0, 5)
─────────●──────→ X (右)● │ ← Rock (world: 3, 0, 2)│Z (前)
• 所有物体在同一全局坐标系中
• Character 的头部在世界空间中: (5, 2, 0)
空间变换
局部空间到世界空间的变换:
// 无父节点:世界矩阵 = 局部矩阵
world_matrix = local_matrix
// 有父节点:世界矩阵 = 父节点世界矩阵 × 局部矩阵
world_matrix = parent_world_matrix × local_matrix
变换传播示例:
层级结构:Root└─ Character (position: 5, 0, 0, rotation: 45°)└─ Arm (local position: 2, 0, 0)└─ Hand (local position: 1, 0, 0)
计算过程:
1. Character 世界矩阵:world_character = translate(5, 0, 0) × rotate_y(45°)
2. Arm 世界矩阵:world_arm = world_character × translate(2, 0, 0)
3. Hand 世界矩阵:world_hand = world_arm × translate(1, 0, 0)
最终结果:Character 世界位置: (5, 0, 0)Arm 世界位置: (5 + rotate(2,0,0, 45°)) ≈ (6.41, 0, 1.41)Hand 世界位置: (6.41 + rotate(1,0,0, 45°)) ≈ (7.12, 0, 2.12)
矩阵组合顺序
矩阵乘法的顺序至关重要:
// ✓ 正确:从左到右是从祖先到子孙
world_matrix = grandparent × parent × child × local
// ✗ 错误:顺序颠倒
world_matrix = local × child × parent × grandparent // 错误!
为什么顺序重要?
矩阵乘法不可交换:A × B ≠ B × A
示例:先旋转后平移 vs 先平移后旋转
┌──────────────────────────────┐
│ 先旋转 45°,后平移 (2, 0, 0) │
│ │
│ ● │
│ ╱ ╲ → ●╱ ╲ │
│ ╱ ╲ ╱ ╲ │
│ │
│ Rotate × Translate │
└──────────────────────────────┘
┌──────────────────────────────┐
│ 先平移 (2, 0, 0),后旋转 45° │
│ │
│ ● → ● │
│ ╱ ╲ ╱ │
│ ╱ ╲ ╱ │
│ ╱ │
│ Translate × Rotate ← 绕原点 │
└──────────────────────────────┘
结果不同!
父子关系系统
场景图结构
场景图是一个树状结构,每个节点是一个 transform:
Scene Graph (场景图):Root (世界根节点)│┌───────────┼───────────┐│ │ │Character Tree Camera│┌───┴───┐Body Arm│Hand
特性:
• 每个节点有 0 或 1 个父节点
• 每个节点可以有多个子节点
• Root 节点没有父节点 (parent = NULL)
• 叶子节点没有子节点
实现方式:
Kohi 使用指针链接实现场景图:
typedef struct transform {
// ... 其他字段 ...
struct transform* parent; // 父节点指针 (单个)
// 注意:子节点列表由外部系统管理 (如 Scene System)
} transform;
为什么只存储 parent 而不存储 children?
优点:
✓ 内存效率:每个 transform 只需 1 个指针
✓ 简化逻辑:设置父节点时只需修改 1 个指针
✓ 遍历简单:从子节点向上遍历到根节点很容易
缺点:
✗ 从父节点向下遍历需要外部数据结构 (Scene System 维护子节点列表)
实际应用:
- Transform 组件只存储 parent
- Scene System 维护完整的场景图结构
- Entity System 可以查询某个 entity 的所有子 entity
父子层级
设置父子关系:
// engine/src/core/transform.c
/**
* @brief 设置父节点
* @param t 子节点
* @param parent 父节点 (NULL 表示设置为根节点)
*/
void transform_set_parent(transform* t, transform* parent) {
if (t->parent == parent) {
return; // 已经是该父节点
}
// 1. 更新父节点指针
t->parent = parent;
// 2. 标记为 dirty (需要重新计算世界矩阵)
t->is_dirty = true;
}
/**
* @brief 获取父节点
*/
transform* transform_get_parent(const transform* t) {
return t->parent;
}
/**
* @brief 检查是否是根节点
*/
b8 transform_is_root(const transform* t) {
return t->parent == NULL;
}
使用示例:
// 创建 transform
transform character;
transform_create(&character);
transform_set_position(&character, (vec3){5, 0, 0});
transform arm;
transform_create(&arm);
transform_set_position(&arm, (vec3){1, 1.5, 0}); // 局部位置
// 设置父子关系
transform_set_parent(&arm, &character); // arm 是 character 的子节点
// 更新变换
transform_update(&character); // 更新角色
transform_update(&arm); // 更新手臂 (自动使用父节点的世界矩阵)
// 获取世界位置
mat4 arm_world = transform_get_world(&arm);
// arm_world 包含 character 和 arm 的组合变换
变换传播
变换如何从父节点传播到子节点:
// engine/src/core/transform.c
/**
* @brief 更新世界变换矩阵
*/
void transform_update(transform* t) {
// 1. 检查是否需要更新
if (!t->is_dirty) {
return; // 没有改变,无需更新
}
// 2. 计算局部变换矩阵 (TRS)
t->local = mat4_mul_mat4(
mat4_translation(t->position),
mat4_mul_mat4(
quat_to_mat4(t->rotation),
mat4_scale(t->scale)
)
);
// 3. 计算世界变换矩阵
if (t->parent) {
// 有父节点:组合父节点的世界矩阵
t->world = mat4_mul_mat4(t->parent->world, t->local);
} else {
// 无父节点 (根节点):世界矩阵 = 局部矩阵
t->world = t->local;
}
// 4. 清除 dirty 标记
t->is_dirty = false;
}
变换传播流程:
层级更新流程:
┌────────────────────────────────┐
│ 1. 根节点更新 │
│ Root (parent = NULL) │
│ world = local │
└──────────────┬─────────────────┘│▼
┌────────────────────────────────┐
│ 2. 第一层子节点更新 │
│ Character │
│ world = Root.world × local │
└──────────────┬─────────────────┘│┌─────┴─────┐│ │▼ ▼
┌──────────────┐ ┌──────────────┐
│ 3. 第二层更新│ │ │
│ Arm │ │ Body │
│ world = │ │ │
│ Character. │ │ │
│ world × local│ │ │
└──────┬───────┘ └──────────────┘│▼
┌──────────────┐
│ 4. 第三层更新│
│ Hand │
│ world = │
│ Arm.world × │
│ local │
└──────────────┘
规则:
• 必须先更新父节点,再更新子节点
• 使用深度优先遍历 (DFS)
• 每个节点只更新一次 (除非标记为 dirty)
Transform数据结构
Transform组件定义
完整的 Transform 结构定义:
// engine/src/core/transform.h
typedef struct transform {
// ========== 局部变换 ==========
vec3 position; // 局部位置
quat rotation; // 局部旋转 (四元数)
vec3 scale; // 局部缩放
// ========== 矩阵缓存 ==========
mat4 local; // 局部变换矩阵 (缓存)
mat4 world; // 世界变换矩阵 (缓存)
// ========== 层级关系 ==========
struct transform* parent; // 父节点指针
// ========== 优化标记 ==========
b8 is_dirty; // Dirty 标记
} transform;
Transform API
Transform 的 API 设计:
// engine/src/core/transform.h
/**
* @brief 创建并初始化 transform (单位变换)
*/
KAPI transform transform_create(void);
/**
* @brief 从位置、旋转、缩放创建 transform
*/
KAPI transform transform_from_position_rotation_scale(vec3 position, quat rotation, vec3 scale);
// ========== 位置 API ==========
/**
* @brief 设置局部位置
*/
KAPI void transform_set_position(transform* t, vec3 position);
/**
* @brief 获取局部位置
*/
KAPI vec3 transform_get_position(const transform* t);
/**
* @brief 获取世界位置
*/
KAPI vec3 transform_get_world_position(const transform* t);
/**
* @brief 平移 (相对移动)
*/
KAPI void transform_translate(transform* t, vec3 translation);
// ========== 旋转 API ==========
/**
* @brief 设置局部旋转 (四元数)
*/
KAPI void transform_set_rotation(transform* t, quat rotation);
/**
* @brief 获取局部旋转
*/
KAPI quat transform_get_rotation(const transform* t);
/**
* @brief 设置局部旋转 (欧拉角,度数)
*/
KAPI void transform_set_rotation_euler(transform* t, vec3 euler_degrees);
/**
* @brief 旋转 (增量旋转)
*/
KAPI void transform_rotate(transform* t, quat rotation);
/**
* @brief 绕轴旋转
*/
KAPI void transform_rotate_axis_angle(transform* t, vec3 axis, f32 angle_radians);
// ========== 缩放 API ==========
/**
* @brief 设置局部缩放
*/
KAPI void transform_set_scale(transform* t, vec3 scale);
/**
* @brief 获取局部缩放
*/
KAPI vec3 transform_get_scale(const transform* t);
// ========== 层级 API ==========
/**
* @brief 设置父节点
*/
KAPI void transform_set_parent(transform* t, transform* parent);
/**
* @brief 获取父节点
*/
KAPI transform* transform_get_parent(const transform* t);
/**
* @brief 检查是否是根节点
*/
KAPI b8 transform_is_root(const transform* t);
// ========== 矩阵 API ==========
/**
* @brief 获取局部变换矩阵
*/
KAPI mat4 transform_get_local(const transform* t);
/**
* @brief 获取世界变换矩阵
*/
KAPI mat4 transform_get_world(const transform* t);
/**
* @brief 更新变换矩阵 (如果 dirty)
*/
KAPI void transform_update(transform* t);
内存布局
Transform 结构的内存布局:
transform 内存布局 (sizeof = 196 bytes):
┌─────────────────────────────────────────┐
│ Offset │ Field │ Type │ Size │
├────────┼──────────┼───────┼────────────┤
│ 0 │ position │ vec3 │ 12 bytes │
├────────┼──────────┼───────┼────────────┤
│ 12 │ rotation │ quat │ 16 bytes │
├────────┼──────────┼───────┼────────────┤
│ 28 │ scale │ vec3 │ 12 bytes │
├────────┼──────────┼───────┼────────────┤
│ 40 │ local │ mat4 │ 64 bytes │
├────────┼──────────┼───────┼────────────┤
│ 104 │ world │ mat4 │ 64 bytes │
├────────┼──────────┼───────┼────────────┤
│ 168 │ parent │ ptr │ 8 bytes │
├────────┼──────────┼───────┼────────────┤
│ 176 │ is_dirty │ b8 │ 1 byte │
│ │ (padding)│ │ 7 bytes │ ← 对齐到 8 bytes
└─────────────────────────────────────────┘
Total: 196 bytes per transform
性能考虑:
• 矩阵缓存 (128 bytes) 占大部分内存
• 但避免每帧重新计算矩阵
• Cache-friendly:常用字段 (position, rotation) 在前面
⚡ 矩阵计算与缓存
局部矩阵计算
从 TRS 计算局部矩阵:
// engine/src/core/transform.c
/**
* @brief 计算局部变换矩阵 (TRS 组合)
*/
mat4 transform_calculate_local(const transform* t) {
// 1. Translate (平移矩阵)
mat4 translation = mat4_translation(t->position);
// 2. Rotate (旋转矩阵,从四元数)
mat4 rotation = quat_to_mat4(t->rotation);
// 3. Scale (缩放矩阵)
mat4 scale = mat4_scale(t->scale);
// 4. 组合:T × R × S
// 注意:矩阵乘法顺序从右到左
return mat4_mul_mat4(translation, mat4_mul_mat4(rotation, scale));
}
优化版本 (直接构建矩阵):
/**
* @brief 优化的局部矩阵计算
* 直接构建 TRS 矩阵,避免多次矩阵乘法
*/
mat4 transform_calculate_local_optimized(const transform* t) {
// 1. 从四元数计算旋转矩阵
mat4 result = quat_to_mat4(t->rotation);
// 2. 应用缩放 (修改旋转矩阵的前 3 列)
result.data[0] *= t->scale.x;
result.data[1] *= t->scale.x;
result.data[2] *= t->scale.x;
result.data[4] *= t->scale.y;
result.data[5] *= t->scale.y;
result.data[6] *= t->scale.y;
result.data[8] *= t->scale.z;
result.data[9] *= t->scale.z;
result.data[10] *= t->scale.z;
// 3. 设置位置 (第 4 列)
result.data[12] = t->position.x;
result.data[13] = t->position.y;
result.data[14] = t->position.z;
return result;
}
世界矩阵计算
从局部矩阵和父节点计算世界矩阵:
/**
* @brief 计算世界变换矩阵
*/
mat4 transform_calculate_world(const transform* t) {
if (t->parent) {
// 有父节点:组合父节点的世界矩阵
return mat4_mul_mat4(t->parent->world, t->local);
} else {
// 无父节点:世界矩阵 = 局部矩阵
return t->local;
}
}
递归计算 (从根到叶):
/**
* @brief 递归更新世界矩阵
*/
void transform_update_world_recursive(transform* t) {
// 1. 更新局部矩阵
t->local = transform_calculate_local(t);
// 2. 更新世界矩阵
if (t->parent) {
// 确保父节点已更新
if (t->parent->is_dirty) {
transform_update_world_recursive(t->parent);
}
t->world = mat4_mul_mat4(t->parent->world, t->local);
} else {
t->world = t->local;
}
// 3. 清除 dirty 标记
t->is_dirty = false;
}
Dirty标记优化
使用 dirty 标记避免不必要的重新计算:
/**
* @brief 标记 transform 为 dirty
*/
void transform_mark_dirty(transform* t) {
t->is_dirty = true;
// TODO: 同时标记所有子节点为 dirty (需要 Scene System 支持)
}
/**
* @brief 设置位置 (自动标记 dirty)
*/
void transform_set_position(transform* t, vec3 position) {
if (vec3_compare(t->position, position, 0.0001f)) {
return; // 位置没有改变,无需更新
}
t->position = position;
transform_mark_dirty(t); // 标记为 dirty
}
/**
* @brief 更新 transform (仅在 dirty 时)
*/
void transform_update(transform* t) {
if (!t->is_dirty) {
return; // 没有改变,跳过更新
}
// 计算矩阵
t->local = transform_calculate_local(t);
t->world = transform_calculate_world(t);
// 清除 dirty 标记
t->is_dirty = false;
}
Dirty 传播策略:
Dirty 传播 (当前实现):
┌────────────────────────────────┐
│ 修改父节点 │
│ parent.position = (10, 0, 0) │
│ parent.is_dirty = true │
└──────────────┬─────────────────┘││ 问题:子节点没有被标记!│▼
┌────────────────────────────────┐
│ 子节点状态 │
│ child.is_dirty = false ✗ │
│ child.world = 旧的世界矩阵 │
└────────────────────────────────┘
解决方案 (未来扩展):
┌────────────────────────────────┐
│ 修改父节点 │
│ parent.position = (10, 0, 0) │
│ parent.is_dirty = true │
└──────────────┬─────────────────┘││ 递归标记所有子节点│▼
┌────────────────────────────────┐
│ 子节点状态 │
│ child.is_dirty = true ✓ │
│ child.world = 需要重新计算 │
└────────────────────────────────┘
当前 workaround:
- Scene System 负责管理子节点列表
- 修改父节点时,Scene System 标记所有子节点为 dirty
场景层级更新
深度优先遍历
场景图的更新使用深度优先遍历 (DFS):
// engine/src/systems/scene_system.c (伪代码)
/**
* @brief 深度优先遍历更新场景图
*/
void scene_update_hierarchy(scene* s) {
// 从根节点开始遍历
for (u32 i = 0; i < s->root_entity_count; ++i) {entity* root = s->root_entities[i];scene_update_entity_recursive(root);}}/*** @brief 递归更新 entity 及其子节点*/void scene_update_entity_recursive(entity* e) {// 1. 更新当前 entity 的 transformtransform* t = entity_get_transform(e);transform_update(t);// 2. 递归更新所有子 entityfor (u32 i = 0; i < e->child_count; ++i) {scene_update_entity_recursive(e->children[i]);}}
遍历顺序示例:
Scene Graph:Root│┌───┴───┐A B│ │┌─┴─┐ ┌─┴─┐C D E F
DFS 遍历顺序:Root → A → C → D → B → E → F1. 访问 Root,更新 Root 的 transform2. 访问 A (Root 的第一个子节点)3. 访问 C (A 的第一个子节点)4. C 没有子节点,返回 A5. 访问 D (A 的第二个子节点)6. D 没有子节点,返回 A,返回 Root7. 访问 B (Root 的第二个子节点)8. 访问 E (B 的第一个子节点)9. E 没有子节点,返回 B10. 访问 F (B 的第二个子节点)11. F 没有子节点,返回 B,返回 Root
优点:
✓ 保证父节点先于子节点更新
✓ 内存访问局部性好 (同一分支的节点连续访问)
✓ 实现简单 (递归)
递归更新
递归更新的实现:
/**
* @brief 递归更新 transform 及其所有子节点
*
* @param t Transform 节点
* @param children 子节点列表 (外部系统提供)
* @param child_count 子节点数量
*/
void transform_update_recursive(
transform* t,
transform** children,
u32 child_count
) {
// 1. 更新当前节点
transform_update(t);
// 2. 递归更新所有子节点
for (u32 i = 0; i < child_count; ++i) {
// 获取子节点的子节点列表 (由外部系统提供)
transform** grandchildren;
u32 grandchild_count;
get_children(children[i], &grandchildren, &grandchild_count);
// 递归更新
transform_update_recursive(children[i], grandchildren, grandchild_count);
}
}
更新策略
不同的更新策略:
策略 1: 每帧更新所有 transform (简单但低效)
┌────────────────────────────────┐
│ void update_all() { │
│ for (transform in scene) { │
│ transform_update(t); │
│ } │
│ } │
└────────────────────────────────┘
优点: 简单
缺点: 浪费 CPU (静态物体也会更新)
策略 2: 只更新 dirty transform (推荐)
┌────────────────────────────────┐
│ void update_dirty() { │
│ for (transform in scene) { │
│ if (t->is_dirty) { │
│ transform_update(t);│
│ } │
│ } │
│ } │
└────────────────────────────────┘
优点: 高效,只更新改变的 transform
缺点: 需要正确管理 dirty 标记
策略 3: 增量更新 (未来优化)
┌────────────────────────────────┐
│ 维护 dirty transform 列表 │
│ 只遍历 dirty 列表,而不是全部 │
└────────────────────────────────┘
优点: 最高效 (只遍历改变的)
缺点: 需要额外的数据结构
实际应用场景
场景 1: 角色动画
// 角色骨骼层级
transform character_root;
transform spine;
transform left_arm;
transform left_hand;
// 设置层级
transform_set_parent(&spine, &character_root);
transform_set_parent(&left_arm, &spine);
transform_set_parent(&left_hand, &left_arm);
// 动画更新 (每帧)
void update_character_animation(f32 delta_time) {
// 1. 移动角色
vec3 pos = transform_get_position(&character_root);
pos.x += 5.0f * delta_time; // 向右移动
transform_set_position(&character_root, pos);
// 2. 旋转手臂 (挥手动画)
f32 arm_angle = sin(time) * 45.0f; // -45° ~ +45°
quat arm_rotation = quat_from_axis_angle((vec3){0, 0, 1}, deg_to_rad(arm_angle));
transform_set_rotation(&left_arm, arm_rotation);
// 3. 更新层级 (自动传播)
transform_update(&character_root);
transform_update(&spine);
transform_update(&left_arm);
transform_update(&left_hand);
// 结果:手的世界位置跟随角色移动 + 手臂挥动
}
场景 2: 车辆系统
// 车辆层级
transform vehicle;
transform wheel_fl; // 前左轮
transform wheel_fr; // 前右轮
transform wheel_rl; // 后左轮
transform wheel_rr; // 后右轮
// 设置层级
transform_set_parent(&wheel_fl, &vehicle);
transform_set_parent(&wheel_fr, &vehicle);
transform_set_parent(&wheel_rl, &vehicle);
transform_set_parent(&wheel_rr, &vehicle);
// 设置车轮局部位置
transform_set_position(&wheel_fl, (vec3){-1.0f, 0.0f, 1.5f});
transform_set_position(&wheel_fr, (vec3){1.0f, 0.0f, 1.5f});
transform_set_position(&wheel_rl, (vec3){-1.0f, 0.0f, -1.5f});
transform_set_position(&wheel_rr, (vec3){1.0f, 0.0f, -1.5f});
// 驾驶更新
void update_vehicle(f32 delta_time, f32 speed, f32 steering) {
// 1. 移动车辆
vec3 forward = transform_get_forward(&vehicle); // 车辆前方向
vec3 pos = transform_get_position(&vehicle);
pos = vec3_add(pos, vec3_mul_scalar(forward, speed * delta_time));
transform_set_position(&vehicle, pos);
// 2. 转向
quat rotation = transform_get_rotation(&vehicle);
quat turn = quat_from_axis_angle((vec3){0, 1, 0}, steering * delta_time);
rotation = quat_mul(rotation, turn);
transform_set_rotation(&vehicle, rotation);
// 3. 旋转车轮 (模拟滚动)
f32 wheel_rotation = (speed / WHEEL_RADIUS) * delta_time;
quat wheel_rot = quat_from_axis_angle((vec3){1, 0, 0}, wheel_rotation);
transform_rotate(&wheel_fl, wheel_rot);
transform_rotate(&wheel_fr, wheel_rot);
transform_rotate(&wheel_rl, wheel_rot);
transform_rotate(&wheel_rr, wheel_rot);
// 4. 更新层级
transform_update(&vehicle); // 车辆移动和转向
transform_update(&wheel_fl); // 车轮跟随车辆 + 自身旋转
transform_update(&wheel_fr);
transform_update(&wheel_rl);
transform_update(&wheel_rr);
}
场景 3: 相机跟随
// 第三人称相机层级
transform player;
transform camera_pivot; // 相机支点 (在玩家头部)
transform camera; // 相机 (在支点后方)
// 设置层级
transform_set_parent(&camera_pivot, &player);
transform_set_parent(&camera, &camera_pivot);
// 设置相机局部位置
transform_set_position(&camera_pivot, (vec3){0, 1.8f, 0}); // 玩家头部高度
transform_set_position(&camera, (vec3){0, 0.5f, -3.0f}); // 后方 3 米,上方 0.5 米
// 相机更新
void update_camera(f32 mouse_x, f32 mouse_y) {
// 1. 鼠标控制相机支点旋转 (环绕玩家)
quat yaw = quat_from_axis_angle((vec3){0, 1, 0}, mouse_x);
quat pitch = quat_from_axis_angle((vec3){1, 0, 0}, mouse_y);
quat rotation = quat_mul(yaw, pitch);
transform_set_rotation(&camera_pivot, rotation);
// 2. 更新层级
transform_update(&player);
transform_update(&camera_pivot);
transform_update(&camera);
// 结果:相机跟随玩家移动,并可以环绕玩家旋转
mat4 view_matrix = mat4_inverse(transform_get_world(&camera));
}
❓ 常见问题
1. 为什么使用四元数而不是欧拉角?欧拉角的问题:
欧拉角 (Euler Angles):
- 用三个角度表示旋转: (pitch, yaw, roll)
- 直观易懂
- 但有万向锁 (Gimbal Lock) 问题
万向锁示例:
1. pitch = 90° (抬头 90°)
2. 此时 yaw 轴和 roll 轴重合!
3. 失去一个自由度,无法表示某些旋转
┌──────────────────────┐
│ pitch = 0° │
│ yaw ↑ roll ⤴ │
│ │ ╱ │
│ │ ╱ │
│ │ ╱ │
│ │╱ │
│ ──────●───── pitch │
└──────────────────────┘
┌──────────────────────┐
│ pitch = 90° ✗ │
│ yaw & roll 重合! │
│ │ │
│ │ │
│ │ │
│ ──────●───── pitch │
│ ╱ │
│ ╱ yaw = roll │
│ ╱ │
└──────────────────────┘
四元数的优势:
四元数 (Quaternion):
- 用四个分量表示旋转: (x, y, z, w)
- 无万向锁问题
- 插值平滑 (slerp)
- 组合旋转高效 (四元数乘法)
// 四元数组合旋转
quat q1 = quat_from_axis_angle((vec3){0, 1, 0}, deg_to_rad(45)); // 绕 Y 轴 45°
quat q2 = quat_from_axis_angle((vec3){1, 0, 0}, deg_to_rad(30)); // 绕 X 轴 30°
quat combined = quat_mul(q1, q2); // 组合旋转
// 四元数插值 (平滑旋转)
quat start = quat_identity();
quat end = quat_from_axis_angle((vec3){0, 1, 0}, deg_to_rad(180));
quat interpolated = quat_slerp(start, end, 0.5f); // 中间状态
何时使用欧拉角?
- 用户输入 (相机控制: pitch, yaw 直观)
- 编辑器 UI (显示角度值)
- 序列化 (保存到文件)
转换:
// 欧拉角 → 四元数
vec3 euler = {30.0f, 45.0f, 0.0f}; // pitch, yaw, roll (度)
quat rotation = quat_from_euler(
deg_to_rad(euler.x),
deg_to_rad(euler.y),
deg_to_rad(euler.z)
);
// 四元数 → 欧拉角
vec3 euler_back = quat_to_euler(rotation);
2. Dirty 标记如何传播到子节点?当前实现的限制:
// 当前 transform 结构不存储子节点
typedef struct transform {
// ...
struct transform* parent; // 只有父节点指针
// 没有子节点列表!
} transform;
// 问题:修改父节点时,无法直接标记子节点为 dirty
void transform_set_position(transform* t, vec3 position) {
t->position = position;
t->is_dirty = true;
// ✗ 无法做到:标记所有子节点为 dirty
// 因为 transform 不知道自己有哪些子节点
}
解决方案:Scene System 负责传播
// Scene System 维护完整的场景图
typedef struct scene {
entity* root_entities;
u32 root_entity_count;
// 每个 entity 包含:
// - transform 组件
// - 子 entity 列表
} scene;
// Scene System 负责 dirty 传播
void scene_mark_entity_dirty_recursive(entity* e) {
// 1. 标记当前 entity 的 transform 为 dirty
transform* t = entity_get_transform(e);
t->is_dirty = true;
// 2. 递归标记所有子 entity
for (u32 i = 0; i < e->child_count; ++i) {scene_mark_entity_dirty_recursive(e->children[i]);}}// 修改父节点时调用void entity_set_position(entity* e, vec3 position) {transform* t = entity_get_transform(e);transform_set_position(t, position);// 传播 dirty 标记到所有子节点scene_mark_entity_dirty_recursive(e);}
为什么这样设计?
优点:
✓ Transform 组件轻量级 (只存储父节点指针)
✓ 场景图结构由 Scene System 统一管理
✓ 灵活性高 (不同系统可以有不同的层级结构)
缺点:
✗ Dirty 传播需要外部系统配合
✗ Transform 单独使用时功能受限
3. 矩阵乘法顺序为什么重要?矩阵乘法不可交换:
// 矩阵乘法:A × B ≠ B × A
示例:
mat4 T = mat4_translation((vec3){5, 0, 0}); // 向右移动 5
mat4 R = mat4_rotation_y(deg_to_rad(90)); // 绕 Y 轴旋转 90°
// 顺序 1: T × R (先旋转,后平移)
mat4 TR = mat4_mul_mat4(T, R);
// 顺序 2: R × T (先平移,后旋转)
mat4 RT = mat4_mul_mat4(R, T);
// 应用到点 (1, 0, 0)
vec4 point = {1, 0, 0, 1};
vec4 result_TR = mat4_mul_vec4(TR, point);
// 1. 先旋转: (1, 0, 0) → (0, 0, -1)
// 2. 后平移: (0, 0, -1) → (5, 0, -1)
vec4 result_RT = mat4_mul_vec4(RT, point);
// 1. 先平移: (1, 0, 0) → (6, 0, 0)
// 2. 后旋转: (6, 0, 0) → (0, 0, -6)
// 结果不同!
// result_TR = (5, 0, -1)
// result_RT = (0, 0, -6)
可视化:
先旋转后平移 (T × R):● → ● → ●(1,0) (0,-1) (5,-1)│ │ │▼ ▼ ▼旋转 90° 向右移 5
先平移后旋转 (R × T):● → ● → ●(1,0) (6,0) (0,-6)│ │ │▼ ▼ ▼向右移 5 旋转 90°
层级变换的正确顺序:
// 从根到叶:从左到右
world = grandparent × parent × child × local
// 这样可以确保:
// 1. 孙节点受父节点影响
// 2. 父节点受祖父节点影响
// 3. 变换从祖先传播到子孙
4. 如何优化大量 Transform 的更新?优化策略:
1. Dirty 标记 (已实现)
// 只更新改变的 transform
void transform_update(transform* t) {
if (!t->is_dirty) {
return; // 跳过未改变的
}
// ... 更新矩阵 ...
}
2. 增量更新列表 (未来优化)
// 维护 dirty transform 列表
typedef struct scene {
transform** dirty_transforms;
u32 dirty_count;
} scene;
// 只遍历 dirty 列表
void scene_update_transforms(scene* s) {
for (u32 i = 0; i < s->dirty_count; ++i) {transform_update(s->dirty_transforms[i]);}s->dirty_count = 0; // 清空列表}
3. 空间分区 (高级优化)
// 使用八叉树或网格分区
// 只更新视锥内的 transform
void scene_update_visible_transforms(scene* s, frustum* camera_frustum) {
octree_query(s->spatial_tree, camera_frustum, &visible_entities);
for (u32 i = 0; i < visible_count; ++i) {
transform* t = entity_get_transform(visible_entities[i]);
transform_update(t);
}
}
4. 多线程更新 (进阶优化)
// 并行更新独立的 transform 子树
void scene_update_parallel(scene* s) {
// 为每个根节点创建任务
for (u32 i = 0; i < s->root_entity_count; ++i) {job_system_submit(update_subtree_job, s->root_entities[i]);}job_system_wait_all();}
性能对比:
| 策略 | 适用场景 | 性能提升 |
|---|---|---|
| Dirty 标记 | 通用 | 2-5x |
| 增量列表 | 大场景 | 5-10x |
| 空间分区 | 开放世界 | 10-100x |
| 多线程 | 多核 CPU | 2-4x |
LookAt 功能:
/**
* @brief 让 transform 看向目标点
* @param t Transform
* @param target 目标世界位置
* @param up 上方向 (默认 (0, 1, 0))
*/
void transform_look_at(transform* t, vec3 target, vec3 up) {
// 1. 获取当前世界位置
vec3 position = transform_get_world_position(t);
// 2. 计算方向向量
vec3 forward = vec3_normalized(vec3_sub(target, position));
vec3 right = vec3_normalized(vec3_cross(up, forward));
vec3 actual_up = vec3_cross(forward, right);
// 3. 构建旋转矩阵
mat4 rotation_matrix = mat4_identity();
rotation_matrix.data[0] = right.x;
rotation_matrix.data[1] = right.y;
rotation_matrix.data[2] = right.z;
rotation_matrix.data[4] = actual_up.x;
rotation_matrix.data[5] = actual_up.y;
rotation_matrix.data[6] = actual_up.z;
rotation_matrix.data[8] = forward.x;
rotation_matrix.data[9] = forward.y;
rotation_matrix.data[10] = forward.z;
// 4. 转换为四元数
quat rotation = quat_from_mat4(rotation_matrix);
// 5. 考虑父节点变换
if (t->parent) {
// 转换到局部空间
quat parent_rotation = quat_from_mat4(t->parent->world);
quat parent_rotation_inv = quat_inverse(parent_rotation);
rotation = quat_mul(parent_rotation_inv, rotation);
}
// 6. 应用旋转
transform_set_rotation(t, rotation);
}
使用示例:
// 相机看向玩家
transform camera;
transform player;
vec3 player_pos = transform_get_world_position(&player);
transform_look_at(&camera, player_pos, (vec3){0, 1, 0});
// 敌人看向玩家
transform enemy;
transform_look_at(&enemy, player_pos, (vec3){0, 1, 0});
练习
练习 1: 实现太阳系模拟任务: 使用 Transform 层级实现太阳系模拟 (太阳、地球、月球)。
// 1. 创建层级结构
transform sun; // 太阳 (根节点)
transform earth; // 地球 (太阳的子节点)
transform moon; // 月球 (地球的子节点)
// 2. 设置层级
transform_create(&sun);
transform_create(&earth);
transform_create(&moon);
transform_set_parent(&earth, &sun);
transform_set_parent(&moon, &earth);
// 3. 设置局部位置
transform_set_position(&earth, (vec3){10.0f, 0, 0}); // 距太阳 10 单位
transform_set_position(&moon, (vec3){2.0f, 0, 0}); // 距地球 2 单位
// 4. 动画更新 (每帧)
void update_solar_system(f32 delta_time) {
// 太阳自转
quat sun_rotation = transform_get_rotation(&sun);
quat sun_spin = quat_from_axis_angle((vec3){0, 1, 0}, deg_to_rad(10 * delta_time));
transform_set_rotation(&sun, quat_mul(sun_rotation, sun_spin));
// 地球公转 (绕太阳)
quat earth_rotation = transform_get_rotation(&earth);
quat earth_orbit = quat_from_axis_angle((vec3){0, 1, 0}, deg_to_rad(30 * delta_time));
transform_set_rotation(&earth, quat_mul(earth_rotation, earth_orbit));
// 月球公转 (绕地球)
quat moon_rotation = transform_get_rotation(&moon);
quat moon_orbit = quat_from_axis_angle((vec3){0, 1, 0}, deg_to_rad(60 * delta_time));
transform_set_rotation(&moon, quat_mul(moon_rotation, moon_orbit));
// 更新层级
transform_update(&sun);
transform_update(&earth);
transform_update(&moon);
}
预期结果:
- 太阳原地自转
- 地球绕太阳公转,同时自转
- 月球绕地球公转,同时跟随地球绕太阳
任务: 实现类似 Unity/Unreal 的 Transform Gizmo,可视化并编辑 Transform。
// Transform Gizmo 组件
typedef struct transform_gizmo {
transform* target; // 目标 transform
// Gizmo 几何体 (箭头)
geometry arrow_x; // 红色箭头 (X 轴)
geometry arrow_y; // 绿色箭头 (Y 轴)
geometry arrow_z; // 蓝色箭头 (Z 轴)
// 交互状态
b8 is_dragging;
vec3 drag_start_pos;
vec3 drag_axis; // 拖拽轴 (1,0,0 或 0,1,0 或 0,0,1)
} transform_gizmo;
// 绘制 Gizmo
void transform_gizmo_render(transform_gizmo* gizmo) {
if (!gizmo->target) return;
// 获取目标的世界位置
vec3 position = transform_get_world_position(gizmo->target);
// 绘制三个箭头
draw_arrow(position, (vec3){1, 0, 0}, (vec4){1, 0, 0, 1}); // X 轴 (红)
draw_arrow(position, (vec3){0, 1, 0}, (vec4){0, 1, 0, 1}); // Y 轴 (绿)
draw_arrow(position, (vec3){0, 0, 1}, (vec4){0, 0, 1, 1}); // Z 轴 (蓝)
}
// 鼠标交互
void transform_gizmo_on_mouse_down(transform_gizmo* gizmo, vec2 mouse_pos) {
// 射线检测:哪个箭头被点击?
ray ray = screen_to_world_ray(mouse_pos);
if (ray_intersects_arrow(&ray, gizmo->arrow_x)) {
gizmo->is_dragging = true;
gizmo->drag_axis = (vec3){1, 0, 0};
gizmo->drag_start_pos = transform_get_position(gizmo->target);
} else if (ray_intersects_arrow(&ray, gizmo->arrow_y)) {
gizmo->is_dragging = true;
gizmo->drag_axis = (vec3){0, 1, 0};
gizmo->drag_start_pos = transform_get_position(gizmo->target);
} else if (ray_intersects_arrow(&ray, gizmo->arrow_z)) {
gizmo->is_dragging = true;
gizmo->drag_axis = (vec3){0, 0, 1};
gizmo->drag_start_pos = transform_get_position(gizmo->target);
}
}
void transform_gizmo_on_mouse_drag(transform_gizmo* gizmo, vec2 mouse_delta) {
if (!gizmo->is_dragging) return;
// 计算拖拽距离 (投影到拖拽轴)
f32 drag_distance = mouse_delta.x * 0.01f; // 简化:使用鼠标 X 偏移
// 计算新位置
vec3 offset = vec3_mul_scalar(gizmo->drag_axis, drag_distance);
vec3 new_position = vec3_add(gizmo->drag_start_pos, offset);
// 应用到目标 transform
transform_set_position(gizmo->target, new_position);
}
练习 3: 实现 IK (反向运动学) - 双骨骼链任务: 实现简单的 IK,让手臂伸向目标点。
// 双骨骼 IK (手臂:肩膀 → 肘部 → 手)
typedef struct two_bone_ik {
transform* root; // 肩膀
transform* middle; // 肘部
transform* end; // 手
f32 upper_length; // 上臂长度
f32 lower_length; // 前臂长度
} two_bone_ik;
/**
* @brief 求解 IK,让 end 指向 target
*/
void two_bone_ik_solve(two_bone_ik* ik, vec3 target) {
// 1. 获取根节点位置
vec3 root_pos = transform_get_world_position(ik->root);
// 2. 计算目标距离
vec3 to_target = vec3_sub(target, root_pos);
f32 target_distance = vec3_length(to_target);
// 3. 限制目标距离 (不能超出手臂长度)
f32 max_reach = ik->upper_length + ik->lower_length;
if (target_distance > max_reach) {
target_distance = max_reach;
to_target = vec3_mul_scalar(vec3_normalized(to_target), max_reach);
}
// 4. 使用余弦定理计算关节角度
f32 a = ik->upper_length;
f32 b = ik->lower_length;
f32 c = target_distance;
// 肘部角度 (使用余弦定理)
f32 cos_elbow = (a*a + b*b - c*c) / (2*a*b);
f32 elbow_angle = acosf(KCLAMP(cos_elbow, -1.0f, 1.0f));
// 肩膀角度
f32 cos_shoulder = (a*a + c*c - b*b) / (2*a*c);
f32 shoulder_angle = acosf(KCLAMP(cos_shoulder, -1.0f, 1.0f));
// 5. 应用旋转
vec3 target_dir = vec3_normalized(to_target);
// 肩膀旋转
quat shoulder_rotation = quat_from_to(
(vec3){0, -1, 0}, // 手臂默认向下
target_dir
);
quat shoulder_bend = quat_from_axis_angle(
vec3_cross((vec3){0, -1, 0}, target_dir),
shoulder_angle
);
transform_set_rotation(ik->root, quat_mul(shoulder_rotation, shoulder_bend));
// 肘部旋转
quat elbow_rotation = quat_from_axis_angle(
vec3_cross((vec3){0, -1, 0}, target_dir),
elbow_angle - K_PI // 弯曲方向
);
transform_set_rotation(ik->middle, elbow_rotation);
// 6. 更新层级
transform_update(ik->root);
transform_update(ik->middle);
transform_update(ik->end);
}
预期结果:
- 给定目标点,手臂自动弯曲以伸向目标
- 肘部和肩膀角度自动计算
- 超出手臂长度时,尽可能靠近目标
恭喜!你已经掌握了变换和父子关系系统!
关注公众号「上手实验室」,获取更多游戏引擎开发教程!
Tutorial written by 上手实验室