摘要
版本信息
Unity 2022.3.8f1c1
涉及内容
- 总体:弹球游戏的实现与改进(基于Catlike教程)。
- 功能:控制开始结束、弹球、控制球拍移动、计分、TextMeshPro字幕。
- 视效:使用 URP 下的 Shader、Shader Graph 实现基本画面表现,用C#脚本向 Shader 传递参数。
- 视效:使用 Global Volume 实现 Bloom 等后处理,粒子系统、击球光效、结束慢动作、希区柯克变焦。
- 踩坑:SRP Batcher 下修改非暴露Shader属性的后果、FixedUpdate 与自定义时间增量的利弊。
- 零碎的笔记
成果预览
Unity弹球游戏原型效果演示
个人感想
Catlike教程比较循循善诱,跟着他的代码一步步迭代后,有点晕头转向。看完以后深感代码有点不成体系。写完感觉有很多地方能做的更好,就有限地选择了写一个duration系统,用于替换原有“随需要随新增”的计时字幕方法,并按自己想法加了一个结束慢动作+希区柯克。
写duration系统时,常感到自己没经验,觉得会有更好的解决办法。仅仅是一个弹球游戏,声明一堆布尔字段的做法就已经感觉有些捉襟见肘。接下来的学习暂定使用资产创建一个RPG玩法demo,包含:能管理角色动作、技能,怪物攻击、受击等行为的状态机,对话系统、任务系统。要有一个赏心悦目的框架。
唉,写到一半又感觉,自己怎么才学了这么一点东西,感觉都好基础,顿时很感挫败。加油吧!
更新日志
2024-02-21 第一次更新
2024-05-27 回顾这篇文章,明显有系统学习不足的感觉(数据结构,设计模式等)。目前在跟joker的课,同时在考研&琢磨blender插件…希望能尽快更新下一篇!
2024-05-28 文中的时间字幕系统应该采用更合适的数据结构——queue
2024-07-27 把后记移到第一章并改名为“更新日志”。也是为了表明还没忘记你,Unity!
2024-08-05 将文章从知乎转移至CSDN,因为CSDN支持Markdown预览。此后在此更新。
Unity C# 前置知识
属性和字段
C#完全面向对象。属性(Property)和字段(Field)代表了游戏对象的状态。有关C#基础请看刘铁猛老师的课程《C#语言入门详解》。
Unity C# 脚本继承自 MonoBehaviour 类,他的继承关系是 Object.Component.Behaviour.MonoBehaviour,这让我们的脚本可以成为组件挂载在 GameObject 上,参与Unity代码执行的主线程。具体见他人博客。
以球拍脚本Paddle.cs为例,了解类内各属性、字段的定义。
using TMPro;
using UnityEngine;
public class Paddle : MonoBehaviour
{
// SERIALIZE FIELD
//----------------------------------------------
[SerializeField] bool isAI = false;
[SerializeField, Min(0f)] float extents = 4f;
[SerializeField, Min(0f)] float AISpeed = 12f;
[SerializeField, ColorUsage(true, true)] Color goalColor = Color.white;
// FIELD
//----------------------------------------------
int score;
float velocity = 0f;
float acceleration = 0.5f;
// SHADER PROPERTIES
//----------------------------------------------
static readonly int timeOfLastHitId = Shader.PropertyToID("_TimeOfLastHit");
static readonly int emissionColorId = Shader.PropertyToID("_EmissionColor");
static readonly int faceColorId = Shader.PropertyToID("_FaceColor");
// GET COMPONENT
//----------------------------------------------
[SerializeField] TextMeshProUGUI scoreText;
[SerializeField] MeshRenderer goalRenderer;
Material paddleMaterial;
Material goalMaterial;
Material scoreMaterial;
后续各种方法……
}
属性、字段前声明的 [SerializeField] 是一种 Unity 定义的特性(Attribute)。特性可以放在脚本中的类、属性或函数上方来指示特殊行为。
本段代码中的 [SerializeField] 使私有字段序列化,从而可以暴露在 Inspector 面板中供开发者修改,同时保有字段的私有性;[Min(0f)] 使字段在 Inspector 中可设置的最小值为0;[ColorUsage(true, true)] 使面板上的颜色显示 Alpha 配置项和采用 HDR 标准。Attribute 可以在同一个方括号内连续编写(本代码方法),也可以为每个 Attribute 分配一个方括号。
更多 Attribute 可以浏览:《Unity手册—Attribute汇总说明 - 知乎 (zhihu.com)》。
生命周期函数
平常使用的 Start(),Update() 等都是MonoBehaviour类中的函数。
有关生命周期函数的总结:《一文读懂Unity常用生命周期函数 – 超级详细、不服来辩~_unity onmousedown时不想调用onmousedrag-CSDN博客》。
脚本向Shader传值
游戏中,希望根据球的撞击,对球的颜色、亮度做出改变,呈现一种撞击时爆发能量的感觉。这可以通过使用C#脚本设置Shader中的属性值达成。
Shader代码:
Shader "MiniGame/Ball"
{
Properties
{
[HDR] _Color("Emmision Color", Color) = (3, 3, 0.4)
}
……
}
C#代码:
static readonly int emissionColorId = Shader.PropertyToID("_EmissionColor");
Material goalMaterial;
……
goalMaterial.SetColor(emissionColorId, goalColor); // 写法1
goalMaterial.SetColor("_EmissionColor", goalColor); // 写法2
在上方代码中,使用 Shader.PropertyToID 获取_EmissionColor的 int 型 id,从而在C#脚本中,使用Material实例中的 SetColor 函数,更改对应属性的颜色。推荐写法1,因为写法1只需在编译时转换一次id给变量,而写法2在每次执行时需要比对字符串。
玩法实现
球的运动
根据速度,设置球的位置:
public void Move()
{
position += velocity * Time.deltaTime;
}
velocity 和 position 是 Vector2 类型的字段,这就代表了球对象的速度和位置属性。velocity 会根据球的碰撞、游戏时间改变大小和方向,最后通过上方代码改变位置。Move函数会直接或间接地在Update函数中被调用,实现每帧更新球的位置。
接下来,对碰撞作出反应,将球反弹:
public void SetXPositionAndSpeed(float start, float speedFactor, float deltaTime)
{
velocity.x = maxXSpeed * speedFactor;
position.x = start + velocity.x * deltaTime;
}
public void BounceX(float boundary)
{
float durationAfterBounce = (position.x - boundary) / velocity.x;
position.x = 2f * boundary - position.x; // this guaranties the ball are in the boundary during the bounce frame
velocity.x = -velocity.x;
EmitBounceParticles(boundary, position.y - velocity.y * durationAfterBounce, boundary < 0f ? 90f : 270f);
}
public void BounceY(float boundary)
{
float durationAfterBounce = (position.y - boundary) / velocity.y;
position.y = 2f * boundary - position.y;
velocity.y = -velocity.y;
bounceEmmisionTime = 0.5f;
EmitBounceParticles(position.x - velocity.x * durationAfterBounce, boundary, boundary < 0f ? 0f : 180f);
}
碰撞反弹函数,用于在碰撞时改变球的 velocity,暴露给球拍 Paddle 类调用。当 paddle 检测到击球或碰撞边界,就调用这些函数,改变球的运动。
Catlike教程还是比较严谨,考虑到了由于检测碰撞和改变速度需要一帧,球发生碰撞,但在该帧继续运动而卡进边界的问题。通过 position.y = 2f * boundary - position.y; 避免了该问题
弹球游戏中,为了鼓励玩家做出高难度操作,在球拍边缘击球可以获得更大横向速度,增加得分概率。这一功能通过 SetXPositionAndSpeed 函数实现,由 Paddle 类负责计算球与球拍边缘的距离,传入函数,设置横向速度。
由于恒定速度没什么可玩性,给球在游戏后期加点速:
void AddYSpeed()
{
velocity.y = Mathf.Sign(velocity.y) * startYSpeed * Mathf.SmoothStep(1, 4, (Time.time - startTime) * 0.04f);
}
直接用 SmoothStep 函数,实现速度沿着开始时间平滑增长,直到最大倍数。
球拍的控制
这里使用了最简单的 Input.GetKey 方法,通过改变 transform.localPosition 移动。我给球拍加了一个平滑加速过程,手感很一般。移动方面之后再研究。
TMPro 字幕
在 Hierarchy 中,新建 UI > Canvas,设置成 World Space 以便将文字作为场景内容显示。新建 UI > Text - Text Mesh Pro,设为Canvas的子级,调整 Canvas 和 Text 锚点与位置,就可以得到字幕了。
Canvas 父级下的 TextMeshPro
脚本里拿一下组件(Catlike教程里的类似乎错了,拿不到对应组件,应该是TextMesProUGUI),查一下Api,就可以设置字幕内容了:
[SerializeField] TextMeshProUGUI scoreText;
……
scoreText.SetText("{0}", newScore);
游戏进程与倒计时
游戏有多处地方需要显示字幕,每次都设置一个duration,并用 if-else 判断是否结束导致代码很不整洁。故声明一个字符串数组作为字幕栈,对应地声明一个持续时间栈,设置好入栈和出栈函数,就做成了一个线性字幕系统,同时可用于事件计时(游戏中没有并行事件)。列表和字典暂时不太熟,之后做更复杂的系统再熟悉。
【更新注:此处用队列合适。初次写此文章时还不熟悉数据结构】
float[] textDurations = {0f, 0f, 0f ,0f, 0f, 0f};
string[] textStacks = new string[6];
int textIndex = 0;
bool isTextSetted = false;
bool isAllTextOver = false;
void SetTextAndDuration(string text, float duration)
{
textStacks[textIndex] = text;
textDurations[textIndex] = duration;
textIndex++;
isAllTextOver = false;
isTextSetted = true;
}
void ShowText()
{
countdownText.gameObject.SetActive(true);
float duration = textDurations[textIndex];
countdownText.SetText(textStacks[textIndex]);
if (duration > 0f)
{
textDurations[textIndex] -= Time.deltaTime;
}
else
{
textIndex--;
}
if (textIndex < 0)
{
isAllTextOver = true;
textIndex = 0;
countdownText.gameObject.SetActive(false);
}
}
float GetCurrentDuration()
{
return textDurations[textIndex];
}
使用示例:
if (!isTextSetted)
{
SetTextAndDuration("GO", 0.5f);
SetTextAndDuration("1", 1f);
SetTextAndDuration("2", 1f);
SetTextAndDuration("GET READY", 1f);
}
else if (isAllTextOver)
{
StartNewGame();
isTextSetted = false;
isGetStart = false;
isPlay = true;
}
else
{
ShowText();
}
检测到键盘按下,玩家开始游戏时,设置字幕,进入显示字幕期间的分支。显示完毕后,进入显示完毕分支,实现游戏进程控制。
当检测到获胜等事件时,会进入相应的字幕设置分支、显示完毕分支。
视效实现
这个弹球游戏使用URP,十分方便。
URP Volume 后处理
URP 下内置的 Global Volume 可以完成很多画面后期工作。这里使用了 Bloom 作为 HDR 颜色的表现(这很重要,SDR 空间下 HDR 颜色的效果呈现十分依赖辉光,过曝区域加上高饱和辉光瞬间让人感觉这里很亮。否则,饱和度和亮度只能二选一(饱和度的本质就是不同颜色通道值差别的大小,饱和度高注定有通道值小,注定明度不高)。
关闭 Global Volume
开启 Global Volume
根据一丢丢的摄影审美,还设置了 Vignette(晕影)、Color Adjustments(调色)。
然而,暗部的色彩断层太明显了,这使我很不满。这个坑以后再研究。
关于 Global Volume 更多设置可以浏览官方URP文档,2022.3+版本 Unity 对应 URP 14.0。
球的变色
目标效果是碰撞后瞬间变亮、变红,再先快后慢变回原来色彩。实现是通过碰撞后计时,将时间映射成g通道的变化和光强度的变化,传递给球的Shader。
由于没写过多少Shader,还是很生疏。照着之前跟着步骤做的 URP 仿星穹铁道渲染,把球的超级简化版 Unlit Shader 拼了出来。能实现就行!不懂的系统学的时候再深究!知乎代码块吞缩进,直接截图吧。
小球发光Shader
小球发光Shader
为了使Shader适配 SRP Batcher,需要遵循官方文档中的提示。可在Shader的 Inspector 界面查看兼容性。
球的C#代码控制颜色部分:
void BounceLight()
{
if (bounceEmmisionTime > 0f)
{
bounceEmmisionTime -= Time.deltaTime;
ballColor.g = Mathf.SmoothStep(1f, -0.1f, bounceEmmisionTime);
ballLight.color = ballColor + Color.white;
float intensity = Mathf.SmoothStep(10f, 50f, bounceEmmisionTime);
ballLight.intensity = 0.75f * intensity;
material.SetColor("_Color", ballColor * (intensity * 0.3f + 0.5f));
}
}
触球发光特效
游戏内,球拍击球会亮白光,底线触球会亮红光 / 绿光。同样地,在C#脚本内检测碰撞然后传值给Shader,但这次使用 Shader Graph 实现,不考虑代码细节。
球拍和底线使用2个材质,共用1个Shader,通过C#脚本暴露在 Inspector 中的颜色属性,在游戏开始时设置发光颜色。触球时会更新Shader中的 _TimeOfLastHit 属性,和Shader内置的_Time属性做差并映射,得到亮度衰减效果。我认为这样做的好处是,不用在C#端每帧更新时间,发送给GPU。
触球发光 Shader Graph
然而,产生了异常:底部球拍击球,球拍和底线同时亮;顶部球拍击球,不亮;底部底线触球,不亮;顶部底线击球,亮。如下图:
底部球拍击球,球拍和底线同时亮的BUG
先说结论,这个问题源自 SRP Batcher 对不暴露Shader属性的不正确优化,具体逻辑不懂,下文仅找解决办法。
之前Catlike基础教程中,了解过 SRP Batcher,大体是对设置 Draw Call 进行效率优化。再具体一点,我的理解是通过设置大型CBUFFER和一个功能较多的Shader,使得使用同一个Shader变体的材质都能共享这个CBUFFER内的部分数据,这样就CPU只需要向GPU发送一次数据,让数据在没变化时留在GPU上,来减少CPU发送数据的时间。下图来自官方文档:
想到与此可能有关,遂关闭 SRP Batcher。结果灯光触发回归正常。
那么问题就出在 SRP Batcher 身上。查看官方文档,其中写道:为使Shader与 SRP Batcher 兼容,必须在一个名为 UnityPerMaterial
的 CBUFFER 中声明所有材质属性。兼容对象使用 SRP Batcher 代码路径,而其他对象则使用标准 SRP 代码路径。
注意,这里指所有在 Properties 声明的,暴露给用户的属性。可以推测,SRP Batcher 的策略是优化用户自定义材质属性向GPU的传递,我们作弊通过C#脚本改Shader中未暴露的属性是在SRPB意料之外的。
探索一下SRPB的兼容性,发现将 _TimeOfLastHit 在 Graph Inspector 中的的 Override Property Declaration 勾选,再将 Shader Declaration 设置为 Per Material,游戏在打开 SRP Batcher 时能恢复正常,同时这个 Shader Graph 显示不兼容 SRP Batcher:
Override Property Declaration 勾选,Shader Declaration 设置为 Per Material,不兼容 SRP Batcher
查看生成的Shader代码,上图情况,没在 Properties 代码块中声明的 _TimeOfLastHit 被放进了CBuffer中,不兼容SRPB,数据没有被SRPB优化处理,游戏正常。
关闭 Override Property Declaration,没在 Properties 代码块中声明的 _TimeOfLastHit 被挪出了 CBuffer,所以兼容SRPB,数据被SRPB优化处理,游戏不正常。
最后,打开 Exposed,让 _TimeOfLastHit 被放进 Properties 代码块。关闭 Override Property Declaration 或打开但将 Shader Declaration 设置为 Per Material,兼容SRPB,数据被SRPB优化处理,但被正确优化,游戏正常。
至于当 _TimeOfLastHit 不暴露时,球拍和底线为什么会一起亮,以及顶部球拍为什么没出现这个现象,就要分析SRPB更深层的逻辑了,能力有限,留给以后研究。
粒子特效
使用Unity自带的 Particle System 和默认的方形粒子。在 Hierarchy 视图中创建,有很方便强大的调整选项。在 Inspector 中调整形状,颜色,大小,持续时间,生命周期,范围,Renderer等等选项即可。
代码设置中,拿到粒子系统组件,调用相应方法即可配置:
[SerializeField] ParticleSystem bounceParticleSystem; // 拿游戏组件
[SerializeField] int bounceParticleEmission = 20; // 亮度
void EmitBounceParticles(float x, float z, float rotation)
{
ParticleSystem.ShapeModule shape = bounceParticleSystem.shape;
shape.position = new Vector3(x, 0f, z);
shape.rotation = new Vector3(0f, rotation, 0f); // 粒子范围形状设置
bounceParticleSystem.Emit(bounceParticleEmission);
}
球的拖尾粒子使用了 Particle System 组件里的 Renderer 模块,给它配置一个 Shader Graph:
拖尾粒子的 Shader Graph
直接给颜色乘以一个3的 Intensity,让它变成 HDR 颜色。
这里的 Vertex Color 来自 Color over LifeTime 模块的设置:
Particle System 组件中的 Color over LifeTime 模块
字幕消隐与变亮
游戏中,0分时没字幕,1到3分字幕依次变亮。先拿到字幕对象和材质:
[SerializeField] TextMeshProUGUI scoreText;
……
scoreMaterial = scoreText.fontMaterial; // Awake内拿
这里由于背景是黑的,直接把颜色设置成黑色即可,反正是Unlit。严谨一些,可以禁用字幕:
scoreText.gameObject.SetActive(false);
需要字幕变亮时:
scoreMaterial.SetColor(faceColorId, goalColor * (0.2f + newScore / pointsToWin));
设置字幕初始颜色时,不要设置顶点色,要设置Material里的Face颜色。
摄像机震动
这个游戏摄像机震动是基于弹球速度,和相机位置改变加速度的。相比直接K摄像机动作,它和弹球等对象耦合度更高,调整起来也更麻烦。
由于 Update() 的调用顺序是随机的,为了确保摄像机在弹球碰撞瞬间震动,要在 LateUpdate() 里写震动代码。
帧率下跌可能导致摄像机在一个长帧里跑太远。把帧率调成8试试看:
Application.targetFrameRate = 8;
结果相机由于每次运动时间太长,跑得太远,很难被回弹到原点,开始无限过度回弹。注意,这个设置是持久的,需要设置回0来解除锁帧。
对付物理逻辑,一个方案是使用 FixedUpdate(),即使帧率跌到很低,也能确保每0.02秒能更新一次相机运动。然而,当高帧率时,可能显示器播放了好几帧,才执行一次 FixedUpdate。帧率波动会使相机有时看起来卡卡的,原理类似25Hz视频在60Hz屏幕上播放一样,显示器是没有1.5帧这一说法的,有时视频一帧对应显示器2帧,有时对应显示器3帧,不均匀的帧分配就会使人眼看起来卡顿。
于是采取自定时间增量的策略,强制相机每帧至少运动一次,当这一帧比较长,则按固定时间间隔重复执行相机运动。这既避免了低帧率相机乱弹,也避免了高帧率两次相机运动间帧分配不均造成的视觉卡顿。同时,由于 Time.timeScale 会同时影响 Time.deltaTime 和 FixedUpdate,采用自定义时间增量可以让慢动作时摄像机依然丝滑运动,而不会因 FixedUpdate 执行间隔变长导致摄像机卡顿。代码如下:
摄像机震动代码
结束慢动作
检测到游戏结束,进入以下代码块:
ShowText();
MovePaddle();
float currentDuration = GetCurrentDuration();
livelyCamera.ExcuteHitchCock(0.27f, currentDuration);
float t = Mathf.Max(0, 0.3f - currentDuration);
Time.timeScale = 11.1111f * t * t;
计算一下,将结束后的一小段线性时间映射成曲线,用于设置 Time.timeScale,实现时间曲线变速。有关 Time.timeScale 对各个时间参数、生命周期函数的影响,可以看《Unity——Time.timeScale详解_unity timescale-CSDN博客》。
希区柯克变焦
就是简单的相机拉近,FOV变大。搜索一下相机FOV有关的api即可实现:
public void ExcuteHitchCock(float totalDuration, float currentDuration)
{
float adder = (totalDuration / 2f - currentDuration) / totalDuration;
adder = 1.5f * Mathf.Max(0, 0.25f - adder * adder);
GetComponent<Camera>().fieldOfView = originalFOV * (1f + adder);
Vector3 p = Vector3.zero;
p.z = 18f * adder;
transform.localPosition = p;
}
小tips:1、给相机设置一个父级物体,可以方便相机沿着自身指向前进或后退。2、默认FOV变换轴是Vertical,换成水平轴需要在变换FOV时运用变换矩阵搞一搞(上网查的时候顺带看到这个点)。
零碎的笔记
OnEnable函数:组件启用时,即Awake后和热重载(Play模式下修改参数)后会触发。OnDisable函数:组件禁用时,即热重载前会触发。
URP与BRP多光源表现:在增加1个光源时,BRP绘制Pass的DrawCall会倍增,因为BRP每个光源采用单独Pass计算。而Dynamic Batching只对Depth和Shadow Pass起作用。URP则因为多光源在同一个Pass中计算,增加的只是光照的计算量,DrawCall和单光源时一样。然而,在更多灯光对不同范围的影响下,**批处理可能会被不同光照而分割,**导致事情变得复杂。例如,在一群批处理的网格中,一个点光源只照亮了部分网格,本应合为一批的网格会变得无法批处理。
Unity有两种结构体:0f-1f计数的Color,和0-255计数的Color32,它们存在隐式转换关系。Inspector中的颜色调节是Color32类型。具体可看:[[Unity 3d] 从源码看 Color 的任性赋值 - 简书 (jianshu.com)]([Unity 3d] 从源码看 Color 的任性赋值)
出现粒子系统的编译错误,虽然可以运行,但运行结果不可预测,原本好的功能很可能会出bug。
AI Navigation Bake高度不对:检查期望的导航烘焙平面,是否被设置为静态。
后记
2024-02-21 第一次更新