河北省网站建设_网站建设公司_虚拟主机_seo优化
2026/1/16 22:57:47 网站建设 项目流程

上一篇:网格 Part 1 | 下一篇:二进制网格格式 | 返回目录


快速导航


目录

简介

在之前的教程中,我们实现了网格系统,可以加载复杂的 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;

字段说明:

字段类型说明
positionvec3局部位置 (相对于父节点的偏移)
rotationquat局部旋转 (四元数,避免万向锁)
scalevec3局部缩放 (可以非均匀缩放)
localmat4局部变换矩阵 (从 TRS 计算)
worldmat4世界变换矩阵 (结合父节点)
parenttransform*父节点指针
is_dirtyb8Dirty 标记 (优化计算)

变换矩阵

变换矩阵是一个 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
多线程多核 CPU2-4x
5. 如何实现 LookAt (看向目标)?

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);
}

预期结果:

  • 太阳原地自转
  • 地球绕太阳公转,同时自转
  • 月球绕地球公转,同时跟随地球绕太阳
练习 2: 实现 Transform Gizmo (编辑器工具)

任务: 实现类似 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 上手实验室

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询