1. 项目概述为什么要在游戏里“玩”光线做游戏开发这么多年我始终觉得能让一个虚拟世界“活”起来的除了精妙的玩法和动人的故事就是那些看似不起眼却无处不在的物理细节。其中光线的行为——尤其是反射和折射——绝对是营造沉浸感的核心魔法。想想看一个光滑大理石地板上倒映出的角色身影一池清澈见底的湖水对岸边景物的扭曲成像或者是一把传奇宝剑上流转的寒光这些瞬间之所以能打动玩家背后都是光线与表面交互的精确计算。这个项目就是一次从理论到实践的深度“造光”之旅。我们将彻底抛开引擎内置的、黑盒般的光照系统亲手用代码去模拟光线遇到障碍物时最基础的两种反应像镜子一样弹开的“反射”和像进入水中一样发生偏折的“折射”。听起来很高深别怕我们不用去啃那些厚重的图形学教材。我的目标很明确用最直白的语言带你一步步推导出核心的向量公式然后在Unity里用C#我们会触及一些C的底层思想把它实现出来让你亲眼看到一束代码生成的“光”是如何在场景中跳跃、弯曲最终塑造出令人信服的视觉奇观。无论你是刚接触Shader编程感到一头雾水的新手还是已经会用Unity做游戏但想深入了解图形原理的进阶开发者这次实战都能让你获得“知其然更知其所以然”的扎实收获。我们不止步于调用Vector3.Reflect这个API而是要弄明白它为什么这样工作我们也不满足于在材质面板调一个折射率滑块而是要亲手写出斯涅尔定律的代码实现。当你完成这个项目你再看到游戏里的任何反光或透明物体眼里看到的将不再是简单的贴图或特效而是一行行清晰、优雅的数学逻辑。2. 核心原理拆解向量图形世界的通用语言在动手写代码之前我们必须把地基打牢。图形学里的一切从物体的位置到光线的方向本质上都是向量。所以理解反射和折射其实就是学会用向量的语言来描述光线的“拐弯”。2.1 反射一个完美的对称游戏想象一下你对着镜子扔一个乒乓球。球飞向镜面的路线和它弹开后的路线关于镜面的法线就是垂直于镜面的那条线是完全对称的。这就是反射定律入射角等于反射角。在向量世界里我们怎么描述这个“对称”呢假设我们有一束光它的方向向量是I(Incident)它撞上了一个表面这个表面的单位法线向量是N(Normal已归一化长度为1)。我们要求出反射后的方向向量R(Reflect)。推导过程如下这是理解后续一切的关键分解入射向量我们可以把入射向量I分解为两个部分一个垂直于法线N的分量和一个平行于法线N的分量。平行于法线的分量其实就是I在N方向上的投影。根据向量投影公式这个分量是(I · N) * N。这里(I · N)是点积结果是一个标量可能为负表示方向相反。垂直分量不变平行分量取反在反射中垂直于法线的那个分量I- 平行分量方向完全不变因为它沿着表面走。而平行于法线的那个分量在碰撞后方向完全反转。所以反射向量R (垂直于法线的分量) - (平行于法线的分量)。代入公式R [I-(I · N) * N] - [(I · N) * N]。得到最终公式化简一下就得到了那个经典且优美的反射公式R I - 2 * (I · N) * N这个公式的美妙之处在于它纯粹用向量运算就表达了物理定律并且计算效率极高。在Unity中Vector3.Reflect函数内部做的就是完全一样的计算。但我们自己推导一遍意义重大当你的反射效果出现奇怪的黑斑或方向错误时你就能精准地判断是法线N没有归一化还是点积(I · N)的计算出了问题。2.2 折射当光线“踩刹车”时发生的弯曲折射现象更生活化筷子插进水里看起来变弯了就是因为光从空气进入水时速度变了导致方向发生偏折。描述这个偏折的规律叫斯涅尔定律Snell‘s Law。斯涅尔定律的标量形式是n₁ * sinθ₁ n₂ * sinθ₂。其中n₁,n₂是两种介质的折射率空气约1.0水约1.33玻璃约1.5θ₁,θ₂分别是入射角和折射角。但我们的世界是三维的光有方向。我们需要它的向量形式。推导比反射复杂一些但核心思想依然是向量的分解与重组。建立坐标系同样我们有入射方向I和表面法线N。折射发生在由I和N所确定的平面内。引入折射率比令η n₁ / n₂。注意顺序当光从介质1射向介质2时如果n₁ n₂如空气到水η 1光线会靠向法线反之则远离。分解与计算将入射向量I分解为垂直于法线的分量I_perp和平行于法线的分量I_para。I_para (I · N) * NI_perp I - I_para应用斯涅尔定律根据斯涅尔定律和几何关系可以推导出折射向量T(Transmit) 的两个分量平行分量方向反转与反射时取反逻辑类似但符号由点积决定T_para -sqrt(1 - η² * (1 - (I·N)²)) * N垂直分量按比例缩放T_perp η * I_perp得到最终公式合并分量得到向量形式的折射公式T η * I (η * c₁ - sqrt(1 - η² * (1 - c₁²))) * N其中c₁ - (I · N)这里取负是为了使公式更统一表示从外部射入。这里有一个至关重要的陷阱根号下的值1 - η² * (1 - c₁²)必须大于等于0。如果它小于0意味着什么意味着根据斯涅尔定律sinθ₂ 1这在数学上不可能。物理上这对应着“全反射”现象——当光从光密介质射向光疏介质比如从水到空气且入射角大于临界角时光将全部反射没有折射。我们的代码必须处理这种情况注意自己手推这个公式可能有点烧脑但理解其每一步的物理意义比死记硬背更重要。在实操中我们可以先按照这个逻辑去实现Unity也有Vector3.Refract函数但知其所以然才能应对自定义的、更复杂的折射模型比如各向异性材质。3. 开发环境与核心工具链搭建理论准备好了我们得找个地方把代码跑起来。Unity是我们的主战场但为了追求极致的理解和控制力我们会采用一种“混合”开发模式。3.1 Unity项目初始化与设置要点打开Unity Hub创建一个新的3D核心项目Core模式避免URP/HDRP的预设干扰。给项目起个名字比如“RayOpticsLab”。创建好后第一件事是调整项目设置为我们的光线可视化做好准备线性颜色空间在Edit - Project Settings - Player - Other Settings中将Color Space从默认的Gamma改为Linear。线性空间下光照计算包括我们手写的光线强度衰减才是物理正确的颜色混合更准确。创建测试场景在场景中创建一个平面Plane作为地面一个球体Sphere和一个立方体Cube作为测试物体。将球体的材质调成光滑的、高光明显的比如Standard材质调高Smoothness立方体可以赋予一个透明的材质后面测试折射用。准备调试工具我们将大量使用Debug.DrawRay来绘制光线路径。确保你在Scene视图的Gizmos下拉菜单中勾选了显示Gizmos。3.2 C#脚本架构设计模拟与渲染分离我们不直接在物体的Shader里写复杂的光线追踪那样效率低且不灵活。我们将采用“CPU端模拟GPU端呈现”的思路用C#脚本在Update或FixedUpdate中计算光线路径然后用Debug.DrawRay或LineRenderer画出来。创建两个核心C#脚本RayEmitter.cs光线发射器。把它挂载在场景中的一个空物体上比如创建一个叫“LightSource”的空物体。它的职责是定义光线的起点通常是自身位置。定义光线的初始方向可以是一个public的Vector3或在Update里用鼠标/键盘控制。在每一帧发起一次“光线投射”模拟。OpticalSurface.cs光学表面组件。把它挂载到任何需要参与光线交互的物体上如地面、球体、立方体。它的职责是标识这个表面的光学类型反射、折射、或两者皆有。提供表面法线对于复杂网格可能需要动态计算碰撞点的法线。存储材质属性如反射率、折射率。这种分离设计符合组件化思想非常灵活。你可以轻松地给任何物体添加光学属性或者创建不同类型的光源。3.3 关键数学工具类实现公式的“武器库”虽然Unity的Vector3结构体已经提供了Reflect和Refract方法但正如我们之前说的这个项目的目的是理解本质。因此我强烈建议你创建一个静态工具类亲手实现这两个函数。创建一个名为RayOpticsMath.cs的脚本里面不继承MonoBehaviour只包含静态方法。using UnityEngine; public static class RayOpticsMath { /// summary /// 手动实现反射向量计算 /// /summary /// param nameinDirection入射方向归一化/param /// param nameinNormal表面法线归一化/param /// returns反射方向向量/returns public static Vector3 MyReflect(Vector3 inDirection, Vector3 inNormal) { // 核心公式R I - 2 * (I·N) * N float dot Vector3.Dot(inDirection, inNormal); return inDirection - 2 * dot * inNormal; } /// summary /// 手动实现折射向量计算 /// /summary /// param nameinDirection入射方向归一化/param /// param nameinNormal表面法线归一化/param /// param nameeta折射率比值 (n_incident / n_transmitted)/param /// returns折射方向向量如果发生全反射则返回零向量/returns public static Vector3 MyRefract(Vector3 inDirection, Vector3 inNormal, float eta) { // 计算点积并处理方向c1 - (I · N) float cosTheta1 -Vector3.Dot(inDirection, inNormal); // 计算根号内的值 float k 1.0f - eta * eta * (1.0f - cosTheta1 * cosTheta1); // 检查是否发生全反射 (k 0) if (k 0.0f) { // 返回零向量表示全反射应由外部逻辑处理例如转为反射 return Vector3.zero; } // 应用折射公式 float cosTheta2 Mathf.Sqrt(k); Vector3 refractedDir eta * inDirection (eta * cosTheta1 - cosTheta2) * inNormal; return refractedDir.normalized; // 确保返回归一化向量 } }这个工具类是我们的核心“武器”。MyReflect方法是对我们推导公式的直接翻译。MyRefract方法则完整实现了向量公式并加入了全反射的判断返回Vector3.zero。在真正的光线追踪中当检测到全反射时我们应该转而计算反射光这将在主逻辑中体现。4. 手把手实现从单次反射到递归光线追踪有了数学工具和组件架构现在开始组装我们第一个能跑起来的光线模拟系统。4.1 第一步实现单次光线投射与反射我们先让一束光打到一个平面上并反射一次。在RayEmitter.cs中我们编写核心的Update逻辑using UnityEngine; public class RayEmitter : MonoBehaviour { public Vector3 rayDirection Vector3.forward; // 初始光线方向 public float rayDistance 50f; // 光线最大探测距离 public int maxBounces 3; // 最大反射/折射次数 void Update() { SimulateSingleRay(transform.position, rayDirection, maxBounces, Color.red); } void SimulateSingleRay(Vector3 origin, Vector3 direction, int remainingBounces, Color rayColor) { if (remainingBounces 0) return; Ray ray new Ray(origin, direction); RaycastHit hit; // 进行物理射线检测 if (Physics.Raycast(ray, out hit, rayDistance)) { // 1. 绘制入射光线从起点到碰撞点 Debug.DrawLine(origin, hit.point, rayColor, Time.deltaTime); // 2. 获取碰撞点的表面法线 Vector3 surfaceNormal hit.normal; // 3. 获取物体表面的光学属性 OpticalSurface opticalSurface hit.collider.GetComponentOpticalSurface(); if (opticalSurface ! null opticalSurface.surfaceType OpticalSurface.SurfaceType.Reflective) { // 4. 使用我们的工具函数计算反射方向 Vector3 reflectDir RayOpticsMath.MyReflect(direction.normalized, surfaceNormal.normalized); // 5. 递归调用模拟下一次反射颜色可以稍微变暗模拟能量衰减 Color nextColor rayColor * 0.8f; SimulateSingleRay(hit.point reflectDir * 0.01f, // 从碰撞点稍远处开始避免自交 reflectDir, remainingBounces - 1, nextColor); } else { // 如果不是光学表面光线被吸收可以绘制一个终点标记 Debug.DrawRay(hit.point, Vector3.up * 0.5f, Color.gray, Time.deltaTime); } } else { // 如果没有击中任何物体绘制光线到最大距离 Debug.DrawRay(origin, direction * rayDistance, rayColor * 0.5f, Time.deltaTime); } } }同时创建OpticalSurface.csusing UnityEngine; public class OpticalSurface : MonoBehaviour { public enum SurfaceType { Reflective, Refractive, Both } public SurfaceType surfaceType SurfaceType.Reflective; // 如果是折射表面需要定义折射率 public float refractiveIndex 1.5f; // 默认类似玻璃 // 可选反射率/透射率系数用于衰减 public float reflectivity 0.8f; public float transmittance 0.9f; }操作与验证将RayEmitter脚本挂到场景中“LightSource”物体上。给作为镜面的物体比如一个倾斜的Quad挂上OpticalSurface组件并设置surfaceType为Reflective。运行游戏。你应该能看到一束红色的线从光源发出击中镜面后根据反射定律弹向另一个方向。调整镜面的角度观察反射光线的变化是否符合你的直觉。实操心得这里有一个新手极易踩坑的地方法线方向。RaycastHit.normal返回的是碰撞点所在三角形面的法线。对于像Sphere这样的凸面体这个法线是从球心指向碰撞点的方向是对的。但对于像Plane或从外部导入的复杂模型你需要确保模型的法线方向是朝外的即面向光线来源的方向。如果发现反射光线方向诡异比如射进了物体内部首先检查法线。可以在Debug.DrawRay(hit.point, hit.normal, Color.blue)来可视化法线。4.2 第二步加入折射与全反射处理现在让我们赋予光线“穿透”物体的能力。修改SimulateSingleRay函数加入对折射表面的处理逻辑。我们需要修改递归函数使其能根据表面类型决定光线的下一步行为void SimulateSingleRay(Vector3 origin, Vector3 direction, int remainingBounces, Color rayColor, float currentIOR 1.0f) { // currentIOR 表示光线当前所在介质的折射率默认为空气1.0 if (remainingBounces 0 || rayColor.maxColorComponent 0.05f) return; // 增加能量衰减终止条件 Ray ray new Ray(origin, direction); RaycastHit hit; if (Physics.Raycast(ray, out hit, rayDistance)) { Debug.DrawLine(origin, hit.point, rayColor, Time.deltaTime); Vector3 surfaceNormal hit.normal; OpticalSurface opticalSurface hit.collider.GetComponentOpticalSurface(); if (opticalSurface ! null) { // 计算下一步光线的起点避免自交 Vector3 nextOrigin hit.point direction * 0.001f; switch (opticalSurface.surfaceType) { case OpticalSurface.SurfaceType.Reflective: HandleReflection(hit, direction, surfaceNormal, remainingBounces, rayColor, nextOrigin); break; case OpticalSurface.SurfaceType.Refractive: HandleRefraction(hit, direction, surfaceNormal, remainingBounces, rayColor, nextOrigin, currentIOR, opticalSurface.refractiveIndex); break; case OpticalSurface.SurfaceType.Both: // 更真实的模拟部分反射部分折射。这里简化处理按概率或固定比例选择一条路径。 // 为了演示我们随机选择一条路径 if (Random.value 0.5f) HandleReflection(hit, direction, surfaceNormal, remainingBounces, rayColor * opticalSurface.reflectivity, nextOrigin); else HandleRefraction(hit, direction, surfaceNormal, remainingBounces, rayColor * opticalSurface.transmittance, nextOrigin, currentIOR, opticalSurface.refractiveIndex); break; } } else { // 非光学表面吸收 Debug.DrawRay(hit.point, Vector3.up * 0.3f, Color.gray, Time.deltaTime); } } else { Debug.DrawRay(origin, direction * rayDistance, rayColor * 0.3f, Time.deltaTime); } } void HandleReflection(RaycastHit hit, Vector3 inDir, Vector3 normal, int bounces, Color color, Vector3 origin) { Vector3 reflectDir RayOpticsMath.MyReflect(inDir.normalized, normal.normalized); SimulateSingleRay(origin, reflectDir, bounces - 1, color * 0.7f); // 反射有能量损失 } void HandleRefraction(RaycastHit hit, Vector3 inDir, Vector3 normal, int bounces, Color color, Vector3 origin, float iorFrom, float iorTo) { // 关键需要判断光线是从外部进入物体还是从物体内部射出 // 通过点积判断光线方向与法线方向如果点积0说明光线大致从外部射向表面法线朝外 bool isEntering Vector3.Dot(inDir, normal) 0; float eta isEntering ? iorFrom / iorTo : iorTo / iorFrom; // 计算正确的折射率比值 // 如果光线从内部射出法线方向需要取反因为我们的公式默认法线指向外部 Vector3 correctNormal isEntering ? normal : -normal; Vector3 refractDir RayOpticsMath.MyRefract(inDir.normalized, correctNormal.normalized, eta); if (refractDir Vector3.zero) { // 发生全反射转为反射逻辑 HandleReflection(hit, inDir, correctNormal, bounces, color, origin); } else { // 更新光线当前所在的折射率 float nextIOR isEntering ? iorTo : iorFrom; SimulateSingleRay(origin, refractDir, bounces - 1, color * 0.9f, nextIOR); // 折射能量损失较小 } }操作与验证创建一个透明立方体或球体挂载OpticalSurface设置surfaceType为RefractiverefractiveIndex设为1.5玻璃。调整光源位置让光线斜着射入这个物体。运行游戏。你应该能看到光线在进入和离开物体时发生两次偏折。尝试增大入射角当光线从玻璃内部射向空气时超过临界角后你会看到折射光线消失转而出现全反射现象。避坑指南折射率比值的顺序是最大的坑。eta n_incident / n_transmitted。当光线从空气n1.0进入玻璃n1.5时eta 1.0/1.5 ≈ 0.667。当光线从玻璃内部返回空气时eta 1.5/1.0 1.5。顺序搞反折射方向会完全错误。我们的代码通过isEntering布尔值来动态计算eta并相应调整法线方向这是处理任意进出情况的标准做法。4.3 第三步实现多物体交互与递归追踪我们的系统现在已经可以处理单次反射和折射了。但真实世界的光线会在多个物体间弹跳。我们的递归函数SimulateSingleRay已经为此设计好了。maxBounces参数控制光线最多反弹多少次。创建一个复杂的测试场景放置两个相对的反射平面比如两个倾斜的Quad组成一个镜面走廊。在走廊中间放一个折射球体玻璃球。将光源RayEmitter的maxBounces设置为5或更高。运行后你会看到一束光在两面镜子间来回反射每次经过玻璃球时发生折射形成非常漂亮的光路。这就是递归光线追踪的雏形虽然我们的模拟非常简陋没有考虑光强衰减的物理模型、没有抗锯齿、没有颜色过滤但它清晰地揭示了光线追踪的核心原理从相机或光源出发沿着视线或光路逆向/正向追踪通过递归计算与场景中物体的交互最终决定屏幕上每个像素的颜色。5. 性能优化与视觉增强实战基础的模拟跑通了但画面可能只是简单的线条。如何让它更高效、更美观甚至应用到实际游戏效果中这里有几个进阶方向。5.1 从Debug绘制到Mesh渲染Debug.DrawRay只在Scene视图和开发时可见且性能一般。为了在Game视图看到持久、美观的光线我们需要使用LineRenderer或自定义Mesh。使用LineRenderer在RayEmitter物体上添加一个LineRenderer组件。修改SimulateSingleRay函数不再使用Debug.DrawLine而是将每一步的光线路径点起点、每个碰撞点、终点存储在一个ListVector3中。在单次模拟结束后将点列表赋值给LineRenderer.positionCount和LineRenderer.SetPositions。可以设置LineRenderer的材质、宽度、颜色渐变让光线看起来更酷炫比如起点亮、终点暗模拟衰减。使用自定义Mesh更灵活适合复杂光束动态生成一个细长的四边形QuadMesh沿着光线路径拉伸。通过Shader来控制其颜色和透明度可以实现光束的辉光、体积光等高级效果。这种方法性能开销更大但视觉效果上限也更高常用于激光武器、探照灯等效果。5.2 性能考量限制与加速递归光线追踪是计算密集型的。我们的简单模拟在复杂场景或多次反弹下也会卡顿。限制反弹次数maxBounces是最直接的控制。通常3-5次反弹已经能产生不错的效果更多次数的贡献度递减。基于距离或能量的衰减我们在代码中简单地将rayColor乘以一个系数来模拟能量损失。更物理的做法是计算光线传播的距离衰减或者根据材质的反射率/透射率系数进行衰减。当光线颜色强度低于某个阈值时提前终止递归。空间加速结构Physics.Raycast本身已经使用了Unity物理引擎的碰撞体层次结构效率尚可。但对于自己实现的纯软件光线追踪就需要用到BVH包围层次盒或KD-Tree等空间加速结构来快速排除大量不可能相交的物体这是专业渲染器的标配。5.3 融入实际游戏效果屏幕空间反射SSR思路启发我们实现的是一种“前向”的光线追踪从光源出发。在现代游戏里更流行的是“后向”或“混合”技术比如屏幕空间反射Screen Space Reflection, SSR。它的原理和我们的实验一脉相承但实现思路巧妙得多起点不同SSR不是从光源而是从摄像机出发针对屏幕上已经渲染好的像素即G-Buffer中的位置、法线信息。步进查询它沿着该像素的反射方向在屏幕深度缓冲区Depth Buffer中一步步“前进”Ray Marching寻找与场景深度相交的点。采样颜色一旦找到交点就去采样交点在屏幕空间对应的颜色作为反射内容。SSR的优势是速度快只在屏幕空间操作能很好地捕捉动态场景的反射。缺点是只能反射屏幕内已有的内容对于屏幕外的物体就无能为力了比如低头看水池反射的天空可能缺失。理解了我们手写的反射原理再看SSR你就会明白它本质上是在做同样的事情只是把追踪的战场从3D场景搬到了2D的屏幕缓冲区内并采用步进逼近而非精确求交来换取性能。这是一个将复杂物理模拟“降维”适配实时渲染的经典案例。6. 常见问题排查与深度调试技巧自己动手实现一定会遇到各种奇怪的问题。这里把我踩过的坑和调试方法总结给你。6.1 光线方向诡异或消失症状反射/折射光线方向完全不对或者突然消失。排查步骤可视化法线在碰撞点用Debug.DrawRay(hit.point, hit.normal, Color.blue, 0.1f)画出法线。确保法线方向是朝外的指向光线来源的大致方向。对于单面模型背面碰撞可能无法获取正确法线。检查向量归一化确保传入MyReflect和MyRefract的inDirection和inNormal都是单位向量.normalized。未归一化的向量会导致点积结果错误进而方向错误。检查折射率比值eta打印出eta的值。确认isEntering逻辑是否正确。记住公式eta 当前介质折射率 / 目标介质折射率。检查全反射条件在MyRefract函数中当k 0时我们返回了Vector3.zero。在主逻辑中是否妥善处理了这种情况转为反射如果没处理光线就会中断。6.2 光线在表面“抖动”或闪烁症状光线在物体边缘或当角度很小时路径不稳定每帧都在轻微变化。原因与解决自交Self-Intersection这是光线追踪的经典问题。当反射/折射光线的新起点nextOrigin设置得离碰撞点太近时由于浮点数精度误差下一次Raycast可能会立刻击中同一个物体的同一表面误判为新的碰撞。我们的代码中使用了hit.point direction * 0.001f来偏移起点这个0.001f一个很小的偏移量就是用来避免自交的。如果抖动严重可以适当增大这个值如0.01f但要权衡可能错过极薄物体的风险。碰撞体精度Unity的MeshCollider在复杂模型上可能不够精确特别是当光线几乎平行于表面时。可以尝试使用更简单的碰撞体近似如BoxCollider, SphereCollider或者增加Raycast的起点偏移。6.3 性能突然下降症状场景物体一多或者maxBounces调高游戏帧率骤降。分析与优化使用Physics.Raycast的重载版本指定层掩码LayerMask不是所有物体都需要进行光线交互。为光学表面物体分配特定的Layer如“Optical”然后在Raycast时指定只与该层碰撞。这能大幅减少不必要的检测。int opticalLayerMask 1 LayerMask.NameToLayer(Optical); if (Physics.Raycast(ray, out hit, rayDistance, opticalLayerMask))降低模拟频率我们的模拟在Update中运行每帧一次。如果不需要实时变化可以改为在FixedUpdate中运行或者每N帧运行一次。限制单帧模拟的光线数量如果发射器发射的是多束光如面光源不要在同一帧模拟所有光线。可以分帧处理或者使用Job System/Burst Compiler进行多线程计算这是更高级的优化。6.4 折射效果看起来“不对”症状光线穿过透明物体后的偏折方向和真实世界观察或直觉不符。深度检查折射率数值确认你使用的折射率是物理正确的。空气1.0水≈1.33玻璃≈1.5-1.7钻石≈2.42。用错数值效果自然不对。法线方向再次强调进出物体时法线方向的处理是折射最难的部分。务必用Debug.DrawRay画出每次计算用的correctNormal确保在射出物体时法线方向取反了。验证公式用一个极端情况验证。当光线垂直入射inDir与normal平行时根据斯涅尔定律折射角应该为0光线方向不变。用你的代码测试一下看refractDir是否和inDir几乎相同允许微小浮点误差。7. 项目总结与扩展方向走完这一趟我们从向量的点乘公式出发亲手搭建了一个能够模拟光线反射与折射的迷你系统。这个过程里最重要的收获不是学会了两个API的调用而是彻底弄明白了Vector3.Reflect和Refract背后那简洁而有力的数学以及如何将物理定律转化为一步步可执行的代码逻辑。当你再看到游戏里那些璀璨的反光、真实的焦散效果时你看到的将是一行行清晰的数学关系。这个项目本身可以作为一个强大的调试工具和教学演示。但它的意义更在于为你打开了图形学编程的一扇门。基于这个基础你可以向多个令人兴奋的方向扩展实现一个简易的光线追踪渲染器抛弃Unity的实时渲染自己用代码遍历屏幕的每个像素发射视线Eye Ray递归地与场景求交计算颜色。你会立刻理解为何离线渲染如此耗时也会对全局光照、软阴影、景深等效果有根本性的认识。集成到Shader中在片段着色器Fragment Shader中使用屏幕空间信息进行单次反射或折射计算可以用来实现高质量的水面、玻璃材质。这需要你熟悉ShaderLab和HLSL/GLSL但原理完全相通。模拟更复杂的光学现象比如菲涅尔效应Fresnel Effect——物体在掠射角视线与表面几乎平行时反射会增强。这只需要在反射/折射系数中加入一个基于视线与法线夹角的计算就能让你的玻璃球在边缘处出现自然的金属般高光真实感大幅提升。计算焦散Caustics这是折射光线汇聚后形成的明亮光斑比如游泳池底的光纹。你可以修改发射器发射大量随机方向的光线光子记录它们经过折射表面后照在漫反射面上的能量分布这是一种光子映射Photon Mapping的简化版。图形学的乐趣就在于用代码和数学去创造视觉的真实。这次光线反射与折射的实战就是构建这个虚拟世界最基础、也最重要的一块砖。希望你在动手实现的过程中不仅收获了代码更点燃了对创造更多视觉奇迹的好奇心。