
1. 项目概述从“掷筛子”到物理引擎的实践窗口最近在整理自己的Unity学习项目时翻出了一个几年前做的“掷骰子”小游戏。别看它简单麻雀虽小五脏俱全它几乎涵盖了Unity初学者从入门到理解物理引擎核心机制所需的所有知识点。很多人学Unity一上来就想做复杂的RPG或射击游戏结果往往卡在物理交互、刚体控制这些基础环节上。而这个掷骰子项目恰恰是一个绝佳的“微缩实验室”它能让你在极短的时间内直观地感受到Unity物理引擎PhysX是如何工作的以及如何通过代码去“驯服”它而不是被它古怪的行为搞得焦头烂额。这个项目的核心目标就是模拟现实中掷出骰子后它在桌面上弹跳、旋转最终静止并显示点数的全过程。听起来简单但要让骰子的运动看起来“真实”且“随机”而不是像一块砖头一样呆板地落下里面涉及了刚体属性、扭矩、力、碰撞检测、随机数生成以及状态管理等多个环节。通过拆解这个项目的源码你不仅能得到一个可以运行的有趣小游戏更能深刻理解Rigidbody、Collider、Random以及协程Coroutine等Unity核心组件的实战用法。接下来我就把这个项目的设计思路、关键代码实现以及我踩过的那些坑毫无保留地分享给你。2. 核心设计思路如何让骰子“掷”得真实一个真实的掷骰子过程其物理本质是什么我们分解一下你用手给骰子一个初始的力包括线速度和角速度然后骰子在空中飞行受重力影响接着与桌面发生碰撞产生反弹和摩擦经过多次弹跳和旋转后能量耗尽最终静止在桌面的某个面上。要让Unity模拟这个过程我们需要解决几个关键问题。2.1 物理模拟的基石刚体与碰撞体在Unity中任何需要受物理规律影响的物体都必须挂载Rigidbody刚体组件。它负责计算物体的质量、阻力、重力影响以及响应外力。而Collider碰撞体组件则定义了物体的物理形状用于检测与其他碰撞体的接触。对于骰子模型我们通常使用一个立方体Cube或者导入一个六面体模型。这里有一个非常重要的细节碰撞体的形状最好与视觉模型完全匹配。如果你用一个简单的Box Collider盒型碰撞体去套一个复杂的骰子模型只要大致形状是立方体问题不大。但如果你为了追求视觉效果使用了带有圆角的骰子模型却依然使用Box Collider那么碰撞检测的边界就会和视觉边界有出入可能导致骰子“悬空”或“嵌入”桌面看起来非常不真实。我的建议是对于标准骰子直接使用Unity自带的Cube并为其添加Box Collider和Rigidbody这是最稳定、性能最好的方案。桌面的实现则更简单通常是一个扁平的Cube或一个Plane平面。为桌面添加一个Box Collider如果是Cube或Mesh Collider如果是Plane注意勾选“Convex”凸面体选项以支持动态碰撞并取消勾选其Rigidbody组件或者根本不加。这样桌面就是一个静态的碰撞体不会被骰子撞飞。2.2 力的施加模拟“掷出”的动作这是整个模拟的灵魂。我们不能简单地把骰子放到空中然后让它自由落体那样太假了。真实的“掷出”动作是同时赋予骰子一个向前或向上的初速度以及一个随机的旋转力扭矩。在Rigidbody组件上我们可以通过代码施加力AddForce和扭矩AddTorque。为了让每次投掷结果都不同我们需要使用随机数来生成这些力和扭矩的大小和方向。// 这是一个施加随机力和扭矩的示例方法 public void ThrowDice() { // 确保骰子处于动力学受控状态 diceRigidbody.isKinematic false; // 1. 施加一个向上的力模拟抛起动作 float upwardForce Random.Range(5f, 10f); diceRigidbody.AddForce(Vector3.up * upwardForce, ForceMode.Impulse); // 2. 施加一个向前的力模拟掷出动作 float forwardForce Random.Range(2f, 5f); // 可以稍微随机化向前方向使骰子运动轨迹更多变 Vector3 forwardDirection new Vector3( Random.Range(-0.2f, 0.2f), 0, 1 ).normalized; diceRigidbody.AddForce(forwardDirection * forwardForce, ForceMode.Impulse); // 3. 施加随机扭矩让骰子旋转起来 float torqueX Random.Range(-10f, 10f); float torqueY Random.Range(-10f, 10f); float torqueZ Random.Range(-10f, 10f); diceRigidbody.AddTorque(new Vector3(torqueX, torqueY, torqueZ), ForceMode.Impulse); }这里使用了ForceMode.Impulse冲量模式它表示瞬间施加一个力更符合“掷出”这个瞬间动作。Random.Range用于在给定范围内生成随机值确保每次投掷的力度和旋转轴都不同。注意施加力和扭矩的数值范围需要根据你的骰子质量Rigidbody.mass和场景重力Physics.gravity进行反复测试调整。数值太小骰子可能跳不起来数值太大骰子可能会像炮弹一样飞出去。这是一个“调参”的过程没有标准答案直到运动轨迹让你感觉“舒服”为止。2.3 状态判定骰子何时“停稳”并读取点数骰子在运动时我们无法判断哪个面朝上。必须等到它完全静止后才能根据其朝上的法线方向来判断点数。这里有两个核心问题第一如何判断骰子“停稳”了第二如何将骰子模型的空间朝向映射到1-6的点数对于第一个问题一个简单而有效的方法是监测骰子刚体的速度。当它的线速度velocity和角速度angularVelocity的幅度都接近于零并持续一小段时间我们就可以认为它静止了。IEnumerator WaitForDiceToStop() { // 等待一小段时间让物理引擎先计算几帧 yield return new WaitForSeconds(0.5f); float stopThreshold 0.01f; // 速度阈值 int stableFrames 0; // 稳定帧数计数 int requiredStableFrames 30; // 需要连续稳定的帧数 while (stableFrames requiredStableFrames) { if (diceRigidbody.velocity.magnitude stopThreshold diceRigidbody.angularVelocity.magnitude stopThreshold) { stableFrames; } else { stableFrames 0; // 一旦速度变大重置计数 } yield return null; // 等待下一帧 } // 此时骰子已稳定可以判定点数 DetermineDiceNumber(); }这里使用了协程Coroutine来方便地执行跨帧的检测逻辑。通过判断速度连续多帧低于阈值可以有效避免骰子在轻微晃动时被误判为静止。3. 核心代码实现与细节剖析有了清晰的设计思路我们就可以着手实现核心代码了。我将创建一个名为DiceController的脚本挂载到骰子物体上它负责控制骰子的整个生命周期初始化、投掷、检测静止、判定点数。3.1 骰子点数的映射与判定这是项目的逻辑核心。一个标准的六面骰子每个面都有一个从1到6的点数。在3D空间中我们需要知道骰子模型的哪个局部坐标轴方向代表“朝上”并对应哪个点数。通常在Unity中一个Cube立方体的六个面分别对应其本地坐标系的六个方向Y轴方向通常对应点数 1或6取决于建模习惯-Y轴方向通常对应点数 6或1X轴方向通常对应点数 5-X轴方向通常对应点数 2Z轴方向通常对应点数 3-Z轴方向通常对应点数 4但是这只是一个常见约定。最可靠的方法是在建模时就在每个面上做一个标记比如一个空物体或者在代码里通过面法线方向来判断。这里我们采用向量点积法计算骰子物体每个面的世界空间法线方向与“世界空间向上向量Vector3.up”的夹角。夹角最小的那个面就是朝上的面。public class DiceController : MonoBehaviour { private Rigidbody rb; private bool isThrown false; private bool isStable false; private int finalNumber 0; // 定义一个结构体来存储每个面朝上的方向和对应的点数 [System.Serializable] public struct DiceFace { public Vector3 localUpDirection; // 该面在骰子本地坐标系中的“向上”方向 public int number; // 该面对应的点数 } public DiceFace[] diceFaces; // 在Inspector面板中配置 void Start() { rb GetComponentRigidbody(); // 初始时让骰子处于运动学状态等待投掷指令 rb.isKinematic true; } // 外部调用的投掷方法 public void Throw() { if (isThrown) return; // 防止重复投掷 ResetDiceState(); isThrown true; // 随机放置骰子起始位置在桌面中心上方 transform.position new Vector3(0, 2f, 0); transform.rotation Random.rotation; // 随机初始旋转 // 切换到动力学状态并施加力 rb.isKinematic false; ApplyRandomForceAndTorque(); // 开始检测骰子是否停止 StartCoroutine(CheckStability()); } void ApplyRandomForceAndTorque() { // ... 如前文所述的随机力和扭矩代码 ... } IEnumerator CheckStability() { // ... 如前文所述的稳定性检测协程 ... // 当检测到稳定后调用 DetermineNumber() } void DetermineNumber() { int result 0; float maxDot -Mathf.Infinity; // 点积最大值初始化为负无穷 // 遍历所有预定义的面 foreach (DiceFace face in diceFaces) { // 将本地方向转换为世界方向 Vector3 worldDir transform.TransformDirection(face.localUpDirection); // 计算该方向与世界“向上”向量的点积夹角余弦值 float dot Vector3.Dot(worldDir, Vector3.up); // 点积越大说明该方向与“向上”方向夹角越小即越朝上 if (dot maxDot) { maxDot dot; result face.number; } } finalNumber result; isStable true; Debug.Log(骰子点数: finalNumber); // 这里可以触发UI更新、音效等 } void ResetDiceState() { isThrown false; isStable false; finalNumber 0; StopAllCoroutines(); } }在Unity Inspector面板中你需要为diceFaces数组配置6个元素。对于一个标准的Cube其本地向上方向就是各个轴向的正负单位向量。例如点数1: Local Up Direction (0, 1, 0)点数6: Local Up Direction (0, -1, 0)点数5: Local Up Direction (1, 0, 0)点数2: Local Up Direction (-1, 0, 0)点数3: Local Up Direction (0, 0, 1)点数4: Local Up Direction (0, 0, -1)实操心得点积判定法非常高效且准确但它依赖于一个前提骰子模型必须是规则的正交方向。如果你的骰子模型是异形的或者有倾斜的面这个方法可能会出错。对于复杂模型更稳妥的方法是在每个面的中心放置一个空子物体如Face1,Face2...判定点数时计算这些子物体的世界Y坐标最高的那个就是朝上的面。虽然多了一些设置步骤但容错率更高。3.2 物理材质与运动真实感为了让骰子的弹跳和滑动更真实我们必须配置物理材质Physic Material。物理材质定义了物体表面的摩擦力和弹性反弹系数。为骰子创建物理材质在Project视图中右键 - Create - Physic Material命名为“DiceMaterial”。Dynamic Friction动态摩擦力设置为0.4左右。这会影响骰子在桌面上滑动时的阻力。太低会滑得很远太高则会很快停下。Static Friction静态摩擦力设置为0.5左右。这决定了骰子从静止到开始滑动所需的力。Bounciness弹性设置为0.6左右。这模拟了骰子的“弹跳感”。值越高弹跳越厉害。设置为1是完美弹性碰撞不现实0则完全不反弹。Friction Combine / Bounce Combine摩擦/弹性组合模式通常选择“Average”平均或“Multiply”相乘。这决定了当两个物体骰子和桌面碰撞时它们的物理材质属性如何相互作用。对于初学者保持默认的“Average”即可。为桌面创建物理材质同样创建一个命名为“TableMaterial”。桌面的摩擦力可以设得高一些比如动态/静态摩擦力0.6弹性设得低一些比如0.2模拟一个略有弹性但主要还是提供摩擦的表面。应用材质将“DiceMaterial”拖拽到骰子物体的Box Collider组件的Material属性上。将“TableMaterial”拖拽到桌面碰撞体的对应属性上。注意事项物理材质的调参是一个“玄学”过程需要反复测试。一个常见的误区是只调骰子的材质忽略了桌面。实际上碰撞效果是两者共同作用的结果。如果你的骰子总是滑出桌面除了检查施加的力是否过大更要检查桌面的摩擦力是否足够。你可以创建一个简单的测试场景只调整一个参数观察骰子运动的变化逐步找到最符合你感觉的数值组合。4. 场景搭建与交互逻辑有了核心的骰子控制器我们还需要构建一个完整的可交互场景。4.1 基础场景搭建创建地面/桌面创建一个Cube缩放其Scale为10, 0.5, 10作为桌面。为其添加Box Collider和之前创建的“TableMaterial”物理材质。可以给它一个木质纹理增加视觉效果。创建骰子创建一个Cube重命名为“Dice”。为其添加Rigidbody组件和Box Collider组件应用“DiceMaterial”。将我们写好的DiceController脚本挂载上去。配置DiceController在Inspector面板中展开DiceController脚本找到diceFaces数组将大小设置为6并按照前文所述填入6个DiceFace结构体的数据Local Up Direction和Number。添加光源和相机创建一个Directional Light平行光照亮场景。调整主相机Main Camera的角度使其能俯视整个桌面。4.2 用户交互按钮控制与UI反馈一个完整的游戏需要用户界面。我们将创建一个简单的UI包含一个“投掷”按钮和一个显示点数的文本。创建UI在Hierarchy面板右键 - UI - Button会自动创建Canvas和EventSystem。将按钮文本改为“投掷”。再创建一个TextTMP文本放在按钮下方用于显示点数初始文本可以为“点数-”。编写UI管理器创建一个新的C#脚本UIManager。using UnityEngine; using UnityEngine.UI; using TMPro; // 如果你使用的是TextMeshPro public class UIManager : MonoBehaviour { public DiceController diceController; // 在Inspector中拖入骰子物体 public Button throwButton; public TMP_Text resultText; // 或使用 Text resultText; void Start() { // 绑定按钮点击事件 throwButton.onClick.AddListener(OnThrowButtonClicked); resultText.text 点数-; } void OnThrowButtonClicked() { // 禁用按钮防止连续点击 throwButton.interactable false; resultText.text 投掷中...; // 调用骰子的投掷方法 diceController.Throw(); // 我们需要一种方式在骰子停止后由DiceController通知UIManager更新UI。 // 这可以通过事件Event、委托Delegate或者简单的公共方法调用实现。 // 这里我们采用一个简单的协程来轮询检查。 StartCoroutine(WaitForDiceResult()); } IEnumerator WaitForDiceResult() { // 假设DiceController有一个公共的isStable属性和finalNumber属性 while (!diceController.IsStable) // 需要你在DiceController中暴露这个属性 { yield return null; // 每帧检查一次 } // 骰子已稳定更新UI resultText.text 点数 diceController.FinalNumber; // 同样需要暴露属性 throwButton.interactable true; // 重新启用按钮 } }修改DiceController以暴露状态在DiceController脚本中添加公共属性供UI访问。public bool IsStable { get { return isStable; } } public int FinalNumber { get { return finalNumber; } }关联引用将UIManager脚本挂载到Canvas或一个空物体上。在Inspector面板中将骰子物体拖到diceController字段将UI按钮和文本拖到对应的字段。现在运行游戏点击“投掷”按钮你应该能看到骰子被随机掷出在桌面上弹跳最终静止并显示点数。5. 性能优化与高级技巧当基础功能实现后我们可以考虑一些优化和增强体验的技巧。5.1 物理模拟性能优化物理计算是性能消耗大户。虽然一个骰子对性能影响微乎其微但养成良好的习惯很重要。合理设置碰撞检测类型Collision Detection在骰子的Rigidbody组件上有一个Collision Detection属性。对于快速运动的小物体如骰子默认的Discrete离散检测可能会在高速时发生“穿透”即两帧之间移动距离太大直接穿过了桌面。可以将其设置为Continuous连续或Continuous Dynamic连续动态。但这会显著增加CPU开销。对于掷骰子这种速度不算极快的场景Discrete通常足够如果发现穿透可以尝试Continuous。记住一个原则只有快速移动的小物体才需要连续检测静态物体或大物体保持默认即可。使用Sleep状态Unity物理引擎会自动让静止的刚体进入“睡眠”Sleep状态停止对其进行物理计算以节省资源。我们的CheckStability协程判断静止后骰子刚体通常会自动进入睡眠。确保不要频繁地通过代码去唤醒WakeUp一个静止的骰子。控制模拟频率在Project Settings - Time中Fixed Timestep决定了物理更新的频率默认0.02秒即50Hz。提高频率减小该值会让物理模拟更平滑但计算量更大。对于简单的掷骰子默认值完全足够。5.2 增强视觉与听觉反馈真实的体验离不开视听反馈。添加声音为骰子添加AudioSource组件。创建两个音频剪辑AudioClip一个用于骰子与桌面碰撞的“咔哒”声一个用于骰子最终停稳的“落定”声。在DiceController中通过OnCollisionEnter碰撞事件来播放碰撞音效。注意使用AudioSource.PlayOneShot()并控制播放频率避免一次碰撞播放多次声音。public AudioClip collisionSound; public AudioClip settleSound; private AudioSource audioSource; private float lastSoundTime; // 用于防止声音播放过于密集 void Start() { audioSource GetComponentAudioSource(); } void OnCollisionEnter(Collision collision) { // 只与桌面碰撞时播放且控制播放间隔 if (collision.gameObject.CompareTag(Table) Time.time - lastSoundTime 0.1f) { audioSource.PlayOneShot(collisionSound, 0.7f); lastSoundTime Time.time; } } // 在DetermineNumber()中播放停稳音效 void DetermineNumber() { // ... 判定点数逻辑 ... audioSource.PlayOneShot(settleSound); }记得给桌面物体打上“Table”标签Tag。添加粒子效果可以在骰子与桌面碰撞点生成微小的灰尘粒子增强冲击感。这需要用到ParticleSystem和OnCollisionEnter事件来获取碰撞点collision.contacts[0].point。5.3 实现“多次投掷”与“历史记录”一个完整的游戏可能允许玩家连续投掷多次并记录每次的结果。扩展UIManager添加一个历史记录面板如一个Scroll View下的Text列表和一个“重置”按钮。修改投掷逻辑每次投掷结束后将结果例如“第X次点数Y”添加到历史记录列表中。重置功能点击“重置”按钮清空历史记录并将骰子重置到初始位置和状态。注意重置时除了重置DiceController的内部状态还要将骰子的刚体设为运动学isKinematic true并设置其位置和旋转否则物理引擎可能会产生异常。6. 常见问题与调试技巧实录在开发这个项目的过程中我遇到了不少典型问题这里汇总一下希望能帮你避坑。6.1 骰子点数判定不准这是最常见的问题。现象是骰子明明某个面朝上但判定的却是另一个点数。原因一面方向映射错误。这是最可能的原因。仔细检查DiceController中diceFaces数组的配置。确保localUpDirection是该面朝上时骰子本地坐标系中指向“上”的方向。一个快速调试的方法是在DetermineNumber函数中将每个面计算出的dot值打印出来观察朝上的那个面是否拥有最大的点积值。void DetermineNumber() { // ... 遍历diceFaces ... foreach (DiceFace face in diceFaces) { Vector3 worldDir transform.TransformDirection(face.localUpDirection); float dot Vector3.Dot(worldDir, Vector3.up); Debug.Log($面 {face.number}: 方向 {face.localUpDirection}, 世界方向 {worldDir}, 点积 {dot}); } // ... 找出最大值 ... }原因二骰子未完全静止。如果判定点数时骰子还在轻微晃动其面法线方向与垂直方向夹角可能不是最小的。务必确保稳定性检测足够严格可以尝试降低速度阈值stopThreshold或增加需要稳定的连续帧数requiredStableFrames。原因三模型原点Pivot不在几何中心。如果骰子模型的轴心点不在其物理中心TransformDirection计算出的方向可能会有偏差。确保导入的模型轴心正确或者在Unity中将骰子物体作为一个空物体的子级通过调整空物体的位置来修正。6.2 骰子运动轨迹不自然或穿透桌面轨迹不自然如滑动像在冰上检查物理材质。确保骰子和桌面都应用了物理材质并调整摩擦系数。动态摩擦力Dynamic Friction影响滑动静态摩擦力Static Friction影响启动。可以尝试将桌面的摩擦力调高。发生穿透首先检查碰撞体是否启用且大小合适。其次对于快速运动的骰子尝试将Rigidbody的Collision Detection从Discrete改为Continuous。最后检查Fixed TimestepTime.fixedDeltaTime如果游戏帧率很低而物理更新频率也低也可能导致穿透可以尝试在Project Settings - Time中稍微减小Fixed Timestep的值如从0.02改为0.01但这会增加CPU负担。6.3 投掷后骰子偶尔会飞出台面或弹跳过高飞出台面检查施加的随机力forwardForce的范围是否过大。同时检查桌面的碰撞体是否足够大能否接住大多数轨迹的骰子。可以考虑在场景边缘添加看不见的“围栏”碰撞体。弹跳过高检查骰子和桌面的物理材质的Bounciness弹性值。通常骰子如塑料、树脂和桌面如木头、布料的弹性都不会太高建议都设置在0.6以下。过高的弹性会导致能量损失慢弹跳很久。6.4 性能问题或游戏卡顿检查物理更新开销在Profiler窗口Window - Analysis - Profiler中查看Physics.Process和Physics.Simulate所占用的CPU时间。如果只有一个骰子开销应该极低。如果卡顿可能是其他脚本如每帧执行的Update函数中的复杂计算导致的。避免在Update中频繁调用物理相关方法如AddForce。物理操作应在FixedUpdate中进行或者像我们一样在投掷时一次性施加冲量ForceMode.Impulse。这个“掷骰子”项目虽然体量小但它像一把钥匙帮你打开了Unity物理交互和状态管理的大门。理解了这个项目你再去看更复杂的物体交互、布娃娃系统、车辆物理等会发现底层逻辑是相通的。最重要的是动手去调、去试改变参数观察效果这才是学习游戏物理最有效的方式。