Unity笔记 - 实现弹球游戏原型

摘要

版本信息

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 第一次更新

  • 13
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值