7. 摄像机视角与2.5D呈现
摄像机视角与2.5D呈现
7.1 为什么摄像机视角如此重要
在2.5D桌球游戏中,摄像机就是玩家的"眼睛"。摄像机摆放的位置和角度,直接决定了:
- 玩家能不能看清楚球的位置关系
- 瞄准操作是否直观方便
- 游戏看起来够不够"高级"
生活化比喻
想象你在看一场桌球比赛:
- 如果你站在球桌正上方往下看(像无人机视角),你能看到所有球的位置,但缺少立体感
- 如果你蹲在球桌旁边看,立体感很强,但看不到全局
- 如果你从稍微倾斜的角度往下看——既能看到全局,又能感受到球的立体感
这个"稍微倾斜的角度"就是我们说的2.5D视角。
7.2 俯视角摄像机的选择
透视 vs 正交
在Godot中,摄像机有两种投影模式:
| 投影模式 | 特点 | 适用场景 |
|---|---|---|
| 透视(Perspective) | 近大远小,有深度感 | 需要立体感的场景 |
| 正交(Orthographic) | 没有透视变形,像看地图 | 纯2D风格的游戏 |
两种模式对比
透视模式 正交模式
远处的球小 所有球一样大
○ ○ ○ ○
○ ○ ○ ○ ○ ○
○ ○ ○ ○ ○ ○ ○ ○
近处的球大 大小一致推荐使用透视模式
对于桌球游戏,透视模式是更好的选择。原因如下:
- 球的立体感更强——近处的球看起来更大,远处的更小,有真实的3D感
- 球桌的纵深感更明显——不会像看平面图一样
- 阴影效果更自然——透视投影下阴影的形状更真实
如果你想要更"卡通"或"简化"的风格,可以考虑正交模式。
7.3 摄像机高度和角度
摄像机的高度和角度是影响游戏观感最重要的两个参数。我们需要找到"既能看到全局,又有立体感"的甜蜜点。
参数关系
摄像机
●
/|
/ |
/ | 高度(Y)
/ |
/θ___|
球桌中心其中:
- 高度(Y):摄像机离桌面的垂直距离
- 角度(θ):摄像机俯视的角度(0度=水平,90度=垂直向下)
- 距离(Z):摄像机离球桌中心的水平距离
不同角度的效果
| 角度 | 高度 | 效果 | 适合场景 |
|---|---|---|---|
| 90度 | 15 | 完全俯视,像看地图 | 不推荐 |
| 75度 | 12 | 接近俯视,稍有立体感 | 竞技风格 |
| 63度 | 12 | 推荐角度,平衡立体感和全局视野 | 通用 |
| 45度 | 8 | 明显的透视效果 | 沉浸风格 |
| 30度 | 5 | 低角度,像站在桌边 | 不推荐(操作困难) |
推荐参数
经过实际测试,以下参数在大多数情况下效果最好:
- 位置:
(0, 12, 6) - 旋转:
(-63, 0, 0) - FOV:
45 - 投影: 透视
这个角度既能看到整个球桌,又能感受到球的立体感和光影效果。
7.4 球体的3D效果
球体的3D效果是2.5D桌球游戏"高级感"的来源。一个看起来像真实桌球的3D球体,需要正确设置光影和材质。
高光(Specular)
高光是球体表面反射光源产生的一个亮点。真实桌球在灯光下会有一道明显的高光,这让球看起来光滑圆润。
| 材质参数 | 推荐值 | 说明 |
|---|---|---|
| Roughness | 0.1-0.2 | 低粗糙度=光滑表面=明显高光 |
| Metallic | 0.0 | 球不是金属 |
| Clearcoat | 0.8-1.0 | 清漆层让高光更明显 |
| Clearcoat Roughness | 0.1 | 清漆层也要光滑 |
Roughness和高光的关系
- Roughness = 0 → 镜面,有一个很亮很集中的高光点
- Roughness = 0.5 → 半光滑,高光较分散
- Roughness = 1 → 完全粗糙,没有高光(像布料)
桌球表面很光滑,所以Roughness要设得很低。
阴影(Shadow)
阴影让球看起来"放在桌面上"而不是"浮在空中"。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| DirectionalLight3D → Shadow → Enabled | 开启 | 必须开启 |
| Shadow → Opacity | 0.5-0.7 | 阴影不能太实也不能太虚 |
| Shadow → Blur | 2-3 | 适当的模糊让阴影更柔和 |
| Shadow → Filter | PCF5 | 阴影过滤模式 |
反射(Reflection)
桌球的光滑表面会反射周围的环境。Godot 4支持屏幕空间反射(SSR):
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Roughness | 低 | 表面越光滑,反射越清晰 |
| Environment Map | 使用WorldEnvironment | 提供反射环境 |
性能提醒
反射效果很好看,但会增加GPU负担。如果目标平台是手机,建议关闭反射或降低质量。桌面平台一般没有问题。
球号标记
真实桌球上有一个白色小圆圈,里面写着球号。在3D游戏中实现这个效果有几种方法:
| 方法 | 说明 | 难度 |
|---|---|---|
| 纹理贴图 | 用Photoshop制作球的纹理贴图 | 中等 |
| 花色球白条 | 用材质+UV映射做花色球的白条 | 较难 |
| 不做标记 | 只用颜色区分,不显示球号 | 最简单 |
初学者建议
如果你刚开始做,可以先不做球号标记,只用颜色区分。等游戏功能完成后,再添加球号标记作为"打磨"工作。
7.5 球桌材质效果
台呢材质
台呢(球桌上的绿色布料)是桌球游戏视觉风格的核心。好的台呢材质应该看起来像真正的布料。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Albedo Color | #0a6e2e | 深绿色 |
| Roughness | 0.85 | 高粗糙度,模拟布料质感 |
| Normal Map | 可选 | 布料纹理的法线贴图 |
| UV1 Scale | (50, 25) | 平铺纹理 |
法线贴图(Normal Map)
法线贴图是一种让平面看起来有凹凸纹理的技术。台呢的布料纹理可以通过法线贴图来模拟——虽然表面实际上是平的,但看起来像有编织纹理。
你可以在网上找到免费的布料法线贴图。
木框材质
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Albedo Color | #5c3317 | 深棕色 |
| Roughness | 0.3-0.4 | 中等粗糙度(上漆木头) |
| Metallic | 0.05 | 微弱金属感 |
| Normal Map | 木纹法线贴图 | 模拟木纹 |
球袋材质
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Albedo Color | #111111 | 接近纯黑 |
| Roughness | 0.95 | 很粗糙(毛毡内衬) |
7.6 视角切换
有些桌球游戏允许玩家切换视角,比如从默认俯视角切换到自由视角观察球桌。
可切换的视角
| 视角 | 位置 | 用途 |
|---|---|---|
| 默认俯视角 | (0, 12, 6) | 正常游戏 |
| 侧面视角 | (0, 3, 5) | 观察球的侧面 |
| 玩家1视角 | (-2, 3, 4) | 从玩家1方向看 |
| 玩家2视角 | (2, 3, -4) | 从玩家2方向看 |
| 俯视全局 | (0, 15, 0) | 查看全局布局 |
视角切换的时机
视角切换应该在"球在运动时"禁用,只在"等待玩家操作时"允许。否则在球运动时切换视角会让玩家看不清球的运动轨迹。
摄像机控制器(增强版)
using Godot;
/// <summary>
/// 增强版摄像机控制器 - 支持视角切换和击球动画
/// </summary>
public partial class EnhancedCameraController : Camera3D
{
/// <summary>预设视角</summary>
[Export] public Vector3 DefaultPosition { get; set; } = new Vector3(0, 12, 6);
[Export] public Vector3 DefaultRotation { get; set; } = new Vector3(-63, 0, 0);
[Export] public Vector3 SideViewPosition { get; set; } = new Vector3(0, 3, 5);
[Export] public Vector3 SideViewRotation { get; set; } = new Vector3(-30, 0, 0);
[Export] public Vector3 TopViewPosition { get; set; } = new Vector3(0, 15, 0.1f);
[Export] public Vector3 TopViewRotation { get; set; } = new Vector3(-89, 0, 0);
/// <summary>过渡速度</summary>
[Export] public float TransitionSpeed { get; set; } = 3.0f;
/// <summary>是否允许视角切换</summary>
public bool CanSwitchView { get; set; } = true;
/// <summary>当前视角名称</summary>
public string CurrentViewName { get; private set; } = "默认俯视角";
/// <summary>目标位置</summary>
private Vector3 _targetPosition;
/// <summary>目标旋转</summary>
private Vector3 _targetRotation;
/// <summary>是否正在过渡中</summary>
private bool _isTransitioning = false;
/// <summary>视角切换事件</summary>
[Signal] public delegate void ViewChangedEventHandler(string viewName);
public override void _Ready()
{
Position = DefaultPosition;
RotationDegrees = DefaultRotation;
_targetPosition = DefaultPosition;
_targetRotation = DefaultRotation;
}
public override void _Process(double delta)
{
// 平滑过渡
if (_isTransitioning)
{
float t = (float)(TransitionSpeed * delta);
Position = Position.Lerp(_targetPosition, t);
RotationDegrees = RotationDegrees.Lerp(_targetRotation, t);
// 检查是否到达目标
if (Position.DistanceTo(_targetPosition) < 0.01f)
{
Position = _targetPosition;
RotationDegrees = _targetRotation;
_isTransitioning = false;
}
}
}
public override void _UnhandledInput(InputEvent @event)
{
if (!CanSwitchView) return;
// 数字键1:默认俯视角
if (@event is InputEventKey key1 && key1.Keycode == Key.Key1 && key1.Pressed)
{
SwitchToDefault();
}
// 数字键2:侧面视角
if (@event is InputEventKey key2 && key2.Keycode == Key.Key2 && key2.Pressed)
{
SwitchToSideView();
}
// 数字键3:俯视全局
if (@event is InputEventKey key3 && key3.Keycode == Key.Key3 && key3.Pressed)
{
SwitchToTopView();
}
}
/// <summary>
/// 切换到默认俯视角
/// </summary>
public void SwitchToDefault()
{
MoveTo(DefaultPosition, DefaultRotation);
CurrentViewName = "默认俯视角";
EmitSignal(SignalName.ViewChanged, CurrentViewName);
}
/// <summary>
/// 切换到侧面视角
/// </summary>
public void SwitchToSideView()
{
MoveTo(SideViewPosition, SideViewRotation);
CurrentViewName = "侧面视角";
EmitSignal(SignalName.ViewChanged, CurrentViewName);
}
/// <summary>
/// 切换到俯视全局
/// </summary>
public void SwitchToTopView()
{
MoveTo(TopViewPosition, TopViewRotation);
CurrentViewName = "俯视全局";
EmitSignal(SignalName.ViewChanged, CurrentViewName);
}
/// <summary>
/// 移动到指定视角
/// </summary>
public void MoveTo(Vector3 position, Vector3 rotation)
{
_targetPosition = position;
_targetRotation = rotation;
_isTransitioning = true;
}
/// <summary>
/// 聚焦到某个球(击球前的特写动画)
/// </summary>
public void FocusOnBall(Vector3 ballPosition)
{
// 计算摄像机位置:在球的斜上方
var focusPos = new Vector3(
ballPosition.X - 0.5f,
ballPosition.Y + 2.0f,
ballPosition.Z + 1.5f
);
var focusRot = new Vector3(-55, 15, 0);
MoveTo(focusPos, focusRot);
CurrentViewName = "聚焦视角";
EmitSignal(SignalName.ViewChanged, CurrentViewName);
}
/// <summary>
/// 击球跟随动画 - 跟随母球运动
/// </summary>
/// <param name="cueBallPosition">母球当前位置</param>
/// <param name="cueBallVelocity">母球当前速度</param>
public void FollowCueBall(Vector3 cueBallPosition, Vector3 cueBallVelocity)
{
// 摄像机跟随母球,但保持一定距离
var speed = cueBallVelocity.Length();
if (speed > 0.5f)
{
// 球在运动,摄像机跟在后面
var followPos = new Vector3(
cueBallPosition.X,
DefaultPosition.Y,
cueBallPosition.Z + DefaultPosition.Z
);
Position = Position.Lerp(followPos, 0.05f);
}
else
{
// 球停了,回到默认位置
SwitchToDefault();
}
}
/// <summary>
/// 重置到默认视角
/// </summary>
public void ResetToDefault()
{
SwitchToDefault();
}
}## 增强版摄像机控制器 - 支持视角切换和击球动画
extends Camera3D
## 默认俯视角位置
@export var default_position: Vector3 = Vector3(0, 12, 6)
@export var default_rotation: Vector3 = Vector3(-63, 0, 0)
## 侧面视角
@export var side_view_position: Vector3 = Vector3(0, 3, 5)
@export var side_view_rotation: Vector3 = Vector3(-30, 0, 0)
## 俯视全局
@export var top_view_position: Vector3 = Vector3(0, 15, 0.1)
@export var top_view_rotation: Vector3 = Vector3(-89, 0, 0)
## 过渡速度
@export var transition_speed: float = 3.0
## 是否允许视角切换
var can_switch_view: bool = true
## 当前视角名称
var current_view_name: String = "默认俯视角"
## 目标位置和旋转
var _target_position: Vector3
var _target_rotation: Vector3
var _is_transitioning: bool = false
## 视角切换事件
signal view_changed(view_name: String)
func _ready() -> void:
position = default_position
rotation_degrees = default_rotation
_target_position = default_position
_target_rotation = default_rotation
func _process(delta: float) -> void:
if _is_transitioning:
var t = transition_speed * delta
position = position.lerp(_target_position, t)
rotation_degrees = rotation_degrees.lerp(_target_rotation, t)
if position.distance_to(_target_position) < 0.01:
position = _target_position
rotation_degrees = _target_rotation
_is_transitioning = false
func _unhandled_input(event: InputEvent) -> void:
if not can_switch_view:
return
if event is InputEventKey:
var key = event as InputEventKey
if key.pressed:
match key.keycode:
KEY_1: switch_to_default()
KEY_2: switch_to_side_view()
KEY_3: switch_to_top_view()
## 切换到默认俯视角
func switch_to_default() -> void:
move_to(default_position, default_rotation)
current_view_name = "默认俯视角"
view_changed.emit(current_view_name)
## 切换到侧面视角
func switch_to_side_view() -> void:
move_to(side_view_position, side_view_rotation)
current_view_name = "侧面视角"
view_changed.emit(current_view_name)
## 切换到俯视全局
func switch_to_top_view() -> void:
move_to(top_view_position, top_view_rotation)
current_view_name = "俯视全局"
view_changed.emit(current_view_name)
## 移动到指定视角
func move_to(pos: Vector3, rot: Vector3) -> void:
_target_position = pos
_target_rotation = rot
_is_transitioning = true
## 聚焦到某个球
func focus_on_ball(ball_position: Vector3) -> void:
var focus_pos = Vector3(
ball_position.x - 0.5,
ball_position.y + 2.0,
ball_position.z + 1.5
)
var focus_rot = Vector3(-55, 15, 0)
move_to(focus_pos, focus_rot)
current_view_name = "聚焦视角"
view_changed.emit(current_view_name)
## 击球跟随动画
func follow_cue_ball(cue_ball_position: Vector3, cue_ball_velocity: Vector3) -> void:
var speed = cue_ball_velocity.length()
if speed > 0.5:
var follow_pos = Vector3(
cue_ball_position.x,
default_position.y,
cue_ball_position.z + default_position.z
)
position = position.lerp(follow_pos, 0.05)
else:
switch_to_default()
## 重置到默认视角
func reset_to_default() -> void:
switch_to_default()7.7 击球时的摄像机动画
当玩家击球后,摄像机可以做一个小的"冲击"动画,增强打击感。
击球震动效果
击球时,摄像机微微震动,模拟击球的冲击力:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 震动幅度 | 0.02-0.05 | 不要太大,微微晃动即可 |
| 震动持续时间 | 0.1-0.2秒 | 很短的震动 |
| 震动衰减 | 指数衰减 | 震动越来越小 |
震动实现
// C# - 简单的摄像机震动
private float _shakeAmount = 0;
private float _shakeDuration = 0;
public void StartShake(float amount, float duration)
{
_shakeAmount = amount;
_shakeDuration = duration;
}
public override void _Process(double delta)
{
if (_shakeDuration > 0)
{
_shakeDuration -= (float)delta;
var offset = new Vector3(
(float)GD.RandRange(-_shakeAmount, _shakeAmount),
(float)GD.RandRange(-_shakeAmount, _shakeAmount),
(float)GD.RandRange(-_shakeAmount, _shakeAmount)
);
Position += offset;
_shakeAmount *= 0.9f; // 衰减
}
}# GDScript - 简单的摄像机震动
var _shake_amount: float = 0.0
var _shake_duration: float = 0.0
func start_shake(amount: float, duration: float) -> void:
_shake_amount = amount
_shake_duration = duration
func _process(delta: float) -> void:
if _shake_duration > 0:
_shake_duration -= delta
var offset = Vector3(
randf_range(-_shake_amount, _shake_amount),
randf_range(-_shake_amount, _shake_amount),
randf_range(-_shake_amount, _shake_amount)
)
position += offset
_shake_amount *= 0.9 # 衰减7.8 后处理效果
Godot 4支持后处理效果,可以进一步增强游戏的视觉效果。
推荐的后处理效果
| 效果 | 说明 | 适用场景 |
|---|---|---|
| SSAO | 屏幕空间环境光遮蔽 | 增强球和桌面的接触感 |
| Bloom | 泛光效果 | 台球灯的光晕 |
| DOF | 景深效果 | 聚焦到击球区域 |
| Vignette | 暗角效果 | 增加氛围感 |
后处理效果的开启方式
在Godot 4中,后处理效果通过 WorldEnvironment 节点的 Environment 属性中的 ScreenSpace 部分来配置。SSAO和Bloom可以直接开启,DOF和Vignette需要额外的设置。
环境光遮蔽(SSAO)
SSAO可以让球和桌面接触的地方产生自然的阴影,让球看起来"真的放在桌面上"。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| Enabled | 开启 | 必须开启 |
| Radius | 1.0 | 遮蔽半径 |
| Intensity | 1.0 | 遮蔽强度 |
| Power | 1.5 | 遮蔽对比度 |
| Detail | 低 | 性能优先 |
7.9 性能优化
3D渲染对GPU有一定要求,特别是阴影和后处理效果。以下是一些优化建议:
渲染优化清单
| 优化项 | 方法 | 效果 |
|---|---|---|
| 阴影贴图大小 | 1024x1024(默认2048) | 减少GPU内存使用 |
| 阴影模糊 | 降低Blur值 | 提升阴影渲染性能 |
| 后处理 | 关闭DOF和Vignette | 减少GPU负担 |
| 球体细分 | 降低SphereMesh的环数 | 减少多边形数量 |
| MSAA | 2x(默认4x) | 减少抗锯齿开销 |
移动端优化
如果目标平台包含手机,需要更激进的优化:
- 阴影贴图降到512x512
- 关闭SSAO
- 关闭Bloom
- 球体细分降到16环
- MSAA关闭或用FXAA代替
7.10 小结
在本章中,我们优化了桌球游戏的视觉呈现:
- 摄像机选择:透视模式+倾斜俯视角,平衡立体感和操作便利性
- 球体3D效果:高光、阴影、反射的正确设置
- 球桌材质:台呢、木框、球袋的材质配置
- 视角切换:默认/侧面/俯视三种视角
- 摄像机动画:击球震动、跟随母球
- 后处理效果:SSAO、Bloom等增强视觉
- 性能优化:针对不同平台的渲染优化
视觉效果是游戏"好不好看"的关键。一个有良好光影和材质的桌球游戏,能给玩家留下深刻的第一印象。
→ 8. 打磨与发布
