Unity3D激光系统实现:从Raycast物理交互到递归光线追踪

📅 2026/7/24 4:49:00
Unity3D激光系统实现:从Raycast物理交互到递归光线追踪
1. 项目概述不只是画一条线那么简单在Unity3D里画一条激光或者射线乍一听像是新手教程里的“Hello World”——不就是从A点向B点发射一条LineRenderer吗但如果你真的这么想那可能就错过了这个需求背后90%的技术深度和乐趣。我最近在做一个虚拟仿真项目里面涉及到高精度的激光测距、激光打靶以及复杂环境下的光学现象模拟才发现要实现一个物理上正确、视觉上逼真、性能上高效的激光/射线系统远非一条简单的线段能搞定。这不仅仅是“画出来”的问题而是涉及到物理模拟、数学计算、渲染优化和交互逻辑的综合性课题。比如在电赛激光打靶这类应用中你需要精确计算激光的落点判断是否击中目标并模拟出光斑效果在工业仿真中你可能需要模拟激光在透明介质中的折射或者在不同材质表面的反射甚至计算能量衰减而在一些创意交互或游戏里炫酷的折射、反射光束本身就是核心玩法。所以当我们谈论在Unity3D中实现激光的发射、折射和反射时我们实际上是在构建一套轻量级的“实时光线追踪”系统它需要兼顾准确性、实时性和表现力。2. 核心思路与方案选型从简到繁的三种路径面对这个需求我梳理了三种主流实现方案它们分别对应不同的复杂度、精度和性能开销。选择哪一种完全取决于你的项目具体需要什么。2.1 方案一视觉派——基于LineRenderer与粒子系统的快速实现这是最快速、最直观的方法适合对物理精度要求不高但需要快速出效果的原型或风格化项目。核心思路使用LineRenderer组件绘制激光的主光束用Particle System在光束端点命中点或反射/折射点生成光晕、火花或散射粒子。如何实现发射在每一帧从激光发射器如摄像机或枪口的位置沿其正前方transform.forward发射一条Raycast物理射线检测。如果击中物体则将LineRenderer的终点设置为碰撞点如果未击中则设置一个最大距离作为终点。然后更新LineRenderer的SetPosition。如何模拟折射/反射这里的“模拟”更多是视觉上的。当射线击中一个表面时你可以根据该表面的法线RaycastHit.normal和入射方向通过Vector3.Reflect函数计算出反射方向。然后你可以从碰撞点开始沿着反射方向再发射一条新的Raycast并用另一条LineRenderer来绘制这条反射光束。如此递归直到达到最大反射次数或能量衰减为零。折射同理需要用到斯涅尔定律计算折射方向但通常可以简化为一个固定的角度偏折。优点实现简单性能开销低易于定制光束的颜色、宽度、纹理和动画。缺点没有真正的物理交互。它只是“画”出了路径光束本身不具备碰撞体无法与其他基于物理射线检测的脚本进行复杂交互比如反射光束无法再次触发OnRaycastHit事件除非你手动进行新的射线检测。物理现象如折射率的模拟也比较粗糙。注意这是很多教程教的方法但它本质上是一个“视觉把戏”。如果你的激光需要与场景物体有复杂的逻辑交互例如反射后的激光需要点燃另一个物体那么这个方案会非常棘手因为交互逻辑需要你手动管理每一次反弹后的新射线检测。2.2 方案二物理派——基于Raycast的递归追踪这是最常用、平衡了效果与复杂度的方案也是我目前在虚拟仿真项目中采用的核心方案。它真正实现了“所见即所得”的物理交互。核心思路编写一个递归函数模拟光线的传播过程。每一次射线检测Physics.Raycast不仅用于确定命中点还用于获取命中物体的表面信息法线、材质从而决定下一步是吸收、反射还是折射并生成新的射线继续追踪。实现发射与递归追踪初始化定义激光的起点、方向、最大距离、剩余能量或最大反弹次数。单步检测发射Physics.Raycast。命中处理记录存储命中点、命中物体、命中法线等信息。可以在这里触发事件如播放击中音效、调用物体的OnHit方法。判断根据命中物体上的自定义组件或标签判断其光学属性如是镜面反射材质、透明折射材质还是漫反射吸收材质。计算新射线反射Vector3.Reflect(入射方向, 法线)。折射使用斯涅尔定律n1 * sinθ1 n2 * sinθ2计算折射方向。在Unity中可以使用Vector3.Refract(入射方向, 法线, n1/n2)函数其中第三个参数是折射率比值。递归以当前命中点为新起点计算出的反射/折射方向为新方向剩余能量衰减或反弹次数减1递归调用追踪函数。绘制在所有递归完成后你将得到一条由多个线段从起点到第一次命中点从第一次命中点到第二次命中点...组成的折线路径。最后用一条LineRenderer设置多个点或分别用多条LineRenderer将这条路径绘制出来。优点物理交互准确。每一次反弹都是真实的物理检测反射/折射光束可以继续与场景交互逻辑严谨。非常适合仿真、解谜游戏如利用激光反射点亮机关。缺点递归深度和每帧的射线检测数量需要严格控制否则性能开销会很大。绘制多条线段时需要注意LineRenderer的合批与性能。2.3 方案三高端派——基于Shader与屏幕后处理的体积光效当你的需求超越了一条“线”而是一束“光柱”或者需要在空气中看到激光的“体积感”如雾气中的激光、水下光束时就需要动用渲染层面的技术了。核心思路不依赖于LineRenderer画线而是通过自定义Shader或屏幕后处理Post-Processing来渲染光束。这通常涉及射线步进Raymarching或体积渲染Volumetric Rendering技术。实现方式Shader绘制编写一个Unlit Shader根据光束的起点、方向、长度等信息在片元着色器中计算每个像素是否在光束体内并赋予颜色和亮度。这可以做出非常柔和、有体积感的光束边缘。屏幕后处理在全屏渲染完成后通过后处理脚本和Shader识别出激光路径例如通过特定的颜色或深度信息然后叠加体积光、辉光Bloom等特效。这种方法的效果最电影化但实现复杂性能消耗也最大。适用场景影视级视觉表现、强调氛围的独立游戏、科幻主题的UI特效。对于需要精确物理交互的仿真项目通常作为方案二的视觉增强补充而不是替代。我的选择与考量对于绝大多数虚拟仿真、教育应用或功能性的游戏机制如激光解谜方案二基于Raycast的递归追踪是基石。它保证了功能的正确性。在此基础上我们可以用方案一LineRenderer来绘制路径使其更美观对于某些特定场景再局部采用方案三Shader特效来增强表现力。所以接下来的核心内容我将围绕方案二展开并告诉你如何用LineRenderer把它漂亮地画出来。3. 核心模块深度解析与实现让我们深入到方案二的代码层面拆解每一个关键模块。我会先给出一个基础可运行的版本然后逐步加入折射、能量衰减等高级特性。3.1 基础架构激光发射器类设计首先我们创建一个LaserEmitter的MonoBehaviour脚本。这是整个激光系统的驱动核心。using System.Collections.Generic; using UnityEngine; public class LaserEmitter : MonoBehaviour { [Header(基础设置)] public int maxBounces 10; // 最大反射/折射次数 public float maxDistance 100f; // 单次射线最大距离 public LayerMask hitLayerMask; // 可以击中的层用于优化性能 public bool laserEnabled true; [Header(视觉渲染)] public LineRenderer lineRendererPrefab; // 线段渲染器预制体 public float laserStartWidth 0.05f; public float laserEndWidth 0.05f; public Color laserColor Color.red; [Header(高级物理 (可选))] public float intensityAttenuation 0.8f; // 每次反弹后强度衰减系数 public bool enableRefraction false; // 是否启用折射 private ListLineRenderer lineRenderers new ListLineRenderer(); private ListVector3 currentLaserPoints new ListVector3(); void Start() { InitializeLineRenderers(); } void Update() { if (!laserEnabled) return; ClearLaserVisuals(); CastLaser(transform.position, transform.forward, maxBounces, 1.0f); UpdateLaserVisuals(); } void InitializeLineRenderers() { // 预创建一定数量的LineRenderer对象池避免频繁Instantiate/Destroy for (int i 0; i maxBounces 1; i) { var lr Instantiate(lineRendererPrefab, transform); lr.startWidth laserStartWidth; lr.endWidth laserEndWidth; lr.startColor laserColor; lr.endColor laserColor; lr.enabled false; lineRenderers.Add(lr); } } }这个类定义了激光的基本参数并管理着一组LineRenderer用于绘制。Update函数中每一帧都会清除旧的视觉表现重新计算激光路径再更新绘制。3.2 核心算法递归光线追踪函数CastLaser这是整个系统的灵魂。它模拟了光线在场景中的物理传播。void CastLaser(Vector3 origin, Vector3 direction, int remainingBounces, float currentIntensity) { // 递归终止条件反弹次数用尽或能量太弱 if (remainingBounces 0 || currentIntensity 0.01f) { // 最后一次射向无穷远或最大距离 Vector3 endPoint origin direction * maxDistance; currentLaserPoints.Add(origin); currentLaserPoints.Add(endPoint); return; } // 发射物理射线 Ray ray new Ray(origin, direction); if (Physics.Raycast(ray, out RaycastHit hit, maxDistance, hitLayerMask)) { // 1. 记录当前线段从起点到命中点 currentLaserPoints.Add(origin); currentLaserPoints.Add(hit.point); // 2. 触发命中事件业务逻辑 HandleHitObject(hit, currentIntensity); // 3. 获取命中物体的光学属性 LaserInteraction interaction hit.collider.GetComponentLaserInteraction(); if (interaction null) return; // 如果没有交互组件光线被吸收停止追踪 // 4. 计算衰减后的强度 float newIntensity currentIntensity * intensityAttenuation * interaction.absorptionFactor; // 5. 判断并计算新的传播方向 Vector3 newDirection direction; bool shouldContinue false; if (interaction.interactionType LaserInteraction.Type.Reflective interaction.reflectivity 0.1f) { // 反射 newDirection Vector3.Reflect(direction, hit.normal).normalized; shouldContinue true; } else if (enableRefraction interaction.interactionType LaserInteraction.Type.Refractive) { // 折射 - 简化版使用固定折射率比值 // 注意这里假设光线从空气n≈1进入材质 float refractiveRatio 1.0f / interaction.refractiveIndex; newDirection Vector3.Refract(direction, hit.normal, refractiveRatio).normalized; // 折射方向可能为零向量全反射需要判断 if (newDirection ! Vector3.zero) { shouldContinue true; } else { // 发生全反射按反射处理 newDirection Vector3.Reflect(direction, hit.normal).normalized; shouldContinue true; } } // 如果是吸收型AbsorptiveshouldContinue 保持 false递归终止 // 6. 递归调用 if (shouldContinue) { // 新的起点从命中点沿法线方向稍微偏移一点避免自相交 Vector3 newOrigin hit.point newDirection * 0.001f; CastLaser(newOrigin, newDirection, remainingBounces - 1, newIntensity); } } else { // 未击中任何物体射线飞向无穷远 Vector3 endPoint origin direction * maxDistance; currentLaserPoints.Add(origin); currentLaserPoints.Add(endPoint); } } void HandleHitObject(RaycastHit hit, float intensity) { // 这里可以添加你的业务逻辑例如 // - 对击中的物体造成伤害伤害值与intensity相关 // - 触发物体的动画或音效 // - 在击中点生成粒子特效火花、烧焦痕迹 // 示例尝试调用物体上的一个接口 ILaserHittable hittable hit.collider.GetComponentILaserHittable(); hittable?.OnLaserHit(hit.point, hit.normal, intensity); }关键点解析递归与终止函数自己调用自己来实现多次反弹。终止条件不仅是反弹次数我还加入了currentIntensity当前强度的判断模拟能量衰减直至消失这比单纯计数更符合物理直觉。偏移量避免自相交在递归调用时新的起点newOrigin是在命中点hit.point基础上沿着新的传播方向newDirection微移了一个极小值0.001f。这是至关重要的一个技巧。如果不偏移下一帧的射线检测可能会立刻再次击中同一个碰撞体的同一表面导致光线被“卡住”在表面无限递归或者产生视觉上的闪烁。这个偏移量要足够小不影响视觉效果又要足够大能避开浮点精度误差。光学属性抽象我设计了一个LaserInteraction组件挂载在需要与激光交互的物体上。它定义了物体的类型反射、折射、吸收、反射率、吸收系数、折射率等。这样设计非常灵活你可以通过调整材质上的这个组件参数轻松改变物体的光学行为而无需修改激光发射器代码。折射处理使用了Unity内置的Vector3.Refract函数。你需要提供入射方向、法线和折射率比值n1/n2。这里有一个常见坑点当入射角大于临界角时Refract函数会返回Vector3.zero表示发生了全反射。我们的代码必须处理这种情况将其 fallback 到反射逻辑。3.3 交互组件设计LaserInteraction与ILaserHittable为了让场景物体能与激光互动我们需要定义一套清晰的接口和组件。// 激光交互类型 public enum LaserInteractionType { Absorptive, Reflective, Refractive } // 挂在物体上定义其光学属性 public class LaserInteraction : MonoBehaviour { public LaserInteractionType interactionType LaserInteractionType.Absorptive; [Range(0,1)] public float reflectivity 1.0f; // 反射率 [Range(0,1)] public float absorptionFactor 0.2f; // 吸收系数导致激光强度衰减 public float refractiveIndex 1.5f; // 折射率仅对折射类型有效 }// 一个可选接口用于物体响应被激光击中事件 public interface ILaserHittable { void OnLaserHit(Vector3 hitPoint, Vector3 hitNormal, float intensity); } // 示例实现一个会被激光点燃的物体 public class FlammableObject : MonoBehaviour, ILaserHittable { public ParticleSystem fireEffect; private bool isOnFire false; public void OnLaserHit(Vector3 hitPoint, Vector3 hitNormal, float intensity) { if (intensity 0.5f !isOnFire) // 强度足够高时才点燃 { isOnFire true; if (fireEffect ! null) { fireEffect.transform.position hitPoint; fireEffect.Play(); } // 可以在这里添加掉血、销毁等逻辑 Debug.Log(gameObject.name is on fire!); } } }这种设计实现了关注点分离LaserEmitter只负责计算光路和触发通用事件物体自身通过LaserInteraction定义“我是什么”通过实现ILaserHittable来定义“我被击中时做什么”。代码结构清晰易于扩展。3.4 视觉渲染优化高效管理LineRenderer在CastLaser函数中我们只是将所有的路径点线段起点和终点存入了currentLaserPoints列表。在UpdateLaserVisuals中我们需要将这些点分配给预先创建好的LineRenderer对象池。void ClearLaserVisuals() { currentLaserPoints.Clear(); foreach (var lr in lineRenderers) { lr.enabled false; lr.positionCount 0; // 清空位置 } } void UpdateLaserVisuals() { // currentLaserPoints 中按顺序存储了起点1终点1也是起点2终点2也是起点3... // 每两个点构成一条线段。 int segmentCount (currentLaserPoints.Count - 1) / 2; // 计算线段数量 if (segmentCount 0) return; for (int i 0; i segmentCount i lineRenderers.Count; i) { LineRenderer lr lineRenderers[i]; lr.enabled true; lr.positionCount 2; // 设置线段起点和终点 lr.SetPosition(0, currentLaserPoints[i * 2]); lr.SetPosition(1, currentLaserPoints[i * 2 1]); // 可选根据线段索引或强度衰减来设置颜色/宽度渐变 float fadeFactor Mathf.Pow(intensityAttenuation, i); // 假设每次反弹衰减相同 Color fadedColor laserColor * fadeFactor; lr.startColor fadedColor; lr.endColor fadedColor; } }性能心得对象池是关键在Start或Awake中预实例化所有可能用到的LineRenderer在运行时只是启用/禁用和设置位置。这比每一帧都Instantiate和Destroy要高效得多。合并绘制调用如果可能尽量使用一个LineRenderer的positionCount来绘制整条折线将所有点依次设置进去这比用多个LineRenderer性能更好。但是当需要为每一段设置不同的颜色或宽度比如模拟能量衰减时多个LineRenderer更灵活。你需要根据视觉效果需求在性能和表现力之间权衡。在我的实现中为了清晰地展示每一段反弹并方便地做衰减效果我选择了多段式。4. 高级特性与效果增强基础功能跑通后我们可以让它变得更强大、更逼真。4.1 精确的折射模拟与全反射处理之前的折射计算是简化版。更精确的模拟需要考虑光线从哪种介质进入哪种介质。我们可以在LaserInteraction组件上增加一个surfaceNormalOrientation参数或者通过射线检测时的点乘结果来判断光线是从外部进入物体还是从内部射出。// 在CastLaser的折射计算部分进行改进 else if (enableRefraction interaction.interactionType LaserInteraction.Type.Refractive) { // 判断光线是从外部射入还是内部射出 bool entering Vector3.Dot(direction, hit.normal) 0; // 方向与法线相反表示射入 float n1 1.0f; // 假设外部是空气 float n2 interaction.refractiveIndex; float ratio entering ? n1 / n2 : n2 / n1; // 进入时n1/n2射出时n2/n1 Vector3 normal entering ? hit.normal : -hit.normal; // 射出时法线取反 newDirection Vector3.Refract(direction, normal, ratio).normalized; if (newDirection Vector3.zero) { // 全反射 newDirection Vector3.Reflect(direction, normal).normalized; shouldContinue true; // 注意发生全反射时光线仍在同一介质内传播可能需要标记防止无限循环 } else { shouldContinue true; } }4.2 能量衰减与颜色渐变真实的激光在空气中传播会有衰减击中不同材料反射时能量损失也不同。我们可以让LaserInteraction的absorptionFactor发挥作用并在视觉上通过LineRenderer的颜色和宽度来体现。// 在UpdateLaserVisuals中为每个线段设置基于强度的视觉属性 for (int i 0; i segmentCount i lineRenderers.Count; i) { // ... 启用和设置位置 ... // 计算该段的强度因子这里用一个简单的指数衰减模拟 float intensityFactor Mathf.Pow(intensityAttenuation, i); // 应用颜色衰减 Color segmentColor laserColor; segmentColor.a * intensityFactor; // 透明度衰减 segmentColor.r * intensityFactor; // 你也可以只调整亮度 segmentColor.g * intensityFactor; segmentColor.b * intensityFactor; lr.startColor segmentColor; lr.endColor segmentColor; // 应用宽度衰减 lr.startWidth laserStartWidth * intensityFactor; lr.endWidth laserEndWidth * intensityFactor; }4.3 击中点特效与交互反馈激光击中物体时不能只有一条线。我们需要丰富的反馈。光斑在HandleHitObject中在命中点hit.point实例化一个小的圆形面片Quad或粒子其法线对齐hit.normal用来模拟激光光斑。火花/烟雾粒子根据击中材质可通过hit.collider.material或自定义标签判断播放不同的粒子系统。比如击中金属产生火花击中木头产生烟雾。贴花Decal使用贴花系统如Unity的Projector组件或更现代的Shader Graph Decal在击中表面投影一个烧焦或熔化的痕迹这会极大增强真实感。声音播放对应的击中音效。这些特效都应该通过对象池管理避免运行时产生GC垃圾回收压力。5. 性能优化与常见问题排查一个每帧都在进行多次Raycast和递归计算的系统性能是必须关注的重点。5.1 性能优化策略限制射线检测频率对于非高速移动的激光不一定需要每帧Update都计算。可以改为每N帧计算一次Coroutine中使用WaitForSeconds或yield return null配合计数器或者在激光路径发生变化时发射器旋转、目标移动才重新计算。对于静态环境中的静态激光路径可以预计算并缓存。使用Physics.Raycast的非分配版本Physics.Raycast有一个重载版本接受RaycastHit作为out参数这不会产生内存分配。而Physics.RaycastAll则会返回数组可能产生GC。在递归中坚持使用非分配版本。精心设置LayerMask通过hitLayerMask排除那些激光绝对不需要交互的层如UI、忽略射线层。这能大幅减少物理引擎需要检测的碰撞体数量。控制递归深度maxBounces不要设置得过高通常5-10次已经足够视觉效果和物理模拟都能接受。可以设置一个minIntensity阈值当能量低于该值时提前终止递归。LineRenderer优化禁用阴影在LineRenderer组件上取消勾选Cast Shadows和Receive Shadows。合批如果使用多个LineRenderer确保它们使用相同的材质并且材质开启了GPU Instancing这样可以被动态合批。减少顶点在满足视觉效果的前提下LineRenderer的Corner Vertices和End Cap Vertices参数可以调低。5.2 常见问题与解决方案实录问题1激光在光滑表面疯狂闪烁或抖动。原因这是最经典的“自相交”问题。递归时新射线的起点newOrigin没有从碰撞点偏移导致下一帧的射线立刻又打到了同一个碰撞体。解决务必在计算新起点时沿新方向做一个微小的偏移如hit.point newDirection * 0.001f。如果问题依然存在可以尝试稍微增大偏移量或者检查碰撞体的Collider是否在抖动可能是刚体物理引起的。问题2激光穿过了薄物体或从边缘“漏”过去。原因Raycast的起点如果已经在碰撞体内部它可能检测不到该碰撞体。当我们的偏移量设置不当时或者物体非常薄就可能发生这种情况。解决使用Physics.Raycast的另一个重载指定QueryTriggerInteraction.Ignore确保不会与触发器发生意外交互。考虑使用Physics.SphereCast或CapsuleCast代替Raycast给射线一个微小的半径可以提高检测稳定性尤其对于边缘情况。仔细调试偏移量。对于折射进入物体的情况可能需要一个向内的偏移对于从物体内部射出时需要一个向外的偏移。问题3递归次数很多时游戏帧率明显下降。原因每一层递归都意味着一次或多次Raycast调用深度过大时CPU开销剧增。解决降低maxBounces这是最直接有效的方法。评估你的场景是否真的需要10次以上的反弹。引入强度衰减设置intensityAttenuation让激光在几次反弹后因能量过低而消失自然限制了深度。分帧计算将一次完整的路径计算分摊到多帧完成。例如第一帧计算前3次反弹下一帧再计算后续的。但这会增加视觉延迟需要权衡。问题4激光击中移动物体时路径更新有延迟或抖动。原因Update中计算路径和渲染是同步的但如果物体移动速度很快当前帧计算出的命中点在下一帧物体已经移开导致激光“追着”物体跑感觉不连贯。解决对于高速移动的目标可以考虑使用FixedUpdate进行物理计算但渲染仍在Update或者对目标位置进行简单的预测如使用上一帧的速度向量来预估下一帧的位置但这会显著增加复杂度。对于大多数仿真项目只要激光速度远高于目标速度光速嘛这个问题不明显。问题5多条激光同时存在时Draw Call很高。原因每个激活的LineRenderer通常至少产生一个Draw Call。如果它们材质不同则无法合批。解决合并材质确保所有激光线段使用完全相同的材质球。使用一个复杂的LineRenderer尝试将所有激光路径的点都合并到一个LineRenderer中用positionCount和SetPositions一次性设置。但这需要处理不同激光之间的连接逻辑通常用NaN位置来断开线条并且所有线段视觉样式必须统一。考虑使用ECS或Jobs对于极大量成千上万条的激光模拟这是终极方案但实现复杂度极高属于高级优化范畴。实现一个健壮的Unity3D激光系统就像在搭建一个微型的物理引擎。它考验着你对射线检测、向量数学、递归算法和渲染管线的综合理解。从最简单的画线开始逐步加入物理交互、能量衰减、视觉特效最后再针对性能进行打磨这个过程本身就是一个极佳的学习路径。希望这份结合了原理、代码和实战经验的拆解能帮你少走弯路更快地在你自己的虚拟仿真、游戏或交互项目中创造出那道既科学又炫目的光。