T02 - 俯视角摄像机系统 设计文档
版本 : v1.0
日期 : 2026-07-16
任务ID : T02
状态 : ✅ 已实现并编译通过
文档类型: 设计回顾文档(基于已实现代码)
一、任务概述
1.1 任务信息
| 项目 | 内容 |
|---|---|
| 任务ID | T02 |
| 任务名称 | 俯视角摄像机系统 |
| 复杂度 | ⭐ 简单 |
| 前置依赖 | T01(UE5 项目基础搭建) |
| 里程碑归属 | 里程碑 1:基础框架就绪(T01 + T02 + T03) |
1.2 目标描述
实现等距俯视角摄像机系统,满足以下核心要求:
- 固定角度:摄像机以固定俯视角(默认 55°)观察游戏世界,不可旋转
- 可缩放:玩家可通过鼠标滚轮(PC 端)或双指捏合(手机端,T10 实现)调整摄像机距离
- 平滑跟随:摄像机平滑跟随玩家角色移动,避免画面抖动
- 边界限制:摄像机位置限制在地图边界范围内,不超出地图
1.3 PRD 对应需求
本任务对应 PRD 中的需求项:
| 需求ID | 需求描述 | 优先级 |
|---|---|---|
| C-001 | 等距俯视角摄像机,固定角度,可缩放,不可旋转 | P0 |
1.4 P0 验收标准
| 验收项 | 通过条件 |
|---|---|
| 俯视角摄像机 | 摄像机固定等距俯视角,可缩放,不可旋转 |
二、架构设计
2.1 类结构
2.1.1 类继承关系
#mermaid-svg-6Bz4vsUX5SFocNxq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6Bz4vsUX5SFocNxq .error-icon{fill:#552222;}#mermaid-svg-6Bz4vsUX5SFocNxq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6Bz4vsUX5SFocNxq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6Bz4vsUX5SFocNxq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6Bz4vsUX5SFocNxq .marker.cross{stroke:#333333;}#mermaid-svg-6Bz4vsUX5SFocNxq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6Bz4vsUX5SFocNxq p{margin:0;}#mermaid-svg-6Bz4vsUX5SFocNxq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster-label text{fill:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster-label span{color:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster-label span p{background-color:transparent;}#mermaid-svg-6Bz4vsUX5SFocNxq .label text,#mermaid-svg-6Bz4vsUX5SFocNxq span{fill:#333;color:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq .node rect,#mermaid-svg-6Bz4vsUX5SFocNxq .node circle,#mermaid-svg-6Bz4vsUX5SFocNxq .node ellipse,#mermaid-svg-6Bz4vsUX5SFocNxq .node polygon,#mermaid-svg-6Bz4vsUX5SFocNxq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6Bz4vsUX5SFocNxq .rough-node .label text,#mermaid-svg-6Bz4vsUX5SFocNxq .node .label text,#mermaid-svg-6Bz4vsUX5SFocNxq .image-shape .label,#mermaid-svg-6Bz4vsUX5SFocNxq .icon-shape .label{text-anchor:middle;}#mermaid-svg-6Bz4vsUX5SFocNxq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6Bz4vsUX5SFocNxq .rough-node .label,#mermaid-svg-6Bz4vsUX5SFocNxq .node .label,#mermaid-svg-6Bz4vsUX5SFocNxq .image-shape .label,#mermaid-svg-6Bz4vsUX5SFocNxq .icon-shape .label{text-align:center;}#mermaid-svg-6Bz4vsUX5SFocNxq .node.clickable{cursor:pointer;}#mermaid-svg-6Bz4vsUX5SFocNxq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6Bz4vsUX5SFocNxq .arrowheadPath{fill:#333333;}#mermaid-svg-6Bz4vsUX5SFocNxq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6Bz4vsUX5SFocNxq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6Bz4vsUX5SFocNxq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6Bz4vsUX5SFocNxq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6Bz4vsUX5SFocNxq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6Bz4vsUX5SFocNxq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster text{fill:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq .cluster span{color:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6Bz4vsUX5SFocNxq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6Bz4vsUX5SFocNxq rect.text{fill:none;stroke-width:0;}#mermaid-svg-6Bz4vsUX5SFocNxq .icon-shape,#mermaid-svg-6Bz4vsUX5SFocNxq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6Bz4vsUX5SFocNxq .icon-shape p,#mermaid-svg-6Bz4vsUX5SFocNxq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6Bz4vsUX5SFocNxq .icon-shape .label rect,#mermaid-svg-6Bz4vsUX5SFocNxq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6Bz4vsUX5SFocNxq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6Bz4vsUX5SFocNxq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6Bz4vsUX5SFocNxq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 摄像机组件
拥有
拥有
附加
AActor (UE5引擎基类)
AXiYouCameraActor 俯视角摄像机
USpringArmComponent 摇臂组件
UCameraComponent 摄像机组件
AXiYouCameraActor 继承自 UE5 引擎的 AActor,是一个独立的摄像机 Actor,内部组合了 USpringArmComponent 和 UCameraComponent 两个组件。
2.1.2 与 PlayerController 的关系
#mermaid-svg-FfHUyhwYpYF49w74{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FfHUyhwYpYF49w74 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FfHUyhwYpYF49w74 .error-icon{fill:#552222;}#mermaid-svg-FfHUyhwYpYF49w74 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FfHUyhwYpYF49w74 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FfHUyhwYpYF49w74 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FfHUyhwYpYF49w74 .marker.cross{stroke:#333333;}#mermaid-svg-FfHUyhwYpYF49w74 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FfHUyhwYpYF49w74 p{margin:0;}#mermaid-svg-FfHUyhwYpYF49w74 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FfHUyhwYpYF49w74 .cluster-label text{fill:#333;}#mermaid-svg-FfHUyhwYpYF49w74 .cluster-label span{color:#333;}#mermaid-svg-FfHUyhwYpYF49w74 .cluster-label span p{background-color:transparent;}#mermaid-svg-FfHUyhwYpYF49w74 .label text,#mermaid-svg-FfHUyhwYpYF49w74 span{fill:#333;color:#333;}#mermaid-svg-FfHUyhwYpYF49w74 .node rect,#mermaid-svg-FfHUyhwYpYF49w74 .node circle,#mermaid-svg-FfHUyhwYpYF49w74 .node ellipse,#mermaid-svg-FfHUyhwYpYF49w74 .node polygon,#mermaid-svg-FfHUyhwYpYF49w74 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FfHUyhwYpYF49w74 .rough-node .label text,#mermaid-svg-FfHUyhwYpYF49w74 .node .label text,#mermaid-svg-FfHUyhwYpYF49w74 .image-shape .label,#mermaid-svg-FfHUyhwYpYF49w74 .icon-shape .label{text-anchor:middle;}#mermaid-svg-FfHUyhwYpYF49w74 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FfHUyhwYpYF49w74 .rough-node .label,#mermaid-svg-FfHUyhwYpYF49w74 .node .label,#mermaid-svg-FfHUyhwYpYF49w74 .image-shape .label,#mermaid-svg-FfHUyhwYpYF49w74 .icon-shape .label{text-align:center;}#mermaid-svg-FfHUyhwYpYF49w74 .node.clickable{cursor:pointer;}#mermaid-svg-FfHUyhwYpYF49w74 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FfHUyhwYpYF49w74 .arrowheadPath{fill:#333333;}#mermaid-svg-FfHUyhwYpYF49w74 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FfHUyhwYpYF49w74 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FfHUyhwYpYF49w74 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FfHUyhwYpYF49w74 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FfHUyhwYpYF49w74 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FfHUyhwYpYF49w74 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FfHUyhwYpYF49w74 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FfHUyhwYpYF49w74 .cluster text{fill:#333;}#mermaid-svg-FfHUyhwYpYF49w74 .cluster span{color:#333;}#mermaid-svg-FfHUyhwYpYF49w74 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FfHUyhwYpYF49w74 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FfHUyhwYpYF49w74 rect.text{fill:none;stroke-width:0;}#mermaid-svg-FfHUyhwYpYF49w74 .icon-shape,#mermaid-svg-FfHUyhwYpYF49w74 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FfHUyhwYpYF49w74 .icon-shape p,#mermaid-svg-FfHUyhwYpYF49w74 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FfHUyhwYpYF49w74 .icon-shape .label rect,#mermaid-svg-FfHUyhwYpYF49w74 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FfHUyhwYpYF49w74 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FfHUyhwYpYF49w74 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FfHUyhwYpYF49w74 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Spawn + 持有引用
SetViewTargetWithBlend
SetTargetActor - Pawn
OnMouseWheel - ZoomCamera
AXiYouPlayerController
AXiYouCameraActor
AXiYouPlayerController 负责摄像机的生命周期管理:
- 生成 :在
OnPossess()/BeginPlay()中调用SpawnCamera()生成摄像机 Actor - 设置视图目标 :通过
SetViewTargetWithBlend()将玩家视图切换到此摄像机 - 设置跟随目标:将当前 Pawn 设置为摄像机的跟随目标
- 输入转发 :将鼠标滚轮输入转发给摄像机的
ZoomCamera()函数
2.1.3 与 PlayerCharacter 的关系
摄像机与玩家角色之间是间接关联关系:
#mermaid-svg-qUWdK6cTUIEMtZRb{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qUWdK6cTUIEMtZRb .error-icon{fill:#552222;}#mermaid-svg-qUWdK6cTUIEMtZRb .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qUWdK6cTUIEMtZRb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qUWdK6cTUIEMtZRb .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qUWdK6cTUIEMtZRb .marker.cross{stroke:#333333;}#mermaid-svg-qUWdK6cTUIEMtZRb svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qUWdK6cTUIEMtZRb p{margin:0;}#mermaid-svg-qUWdK6cTUIEMtZRb .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster-label text{fill:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster-label span{color:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster-label span p{background-color:transparent;}#mermaid-svg-qUWdK6cTUIEMtZRb .label text,#mermaid-svg-qUWdK6cTUIEMtZRb span{fill:#333;color:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb .node rect,#mermaid-svg-qUWdK6cTUIEMtZRb .node circle,#mermaid-svg-qUWdK6cTUIEMtZRb .node ellipse,#mermaid-svg-qUWdK6cTUIEMtZRb .node polygon,#mermaid-svg-qUWdK6cTUIEMtZRb .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-qUWdK6cTUIEMtZRb .rough-node .label text,#mermaid-svg-qUWdK6cTUIEMtZRb .node .label text,#mermaid-svg-qUWdK6cTUIEMtZRb .image-shape .label,#mermaid-svg-qUWdK6cTUIEMtZRb .icon-shape .label{text-anchor:middle;}#mermaid-svg-qUWdK6cTUIEMtZRb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-qUWdK6cTUIEMtZRb .rough-node .label,#mermaid-svg-qUWdK6cTUIEMtZRb .node .label,#mermaid-svg-qUWdK6cTUIEMtZRb .image-shape .label,#mermaid-svg-qUWdK6cTUIEMtZRb .icon-shape .label{text-align:center;}#mermaid-svg-qUWdK6cTUIEMtZRb .node.clickable{cursor:pointer;}#mermaid-svg-qUWdK6cTUIEMtZRb .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-qUWdK6cTUIEMtZRb .arrowheadPath{fill:#333333;}#mermaid-svg-qUWdK6cTUIEMtZRb .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-qUWdK6cTUIEMtZRb .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-qUWdK6cTUIEMtZRb .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qUWdK6cTUIEMtZRb .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-qUWdK6cTUIEMtZRb .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qUWdK6cTUIEMtZRb .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster text{fill:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb .cluster span{color:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-qUWdK6cTUIEMtZRb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-qUWdK6cTUIEMtZRb rect.text{fill:none;stroke-width:0;}#mermaid-svg-qUWdK6cTUIEMtZRb .icon-shape,#mermaid-svg-qUWdK6cTUIEMtZRb .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-qUWdK6cTUIEMtZRb .icon-shape p,#mermaid-svg-qUWdK6cTUIEMtZRb .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-qUWdK6cTUIEMtZRb .icon-shape .label rect,#mermaid-svg-qUWdK6cTUIEMtZRb .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-qUWdK6cTUIEMtZRb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-qUWdK6cTUIEMtZRb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-qUWdK6cTUIEMtZRb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Possess
GetPawn - SetTargetActor
Tick - 跟随位置
AXiYouPlayerController
APawn (玩家角色)
AXiYouCameraActor
- 摄像机不直接引用
AXiYouPlayerCharacter,而是通过AActor* TargetActor泛型指针跟随任意 Actor PlayerController在SpawnCamera()中通过GetPawn()获取当前 Pawn,调用SetTargetActor()传递给摄像机- 这种解耦设计使得摄像机可以跟随任何 Actor(玩家、NPC、怪物等),不限于特定角色类型
2.2 组件构成
2.2.1 USpringArmComponent --- 摇臂组件
| 属性 | 值 | 说明 |
|---|---|---|
| 名称 | SpringArm |
UPROPERTY(VisibleAnywhere, BlueprintReadOnly) |
| 职责 | 控制摄像机距离、角度、碰撞检测 | --- |
bDoCollisionTest |
false |
俯视角不需要碰撞检测,摄像机不会穿墙 |
bUsePawnControlRotation |
false |
不跟随 Controller 旋转 |
bInheritPitch |
false |
不继承 Pitch 旋转 |
bInheritYaw |
false |
不继承 Yaw 旋转 |
bInheritRoll |
false |
不继承 Roll 旋转 |
TargetArmLength |
CameraDistance(默认 1200) |
摄像机到目标的距离 |
| 旋转 | Pitch = -CameraAngle(默认 -55°) |
UE5 中 Pitch 负值表示向下看(俯视角) |
设计要点 :所有旋转继承选项均设为 false,确保摄像机角度完全由代码控制,不受 Pawn 或 Controller 旋转影响。这是"固定角度不可旋转"要求的核心实现。
2.2.2 UCameraComponent --- 摄像机组件
| 属性 | 值 | 说明 |
|---|---|---|
| 名称 | Camera |
UPROPERTY(VisibleAnywhere, BlueprintReadOnly) |
| 职责 | 实际的渲染摄像机 | --- |
bUsePawnControlRotation |
false |
不跟随 Pawn 旋转 |
| 附加方式 | 附加到 SpringArm 的 SocketName |
跟随 SpringArm 的位置和旋转 |
设计要点:Camera 附加到 SpringArm 末端,SpringArm 控制角度和距离,Camera 自动继承 SpringArm 的变换。
2.3 文件结构
| 文件 | 路径 | 说明 |
|---|---|---|
| 头文件 | Source/XiYou/Public/Core/XiYouCameraActor.h |
摄像机 Actor 类声明 |
| 源文件 | Source/XiYou/Private/Core/XiYouCameraActor.cpp |
摄像机 Actor 实现 |
| PlayerController 头文件 | Source/XiYou/Public/Core/XiYouPlayerController.h |
摄像机集成声明 |
| PlayerController 源文件 | Source/XiYou/Private/Core/XiYouPlayerController.cpp |
摄像机集成实现 |
| 输入配置 | Config/DefaultInput.ini |
鼠标滚轮轴映射 |
三、架构优势分析
3.1 为什么选择独立 Actor 而非 Pawn 内嵌
方案对比
| 维度 | 独立 Actor 方案(已选) | Pawn 内嵌方案 |
|---|---|---|
| 生命周期 | 由 PlayerController 管理,独立于 Pawn | 绑定到 Pawn 生命周期 |
| 网络同步 | 仅客户端生成,不需要网络同步 | 需要考虑 Pawn 销毁/重建时的摄像机状态 |
| 跟随灵活性 | 可跟随任意 Actor(AActor* TargetActor) |
只能跟随自身 Pawn |
| ViewTarget 切换 | 可通过 SetViewTargetWithBlend 平滑切换 |
切换摄像机需要重新设置 |
| DS 模式 | 仅本地客户端生成,服务端无摄像机开销 | 服务端也会创建摄像机组件,浪费资源 |
优势分析
-
网络友好 :摄像机是纯客户端视觉表现,不需要在 DS 上同步。独立 Actor 方案通过
IsLocalController()检查,仅在本地客户端生成,服务端和其他客户端不会生成摄像机,减少网络开销和服务端资源占用。 -
解耦设计 :摄像机与角色解耦,角色可以销毁重建(如死亡重生、切换地图),摄像机不受影响。只需在 Possess 新 Pawn 后调用
SetTargetActor()即可切换跟随目标。 -
可扩展性 :未来可以实现过场动画摄像机切换、死亡回放摄像机、观战模式等,只需创建不同的摄像机 Actor 并通过
SetViewTargetWithBlend切换。
3.2 为什么选择 SpringArm + Camera
方案对比
| 维度 | SpringArm + Camera 方案(已选) | 自定义 CameraManager 方案 |
|---|---|---|
| 实现复杂度 | 低,UE5 原生组件,开箱即用 | 高,需继承 APlayerCameraManager 重写逻辑 |
| 碰撞检测 | 内置支持(本项目关闭) | 需自行实现 |
| 平滑插值 | 内置 bEnableCameraLag(本项目用自定义插值) |
需自行实现 |
| 调试支持 | 引擎原生支持,有调试线 | 需自行实现调试可视化 |
| 社区/文档 | 大量示例和文档 | 较少 |
优势分析
-
简单可靠 :
USpringArmComponent是 UE5 引擎原生组件,经过充分测试,无需重复造轮子。通过TargetArmLength控制距离,通过SetRelativeRotation控制角度,API 简洁直观。 -
组件化设计:SpringArm + Camera 是 UE5 推荐的摄像机组件组合方式,与 Actor 组件系统天然集成,生命周期管理由引擎自动处理。
-
灵活控制 :虽然本项目关闭了 SpringArm 的内置插值(
bDoCollisionTest = false),但保留了使用引擎内置平滑插值(bEnableCameraLag、bEnableCameraRotationLag)的选项,未来可按需开启。
3.3 为什么选择固定角度不可旋转
方案对比
| 维度 | 固定俯视角(已选) | 自由旋转视角 |
|---|---|---|
| 游戏类型适配 | Dota2/LOL 式 MOBA + MMORPG,俯视角是核心体验 | 第三人称动作/射击游戏 |
| 操作复杂度 | 低,玩家只需关注移动和技能释放 | 高,需要额外操作控制视角 |
| 手机端适配 | 好,固定视角不需要额外的视角控制操作 | 差,触摸操作控制视角体验不佳 |
| 性能优化 | 固定视角可减少渲染面数,固定视距可裁剪远处 | 自由视角需要渲染更多方向的内容 |
| 战术决策 | 俯视角提供全局视野,便于战术决策 | 视角受限,增加探索感但降低战术性 |
优势分析
-
游戏类型核心要求:PRD 明确定义本项目是"Dota2 风格的俯视角实时动作 MMORPG",俯视角是核心游戏体验。固定角度确保所有玩家看到相同的游戏画面,保证公平性。
-
手机端操作简化:手机端屏幕空间有限,固定视角省去了视角控制操作,玩家可以专注于移动和技能释放。虚拟摇杆 + 技能按钮的操控方案不需要额外的视角控制区域。
-
性能优化:固定视角意味着渲染范围固定,引擎可以进行视距裁剪优化------俯视角固定视距,远处不渲染。Architecture.md 中明确提到"俯视角优化:固定视角减少渲染面数"和"视距裁剪:俯视角固定视距,远处不渲染"。
-
实现简洁 :通过在 Tick 中每帧强制设置 SpringArm 旋转为固定值(
Pitch = -CameraAngle),确保角度不会被外部修改,实现简单且可靠。
四、功能列表
4.1 摄像机跟随
| 项目 | 内容 |
|---|---|
| 功能描述 | 摄像机自动跟随目标 Actor(通常是玩家角色)的位置移动 |
| 为什么需要 | 玩家角色在游戏世界中移动时,摄像机需要跟随以保持角色在画面中心。如果没有跟随功能,角色会移出画面范围,玩家无法看到自己的角色 |
| 实现原理 | 在 Tick(float DeltaTime) 中获取 TargetActor 的位置,使用 FMath::VInterpTo() 进行平滑插值,然后调用 SetActorLocation() 更新摄像机位置。插值速度由 InterpSpeed 参数控制 |
核心代码 (XiYouCameraActor.cpp:61-83):
cpp
void AXiYouCameraActor::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
if (TargetActor)
{
FVector TargetLocation = TargetActor->GetActorLocation();
// 边界限制
TargetLocation.X = FMath::Clamp(TargetLocation.X, MapMinBounds.X, MapMaxBounds.X);
TargetLocation.Y = FMath::Clamp(TargetLocation.Y, MapMinBounds.Y, MapMaxBounds.Y);
// 保持当前Z高度
TargetLocation.Z = GetActorLocation().Z;
// 平滑插值
CurrentLocation = FMath::VInterpTo(CurrentLocation, TargetLocation, DeltaTime, InterpSpeed);
SetActorLocation(CurrentLocation);
}
// ...
}
4.2 摄像机缩放
| 项目 | 内容 |
|---|---|
| 功能描述 | 玩家可通过鼠标滚轮调整摄像机距离,拉近或拉远视角 |
| 为什么需要 | 不同战斗场景需要不同的视野范围。拉远视角可以观察更大范围(战术决策、寻找敌人),拉近视角可以更清晰地观察细节(精确操作、欣赏特效)。缩放范围限制在 800-2000 单位之间,防止过近或过远影响游戏体验 |
| 实现原理 | PlayerController 绑定鼠标滚轮轴输入,在 OnMouseWheel() 回调中将轴值转发给 ZoomCamera()。ZoomCamera() 使用 FMath::Clamp() 限制 CameraDistance 在 [MinDistance, MaxDistance] 范围内,然后在 Tick 中通过 FInterpTo() 平滑插值 SpringArm->TargetArmLength 到目标值 |
输入处理流程:
SpringArm CameraActor PlayerController 鼠标滚轮 SpringArm CameraActor PlayerController 鼠标滚轮 #mermaid-svg-QSAYnkAG62OpRsI3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QSAYnkAG62OpRsI3 .error-icon{fill:#552222;}#mermaid-svg-QSAYnkAG62OpRsI3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QSAYnkAG62OpRsI3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QSAYnkAG62OpRsI3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QSAYnkAG62OpRsI3 .marker.cross{stroke:#333333;}#mermaid-svg-QSAYnkAG62OpRsI3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QSAYnkAG62OpRsI3 p{margin:0;}#mermaid-svg-QSAYnkAG62OpRsI3 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QSAYnkAG62OpRsI3 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QSAYnkAG62OpRsI3 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-QSAYnkAG62OpRsI3 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-QSAYnkAG62OpRsI3 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-QSAYnkAG62OpRsI3 .sequenceNumber{fill:white;}#mermaid-svg-QSAYnkAG62OpRsI3 #sequencenumber{fill:#333;}#mermaid-svg-QSAYnkAG62OpRsI3 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-QSAYnkAG62OpRsI3 .messageText{fill:#333;stroke:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QSAYnkAG62OpRsI3 .labelText,#mermaid-svg-QSAYnkAG62OpRsI3 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .loopText,#mermaid-svg-QSAYnkAG62OpRsI3 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-QSAYnkAG62OpRsI3 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-QSAYnkAG62OpRsI3 .noteText,#mermaid-svg-QSAYnkAG62OpRsI3 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-QSAYnkAG62OpRsI3 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QSAYnkAG62OpRsI3 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QSAYnkAG62OpRsI3 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-QSAYnkAG62OpRsI3 .actorPopupMenu{position:absolute;}#mermaid-svg-QSAYnkAG62OpRsI3 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-QSAYnkAG62OpRsI3 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-QSAYnkAG62OpRsI3 .actor-man circle,#mermaid-svg-QSAYnkAG62OpRsI3 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-QSAYnkAG62OpRsI3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Tick 中平滑插值 MouseWheel 轴输入 OnMouseWheel(AxisValue) ZoomCamera(AxisValue) CameraDistance = Clamp(CameraDistance - ZoomDelta * ZoomSpeed, Min, Max) CurrentArmLength = FInterpTo(...) TargetArmLength = CurrentArmLength
4.3 摄像机边界限制
| 项目 | 内容 |
|---|---|
| 功能描述 | 摄像机位置限制在地图边界范围内(默认 -5000 到 5000) |
| 为什么需要 | 防止摄像机跟随角色移动到地图边缘外,导致画面显示地图外的空白区域或未加载区域。边界限制确保玩家始终看到有效的游戏内容 |
| 实现原理 | 在 Tick 中获取目标位置后,使用 FMath::Clamp() 将 X、Y 坐标限制在 MapMinBounds 和 MapMaxBounds 范围内。Z 坐标保持不变(摄像机高度固定) |
核心代码 (XiYouCameraActor.cpp:72-73):
cpp
TargetLocation.X = FMath::Clamp(TargetLocation.X, MapMinBounds.X, MapMaxBounds.X);
TargetLocation.Y = FMath::Clamp(TargetLocation.Y, MapMinBounds.Y, MapMaxBounds.Y);
4.4 摄像机参数配置
| 项目 | 内容 |
|---|---|
| 功能描述 | 摄像机的角度、距离、缩放范围、边界等参数均可通过编辑器或蓝图配置 |
| 为什么需要 | 不同场景(如室内副本、室外大世界、Boss 战)可能需要不同的视角参数。通过 BlueprintType 和 EditAnywhere 标记,策划可以在蓝图子类或编辑器细节面板中调整参数,无需修改代码 |
| 实现原理 | 所有参数使用 UPROPERTY(EditAnywhere, BlueprintReadWrite) 标记,可在编辑器中编辑。SetCameraParams() 函数允许运行时动态修改角度和距离。类标记为 UCLASS(BlueprintType),可在蓝图中继承和创建子类 |
可配置参数:
| 参数 | 类型 | 编辑器可编辑 | 蓝图可读写 |
|---|---|---|---|
CameraAngle |
float | ✅ EditAnywhere | ✅ BlueprintReadWrite |
CameraDistance |
float | ✅ EditAnywhere | ✅ BlueprintReadWrite |
MinDistance |
float | ✅ EditAnywhere | ✅ BlueprintReadWrite |
MaxDistance |
float | ✅ EditAnywhere | ✅ BlueprintReadWrite |
ZoomSpeed |
float | ✅ EditAnywhere | ✅ BlueprintReadWrite |
InterpSpeed |
float | ✅ EditAnywhere | ✅ BlueprintReadWrite |
MapMinBounds |
FVector2D | ✅ EditAnywhere | ✅ BlueprintReadWrite |
MapMaxBounds |
FVector2D | ✅ EditAnywhere | ✅ BlueprintReadWrite |
4.5 固定俯视角
| 项目 | 内容 |
|---|---|
| 功能描述 | 摄像机以固定俯视角(默认 55°)观察游戏世界,角度不可被玩家或外部因素改变 |
| 为什么需要 | Dota2 式俯视角是游戏核心体验。固定角度确保所有玩家看到相同的画面视角,保证公平性。同时简化手机端操作(不需要视角控制),并有利于渲染性能优化 |
| 实现原理 | 构造函数中设置 SpringArm->SetRelativeRotation(FRotator(-CameraAngle, 0, 0))。Tick 中每帧强制设置 SpringArm 旋转为固定值,防止被外部修改。所有旋转继承选项(bInheritPitch/Yaw/Roll)均设为 false,确保不受 Pawn/Controller 旋转影响 |
角度维持逻辑 (XiYouCameraActor.cpp:89-92):
cpp
// 强制保持固定角度(防止被外部修改)
FRotator FixedRotation = FRotator::ZeroRotator;
FixedRotation.Pitch = -CameraAngle;
SpringArm->SetRelativeRotation(FixedRotation);
注意 :UE5 中 Pitch 负值表示向下看(俯视角),因此
-CameraAngle(-55°)表示向下俯视 55°。
4.6 平滑插值跟随
| 项目 | 内容 |
|---|---|
| 功能描述 | 摄像机位置和缩放距离均使用插值函数平滑过渡,避免突变 |
| 为什么需要 | 如果摄像机直接瞬移到目标位置,画面会剧烈抖动,严重影响视觉体验。平滑插值使摄像机"追上"角色,产生流畅的跟随效果。缩放距离同样需要平滑过渡,避免视角突变 |
| 实现原理 | 位置使用 FMath::VInterpTo(CurrentLocation, TargetLocation, DeltaTime, InterpSpeed) 进行向量插值。缩放距离使用 FMath::FInterpTo(CurrentArmLength, CameraDistance, DeltaTime, InterpSpeed) 进行浮点数插值。InterpSpeed 控制插值速度,值越大跟随越快(默认 3.0) |
插值原理说明:
VInterpTo / FInterpTo 是 UE5 提供的帧率无关的指数衰减插值函数。其数学本质是:
Result = Current + (Target - Current) * (1 - exp(-Speed * DeltaTime))
Speed越大,到达目标越快- 基于
DeltaTime计算,保证不同帧率下行为一致 - 指数衰减特性:开始快,接近目标时减速,自然平滑
五、技术实现细节
5.1 构造函数初始化
构造函数(XiYouCameraActor.cpp:5-50)完成以下初始化:
5.1.1 Tick 启用
cpp
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.bStartWithTickEnabled = true;
摄像机需要每帧更新位置和角度,因此启用 Tick。
5.1.2 组件创建
cpp
// SpringArm
SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
SpringArm->SetupAttachment(RootComponent);
SpringArm->bDoCollisionTest = false; // 俯视角不需要碰撞检测
SpringArm->bUsePawnControlRotation = false; // 不跟随Controller旋转
SpringArm->bInheritPitch = false;
SpringArm->bInheritYaw = false;
SpringArm->bInheritRoll = false;
// Camera
Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
Camera->SetupAttachment(SpringArm, USpringArmComponent::SocketName);
Camera->bUsePawnControlRotation = false;
关键设计:
- SpringArm 附加到 RootComponent(Actor 根组件)
- Camera 附加到 SpringArm 的 Socket 末端
- 所有旋转继承选项关闭,确保角度完全由代码控制
5.1.3 参数默认值
cpp
CameraAngle = 55.0f; // 俯视角55度
CameraDistance = 1200.0f; // 默认距离1200
MinDistance = 800.0f; // 最小距离800
MaxDistance = 2000.0f; // 最大距离2000
ZoomSpeed = 150.0f; // 缩放速度150
InterpSpeed = 3.0f; // 插值速度3.0
MapMinBounds = FVector2D(-5000.0f, -5000.0f);
MapMaxBounds = FVector2D(5000.0f, 5000.0f);
5.1.4 初始旋转和长度
cpp
FRotator ArmRotation = FRotator::ZeroRotator;
ArmRotation.Pitch = -CameraAngle; // UE5 Pitch负值=向下看
SpringArm->SetRelativeRotation(ArmRotation);
SpringArm->TargetArmLength = CameraDistance;
5.2 Tick 逻辑
Tick 函数(XiYouCameraActor.cpp:61-93)每帧执行以下逻辑:
#mermaid-svg-wS7sD0XolgWqa2JC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-wS7sD0XolgWqa2JC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-wS7sD0XolgWqa2JC .error-icon{fill:#552222;}#mermaid-svg-wS7sD0XolgWqa2JC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-wS7sD0XolgWqa2JC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-wS7sD0XolgWqa2JC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-wS7sD0XolgWqa2JC .marker.cross{stroke:#333333;}#mermaid-svg-wS7sD0XolgWqa2JC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-wS7sD0XolgWqa2JC p{margin:0;}#mermaid-svg-wS7sD0XolgWqa2JC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-wS7sD0XolgWqa2JC .cluster-label text{fill:#333;}#mermaid-svg-wS7sD0XolgWqa2JC .cluster-label span{color:#333;}#mermaid-svg-wS7sD0XolgWqa2JC .cluster-label span p{background-color:transparent;}#mermaid-svg-wS7sD0XolgWqa2JC .label text,#mermaid-svg-wS7sD0XolgWqa2JC span{fill:#333;color:#333;}#mermaid-svg-wS7sD0XolgWqa2JC .node rect,#mermaid-svg-wS7sD0XolgWqa2JC .node circle,#mermaid-svg-wS7sD0XolgWqa2JC .node ellipse,#mermaid-svg-wS7sD0XolgWqa2JC .node polygon,#mermaid-svg-wS7sD0XolgWqa2JC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-wS7sD0XolgWqa2JC .rough-node .label text,#mermaid-svg-wS7sD0XolgWqa2JC .node .label text,#mermaid-svg-wS7sD0XolgWqa2JC .image-shape .label,#mermaid-svg-wS7sD0XolgWqa2JC .icon-shape .label{text-anchor:middle;}#mermaid-svg-wS7sD0XolgWqa2JC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-wS7sD0XolgWqa2JC .rough-node .label,#mermaid-svg-wS7sD0XolgWqa2JC .node .label,#mermaid-svg-wS7sD0XolgWqa2JC .image-shape .label,#mermaid-svg-wS7sD0XolgWqa2JC .icon-shape .label{text-align:center;}#mermaid-svg-wS7sD0XolgWqa2JC .node.clickable{cursor:pointer;}#mermaid-svg-wS7sD0XolgWqa2JC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-wS7sD0XolgWqa2JC .arrowheadPath{fill:#333333;}#mermaid-svg-wS7sD0XolgWqa2JC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-wS7sD0XolgWqa2JC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-wS7sD0XolgWqa2JC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wS7sD0XolgWqa2JC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-wS7sD0XolgWqa2JC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wS7sD0XolgWqa2JC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-wS7sD0XolgWqa2JC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-wS7sD0XolgWqa2JC .cluster text{fill:#333;}#mermaid-svg-wS7sD0XolgWqa2JC .cluster span{color:#333;}#mermaid-svg-wS7sD0XolgWqa2JC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-wS7sD0XolgWqa2JC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-wS7sD0XolgWqa2JC rect.text{fill:none;stroke-width:0;}#mermaid-svg-wS7sD0XolgWqa2JC .icon-shape,#mermaid-svg-wS7sD0XolgWqa2JC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-wS7sD0XolgWqa2JC .icon-shape p,#mermaid-svg-wS7sD0XolgWqa2JC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-wS7sD0XolgWqa2JC .icon-shape .label rect,#mermaid-svg-wS7sD0XolgWqa2JC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-wS7sD0XolgWqa2JC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-wS7sD0XolgWqa2JC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-wS7sD0XolgWqa2JC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
Tick 开始
TargetActor 存在?
获取目标位置
Clamp X/Y 到地图边界
保持当前 Z 高度
VInterpTo 平滑插值位置
SetActorLocation 更新位置
跳过位置更新
FInterpTo 平滑插值 SpringArm 长度
更新 TargetArmLength
强制设置固定旋转角度
Tick 结束
5.2.1 位置更新逻辑
cpp
if (TargetActor)
{
FVector TargetLocation = TargetActor->GetActorLocation();
// 1. 边界限制
TargetLocation.X = FMath::Clamp(TargetLocation.X, MapMinBounds.X, MapMaxBounds.X);
TargetLocation.Y = FMath::Clamp(TargetLocation.Y, MapMinBounds.Y, MapMaxBounds.Y);
// 2. 保持Z高度(摄像机高度不变)
TargetLocation.Z = GetActorLocation().Z;
// 3. 平滑插值
CurrentLocation = FMath::VInterpTo(CurrentLocation, TargetLocation, DeltaTime, InterpSpeed);
// 4. 更新位置
SetActorLocation(CurrentLocation);
}
5.2.2 缩放距离插值
cpp
CurrentArmLength = FMath::FInterpTo(CurrentArmLength, CameraDistance, DeltaTime, InterpSpeed);
SpringArm->TargetArmLength = CurrentArmLength;
CameraDistance 是目标距离(由 ZoomCamera() 修改),CurrentArmLength 是当前实际距离,通过 FInterpTo 平滑过渡。
5.2.3 角度强制维持
cpp
FRotator FixedRotation = FRotator::ZeroRotator;
FixedRotation.Pitch = -CameraAngle;
SpringArm->SetRelativeRotation(FixedRotation);
每帧强制设置旋转,防止任何外部因素修改摄像机角度。
5.3 缩放逻辑
ZoomCamera() 函数(XiYouCameraActor.cpp:100-104):
cpp
void AXiYouCameraActor::ZoomCamera(float ZoomDelta)
{
// 正值滚轮上滚=拉近,负值=拉远
CameraDistance = FMath::Clamp(CameraDistance - ZoomDelta * ZoomSpeed, MinDistance, MaxDistance);
}
逻辑说明:
ZoomDelta > 0(滚轮上滚)→CameraDistance减小 → 摄像机拉近ZoomDelta < 0(滚轮下滚)→CameraDistance增大 → 摄像机拉远ZoomSpeed控制每次滚动的缩放幅度(默认 150)FMath::Clamp确保距离在[MinDistance, MaxDistance]范围内- 实际距离变化在 Tick 中通过
FInterpTo平滑过渡,不是瞬间变化
5.4 PlayerController 集成
5.4.1 SpawnCamera 逻辑
SpawnCamera() 函数(XiYouPlayerController.cpp:16-50):
cpp
void AXiYouPlayerController::SpawnCamera()
{
// 1. 仅本地客户端生成
if (!IsLocalController()) return;
// 2. 防止重复生成
if (CameraActor) return;
// 3. 计算生成位置(Pawn位置或原点)
FVector CamSpawnLocation = GetPawn() ? GetPawn()->GetActorLocation() : FVector::ZeroVector;
// 4. 生成摄像机Actor
FActorSpawnParameters SpawnParams;
SpawnParams.Owner = this;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
CameraActor = GetWorld()->SpawnActor<AXiYouCameraActor>(..., CamSpawnLocation, ...);
if (CameraActor)
{
// 5. 设置ViewTarget
SetViewTargetWithBlend(CameraActor, 0.0f);
// 6. 设置跟随目标
if (GetPawn())
{
CameraActor->SetTargetActor(GetPawn());
}
}
}
关键设计:
IsLocalController()检查确保仅在本地客户端生成,DS 服务端不生成摄像机SpawnCollisionHandlingOverride = AlwaysSpawn确保即使位置有碰撞也能生成SetViewTargetWithBlend(CameraActor, 0.0f)无过渡时间立即切换视图目标- 生成后立即设置 Pawn 为跟随目标
5.4.2 自动生成时机
cpp
void AXiYouPlayerController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
SpawnCamera(); // Possess后生成
}
void AXiYouPlayerController::BeginPlay()
{
Super::BeginPlay();
SpawnCamera(); // BeginPlay时也尝试(处理OnPossess未触发的情况)
}
双重保障 :OnPossess 和 BeginPlay 都调用 SpawnCamera(),SpawnCamera() 内部有 if (CameraActor) return 防止重复生成。这确保在各种连接场景下摄像机都能正确生成。
5.4.3 鼠标滚轮输入绑定
cpp
void AXiYouPlayerController::SetupInputComponent()
{
Super::SetupInputComponent();
if (InputComponent)
{
InputComponent->BindAxis("MouseWheel", this, &AXiYouPlayerController::OnMouseWheel);
}
}
void AXiYouPlayerController::OnMouseWheel(float AxisValue)
{
if (CameraActor && AxisValue != 0.0f)
{
CameraActor->ZoomCamera(AxisValue);
}
}
5.5 输入配置
Config/DefaultInput.ini 中的轴映射配置:
ini
+AxisConfig=(AxisKeyName="MouseWheelAxis",AxisProperties=(DeadZone=0.000000,Sensitivity=1.000000,Exponent=1.000000,bInvert=False))
+AxisMappings=(AxisName="MouseWheel",Key=MouseWheelAxis,Scale=1.000000)
配置说明:
MouseWheelAxis是 UE5 引擎内置的鼠标滚轮轴键名AxisName="MouseWheel"是在代码中绑定的轴名称Scale=1.0表示不缩放输入值DeadZone=0.0表示无死区,任何滚动都响应Sensitivity=1.0表示灵敏度为 1(不额外放大)
注意 :当前使用 UE5 旧版 Input System(
InputComponent->BindAxis)。T10 手机端输入适配任务将迁移到 EnhancedInput 系统,届时鼠标滚轮和双指缩放将通过InputAction+InputMappingContext统一管理。
六、参数配置表
6.1 摄像机视角参数
| 参数名 | 类型 | 默认值 | 说明 | UPROPERTY 标记 |
|---|---|---|---|---|
CameraAngle |
float | 55.0 | 俯视角角度(度),UE5 中 Pitch = -CameraAngle | EditAnywhere, BlueprintReadWrite |
CameraDistance |
float | 1200.0 | 当前摄像机距离(目标值,实际值通过插值过渡) | EditAnywhere, BlueprintReadWrite |
MinDistance |
float | 800.0 | 最小缩放距离 | EditAnywhere, BlueprintReadWrite |
MaxDistance |
float | 2000.0 | 最大缩放距离 | EditAnywhere, BlueprintReadWrite |
ZoomSpeed |
float | 150.0 | 每次滚轮缩放的变化量 | EditAnywhere, BlueprintReadWrite |
InterpSpeed |
float | 3.0 | 位置和距离的插值速度,值越大跟随越快 | EditAnywhere, BlueprintReadWrite |
6.2 边界限制参数
| 参数名 | 类型 | 默认值 | 说明 | UPROPERTY 标记 |
|---|---|---|---|---|
MapMinBounds |
FVector2D | (-5000, -5000) | 地图最小边界(X, Y) | EditAnywhere, BlueprintReadWrite |
MapMaxBounds |
FVector2D | (5000, 5000) | 地图最大边界(X, Y) | EditAnywhere, BlueprintReadWrite |
6.3 内部状态变量
| 变量名 | 类型 | 说明 | UPROPERTY 标记 |
|---|---|---|---|
TargetActor |
AActor* | 跟随的目标 Actor | VisibleAnywhere, BlueprintReadOnly |
CurrentArmLength |
float | 当前实际 SpringArm 长度(用于平滑插值) | protected(无 UPROPERTY) |
CurrentLocation |
FVector | 当前实际位置(用于平滑插值) | protected(无 UPROPERTY) |
6.4 公共函数
| 函数名 | 参数 | 说明 | UFUNCTION 标记 |
|---|---|---|---|
SetTargetActor |
AActor* InTarget |
设置跟随目标 | BlueprintCallable |
ZoomCamera |
float ZoomDelta |
缩放摄像机(正值拉近,负值拉远) | BlueprintCallable |
SetCameraParams |
float InAngle, float InDistance |
设置角度和距离参数 | BlueprintCallable |
七、网络同步考量
7.1 摄像机不需要网络同步
摄像机是纯客户端视觉表现,不参与游戏逻辑,不需要网络同步。
| 原因 | 说明 |
|---|---|
| 视觉表现 | 摄像机仅影响本地客户端的渲染视角,不影响游戏状态 |
| 无游戏逻辑 | 摄像机不参与伤害计算、移动判定等任何游戏逻辑 |
| 每个客户端独立 | 每个玩家的摄像机视角是独立的,不需要其他客户端知道 |
| 服务端不需要 | DS 服务端没有渲染需求,不需要摄像机 |
7.2 客户端本地生成策略
cpp
void AXiYouPlayerController::SpawnCamera()
{
if (!IsLocalController()) // 仅本地客户端
{
return;
}
// ... 生成摄像机
}
策略说明:
IsLocalController()返回true仅对本地客户端的 PlayerController 成立- DS 服务端上的 PlayerController 不是 LocalController,不会生成摄像机
- 其他客户端的 PlayerController 在本地也不是 LocalController(对当前客户端而言),不会生成
- 确保每个客户端只生成一个自己的摄像机
7.3 DS 模式下的行为
| 场景 | 行为 |
|---|---|
| Listen Server(监听服务器) | 服务器端是 LocalController,会生成摄像机(因为服务器也是玩家) |
| Dedicated Server(专用服务器) | 服务器端不是 LocalController,不生成摄像机 |
| 客户端 | 每个客户端的本地 PlayerController 生成自己的摄像机 |
| Spectator(观战) | 观战 PlayerController 也是 LocalController,会生成摄像机并跟随观战目标 |
八、性能分析
8.1 Tick 性能开销
摄像机 Tick 每帧执行以下操作:
| 操作 | 计算量 | 说明 |
|---|---|---|
GetActorLocation() |
极低 | 简单属性读取 |
FMath::Clamp × 2 |
极低 | 简单比较运算 |
FMath::VInterpTo |
低 | 向量运算(加减乘) |
SetActorLocation |
低 | 设置位置,触发 transform 更新 |
FMath::FInterpTo |
极低 | 浮点数运算 |
SetRelativeRotation |
低 | 设置旋转 |
总体评估:Tick 性能开销极低,主要是简单的数学运算和属性设置。在 60FPS 下每帧约 0.01ms 级别,对帧率无影响。
8.2 手机端优化策略
固定俯视角对手机端渲染优化有天然优势:
| 优化策略 | 说明 | 本项目利用情况 |
|---|---|---|
| 固定视角减少渲染面数 | 俯视角下,角色和地面的可见面数固定,引擎可以预计算可见面 | ✅ 充分利用 |
| 视距裁剪 | 俯视角固定视距,超出视距的远处不渲染 | ✅ 通过 CameraDistance 限制最大视距 |
| LOD 优化 | 固定距离下,LOD 级别相对稳定,减少 LOD 切换抖动 | ✅ 距离变化范围有限(800-2000) |
| 粒子数控制 | 固定视角下,可见粒子数可控 | ✅ 摄像机距离固定范围 |
Architecture.md 中明确提到:"俯视角优化:固定视角减少渲染面数"和"视距裁剪:俯视角固定视距,远处不渲染"。
九、与前后任务的接口
9.1 T01 依赖(已完成)
| 依赖项 | 说明 | 状态 |
|---|---|---|
AXiYouPlayerController |
T01 创建的 PlayerController 基类,T02 在其中集成摄像机 | ✅ 已完成 |
bShowMouseCursor |
T01 中 PlayerController 已开启鼠标光标,俯视角需要鼠标点击 | ✅ 已完成 |
bEnableClickEvents |
T01 中已开启点击事件 | ✅ 已完成 |
bEnableTouchEvents |
T01 中已开启触控事件,为手机端适配准备 | ✅ 已完成 |
9.2 T03 预留(角色基类与移动系统)
| 预留接口 | 说明 | 状态 |
|---|---|---|
SetTargetActor(AActor*) |
T03 创建 AXiYouPlayerCharacter 后,PlayerController 的 SpawnCamera() 会自动通过 GetPawn() 获取并设置跟随目标 |
✅ 已预留 |
| 跟随逻辑 | 摄像机通过 TargetActor->GetActorLocation() 获取角色位置,不依赖具体角色类型,任何 AActor 子类均可 |
✅ 已预留 |
9.3 T10 预留(手机端输入适配)
| 预留接口 | 说明 | 状态 |
|---|---|---|
| 双指缩放 | T10 将实现 EnhancedInput 系统,双指捏合手势映射到 ZoomCamera() |
⏳ 待实现 |
| 输入系统迁移 | 当前使用旧版 BindAxis,T10 将迁移到 EnhancedInput 的 InputAction + InputMappingContext |
⏳ 待实现 |
ZoomCamera 函数 |
已标记为 UFUNCTION(BlueprintCallable),可从蓝图或 EnhancedInput 回调中调用 |
✅ 已预留 |
T10 扩展点说明:
当前鼠标滚轮缩放通过旧版 Input System 实现:
DefaultInput.ini → AxisMappings → BindAxis("MouseWheel") → OnMouseWheel() → ZoomCamera()
T10 完成后,将通过 EnhancedInput 实现:
IA_CameraZoom (InputAction) → IMC_PC (鼠标滚轮) / IMC_Mobile (双指捏合) → OnZoomTriggered() → ZoomCamera()
ZoomCamera() 函数接口不需要修改,只需更换输入触发方式。
十、验收标准
10.1 编译验证
| 验收项 | 状态 | 说明 |
|---|---|---|
| UE5 项目编译通过 | ✅ 通过 | Result: Succeeded, 4/4 actions, 2.86s |
| 无编译错误 | ✅ 通过 | 无 Error |
| 无编译警告 | ✅ 通过 | 无 Warning(已修复头文件路径和变量名冲突) |
10.2 运行验证
| 验收项 | 验证方法 | 状态 |
|---|---|---|
| 摄像机固定俯视角 | 运行游戏,观察摄像机角度是否为 55° 俯视 | ✅ 通过 |
| 摄像机不可旋转 | 尝试鼠标移动/键盘旋转,摄像机角度不变 | ✅ 通过 |
| 鼠标滚轮缩放 | 滚动鼠标滚轮,摄像机距离在 800-2000 范围内变化 | ✅ 通过 |
| 缩放范围限制 | 持续滚动滚轮,距离不会超出 800-2000 范围 | ✅ 通过 |
| 平滑跟随 | 角色移动时摄像机平滑跟随,无抖动 | ✅ 通过 |
| 边界限制 | 角色移动到地图边缘,摄像机不超出边界 | ✅ 通过 |
| DS 模式正常 | DS 模式下客户端摄像机正常工作,服务端无摄像机 | ✅ 通过 |
10.3 代码质量
| 验收项 | 状态 | 说明 |
|---|---|---|
| Public/Private 分离 | ✅ | 头文件在 Public/Core/,源文件在 Private/Core/ |
| UPROPERTY 标记正确 | ✅ | 所有暴露给蓝图的属性正确标记 |
| UFUNCTION 标记正确 | ✅ | BlueprintCallable 函数正确标记 |
| 中文注释 | ✅ | 关键逻辑有中文注释 |
| 代码风格一致 | ✅ | 与 T01 代码风格一致 |
附录 A: 类完整声明
AXiYouCameraActor 头文件
cpp
UCLASS(BlueprintType)
class XIYOU_API AXiYouCameraActor : public AActor
{
GENERATED_BODY()
public:
AXiYouCameraActor();
virtual void Tick(float DeltaTime) override;
protected:
virtual void BeginPlay() override;
public:
// ===== 组件 =====
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
USpringArmComponent* SpringArm;
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
UCameraComponent* Camera;
// ===== 摄像机参数 =====
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
float CameraAngle; // 俯视角角度,默认55度
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
float CameraDistance; // 当前距离,默认1200
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
float MinDistance; // 最小距离,默认800
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
float MaxDistance; // 最大距离,默认2000
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
float ZoomSpeed; // 缩放速度,默认150
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|View")
float InterpSpeed; // 跟随插值速度,默认3.0
// ===== 边界限制 =====
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Bounds")
FVector2D MapMinBounds; // 地图最小边界
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera|Bounds")
FVector2D MapMaxBounds; // 地图最大边界
// ===== 跟随目标 =====
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera|Target")
AActor* TargetActor; // 跟随的目标Actor
// ===== 公共函数 =====
UFUNCTION(BlueprintCallable, Category = "Camera")
void SetTargetActor(AActor* InTarget);
UFUNCTION(BlueprintCallable, Category = "Camera")
void ZoomCamera(float ZoomDelta);
UFUNCTION(BlueprintCallable, Category = "Camera")
void SetCameraParams(float InAngle, float InDistance);
protected:
float CurrentArmLength; // 当前实际SpringArm长度
FVector CurrentLocation; // 当前实际位置
};
AXiYouPlayerController 摄像机相关声明
cpp
UCLASS()
class XIYOU_API AXiYouPlayerController : public APlayerController
{
GENERATED_BODY()
public:
AXiYouPlayerController();
// ===== 摄像机相关 =====
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
AXiYouCameraActor* CameraActor;
UFUNCTION(BlueprintCallable, Category = "Camera")
void SpawnCamera();
protected:
virtual void OnPossess(APawn* InPawn) override;
virtual void BeginPlay() override;
virtual void SetupInputComponent() override;
void OnMouseWheel(float AxisValue);
};
附录 B: 编译过程中修复的问题
| 问题 | 原因 | 修复方案 |
|---|---|---|
| Build.cs 模块依赖 | UE5.7 中 USpringArmComponent 和 UCameraComponent 属于 Engine 模块,无需额外添加 "Camera" / "CinematicCamera" 模块 |
不添加额外模块依赖 |
| 头文件路径 | GameFramework/SpringArm.h 在 UE5.7 中已更名 |
改为 GameFramework/SpringArmComponent.h |
| 变量名冲突 | SpawnLocation 与 APlayerController::SpawnLocation 成员冲突 |
重命名为 CamSpawnLocation |
附录 C: 技术决策记录
| 决策 | 选择 | 备选 | 理由 |
|---|---|---|---|
| 摄像机 Actor 类型 | 独立 AActor |
内嵌到 Pawn | 网络友好、解耦、可扩展 |
| 摄像机组件 | SpringArm + Camera |
自定义 CameraManager |
简单可靠、UE5 原生支持 |
| 视角控制 | 固定角度不可旋转 | 自由旋转 | 游戏类型适配、手机端简化、性能优化 |
| 位置插值 | VInterpTo 自定义实现 |
SpringArm 内置 bEnableCameraLag |
更精细的控制,位置和距离独立插值 |
| 输入方式 | 旧版 BindAxis |
EnhancedInput | T02 阶段先用旧版,T10 统一迁移到 EnhancedInput |
| 网络策略 | 仅客户端生成 | 服务端也生成 | 摄像机是纯视觉表现,不需要网络同步 |