Unity `LineRenderer` 与 `TrailRenderer` 源码级分析

Unity 与 源码级分析 1. 先给结论 适合做什么: 中低顶点数的折线、激光、弹道预览、导航路径、编辑器调试线。 需要宽度曲线、颜色渐变、圆角、端帽、贴图沿线铺开的视觉线段。 点位由脚本明确控制,更新频率不高,或者虽然会更新,但数量和摄像机数量都可控。 不适合做什么: 超大规模折线集合,尤其是“很多对象 很多点 很多摄像机/阴影 pass”同时存在的场景。 需要碰撞、物理求解、精确拓扑编辑的“…


Unity LineRendererTrailRenderer 源码级分析

1. 先给结论

LineRenderer

适合做什么:

  • 中低顶点数的折线、激光、弹道预览、导航路径、编辑器调试线。
  • 需要宽度曲线、颜色渐变、圆角、端帽、贴图沿线铺开的视觉线段。
  • 点位由脚本明确控制,更新频率不高,或者虽然会更新,但数量和摄像机数量都可控。

不适合做什么:

  • 超大规模折线集合,尤其是“很多对象 * 很多点 * 很多摄像机/阴影 pass”同时存在的场景。
  • 需要碰撞、物理求解、精确拓扑编辑的“真实绳子/道路/河流网格”。
  • 每帧全量改写超长点列的场景。它本质上仍是 CPU 侧重建线带几何,不是 GPU 驱动的大规模折线系统。

TrailRenderer

适合做什么:

  • 典型运动残影:刀光、拖尾、烟尾、飞弹尾迹、能量拖痕、角色高速移动后的视觉轨迹。
  • “轨迹来自物体运动”而不是“轨迹来自外部数据流”的场景。
  • 希望自动按时间衰减、按移动距离采样、自动平滑尾巴消失的效果。

不适合做什么:

  • 手工编辑任意折线、精确控制每个点的生命周期、稳定复用固定拓扑的场景。
  • 很长寿命、极小 minVertexDistance、高速移动同时大量实例并存的场景。
  • 以为 emitting=false 就没有代价的场景。源码上它仍会继续记录“不可见点”,只是这些点在生成宽度乘子时被压成 0。

2. 共用实现骨架

两者不是两套完全独立的系统,而是共用同一套“线带网格生成”管线:

  • 共享参数结构:LineParameters,包含 widthCurvecolorGradientnumCornerVerticesnumCapVerticesalignmenttextureModetextureScaleshadowBiasgenerateLightingData 等。见 Runtime/Graphics/LineBuilder.h:43-151
  • 共享几何生成函数:Build3DLine(...),输入中心线点列,输出面片化后的带状三角形网格。见 Runtime/Graphics/LineBuilder.cpp:437-552
  • 共享渲染回调类型:DrawUtil::LineAndTrailDrawCallData,最终都是动态顶点/索引缓冲上的一段几何。见 Runtime/Graphics/LineRenderer.cpp:523-620Runtime/Graphics/TrailRenderer.cpp:786-885
  • C# 侧 API 都绑定到原生实现,且都支持 Vector3[] / NativeArray<Vector3> / NativeSlice<Vector3>,见 Runtime/Export/Graphics/GraphicsRenderers.bindings.cs:219-373

可以把它们理解成:

  • LineRenderer = “你给我一串中心点,我把它挤出成摄像机朝向或 Transform Z 朝向的带状网格”。
  • TrailRenderer = “我先按物体运动自动维护一串带时间戳的中心点,再走同样的带状网格生成器”。

3. LineRenderer 源码解读

3.1 数据结构与生命周期

  • 点位直接存放在 LinePoints::m_Array,类型是 dynamic_array<float3_storage>,没有时间戳。见 Runtime/Graphics/LineRenderer.h:275-295
  • positionCount 真正对应的是原生数组大小,SetPositionsCount 会直接 resize_initialized。见 Runtime/Graphics/LineRenderer.cpp:85-99
  • SetPositions(...) 只会写入“已有容量”范围内的数据,不会自动扩容,所以脚本通常必须先设置 positionCount。见 Runtime/Graphics/LineRenderer.cpp:145-160
  • Simplify(float tolerance) 调用 SimplifyLine 做点集简化,再回写数组。见 Runtime/Graphics/LineRenderer.cpp:101-115

这说明 LineRenderer 的核心模型是“显式拥有一份点列”。它不会像 TrailRenderer 一样帮你管采样与淘汰。

3.2 世界空间 / 本地空间

  • m_UseWorldSpace 决定输入点是世界空间还是本地空间。见 Runtime/Graphics/LineRenderer.h:299Runtime/Graphics/LineRenderer.cpp:367-375
  • 包围盒计算时,如果是 world space,则直接按点列做世界 AABB;如果是 local space,则再乘当前 Transform。见 Runtime/Graphics/LineRenderer.cpp:297-350

适用判断:

  • 路径、激光、调试线通常更适合 useWorldSpace=true
  • 绑定在角色骨骼或武器节点上的局部线段,更适合 false

3.3 几何生成方式

  • 基础几何量:vertexCount = size * 2indexCount = (size - 1) * 6。见 Runtime/Graphics/LineRenderer.cpp:477-490
  • 开启 loop 时会多补一个逻辑点。见 Runtime/Graphics/LineRenderer.cpp:486-487
  • 开启圆角时,每个 corner 会额外增加 numCornerVertices * 2 个顶点和 numCornerVertices * 3 个索引。见 Runtime/Graphics/LineRenderer.cpp:492-497
  • 开启端帽时,还会额外增加 (numCapVertices + 3) * 2 个顶点和 (numCapVertices + 1) * 3 * 2 个索引。见 Runtime/Graphics/LineRenderer.cpp:499-503

几何不是缓存好的静态网格,而是在可见性输出阶段为每个 renderer 安排 geometry job:

  • 统计顶点和索引数量:RendererCullingOutputReady(...)。见 Runtime/Graphics/LineRenderer.cpp:523-620
  • 分配 DynamicVBOBuffer / IndexBuffer
  • RenderGeometryJob(...) 中调用 Build3DLine(...) 真正写顶点索引。见 Runtime/Graphics/LineRenderer.cpp:622-643

这意味着:只要它被渲染,就会持续走 CPU 侧几何生成流程,而不是像静态 MeshRenderer 一样直接复用现成网格。

3.4 什么时候适合用它

  • 单条或少量折线,但希望直接享受 Unity 自带的宽度曲线、渐变、圆角、端帽、UV 模式。
  • 调试、编辑器工具、临时可视化、技能激光、锁定线、指路线。
  • 点列会变,但不是几千条超长折线每帧整体重算。

3.5 什么时候不适合

  • 超长路线图、道路网、地形边界线这类“数据大而稳定”的内容。更适合离线烘成 Mesh。
  • 高频全量重建。例如每帧 positionCount = NSetPositions(N),并且 N 很大。
  • 需要实例化海量同类线条的系统。LineRenderer 不是面向大规模实例合批设计的。

4. TrailRenderer 源码解读

4.1 它不是“脚本版 LineRenderer”

TrailRenderer 的本质是“运动采样器 + 线带生成器”:

  • 每个点是 TrailPoint,包含 m_Positionm_TimeStampm_Visible。见 Runtime/Graphics/TrailRenderer.h:300-305
  • 数据存储是一个环形缓冲区,使用 m_FrontPointm_BackPointm_NumPoints 管理。见 Runtime/Graphics/TrailRenderer.h:326-334
  • 它会注册到 Transform change system,只要物体的全局 TRS 变化就参与更新。见 Runtime/Graphics/TrailRenderer.cpp:51-56139-152

也就是说,它天然绑定“对象运动”这件事,而不是“外部提供任意折线数据”。

4.2 点是怎么自动生成的

CalculateWorldMatrixAndBoundsJob(...) 里:

  • 若这是第一批点,并且对象已有上一帧位置,会先补一个“旧位置”点,保证 trail 从实例化位置开始。见 Runtime/Graphics/TrailRenderer.cpp:305-316
  • 然后调用 AddPointWithMinDistanceCheck(now, worldMatrix.t)。见 Runtime/Graphics/TrailRenderer.cpp:318
  • 再调用 RemoveOldPoints(now, kDisallowAutodestruct) 淘汰过期点。见 Runtime/Graphics/TrailRenderer.cpp:319

AddPointWithMinDistanceCheck(...) 很简单:

  • 如果还没有点,直接加。
  • 否则只有“与最新点距离平方大于 minVertexDistance^2”时才加。见 Runtime/Graphics/TrailRenderer.cpp:377-380

这说明影响点数增长速度的关键参数是:

  • 物体速度。
  • minVertexDistance
  • time

4.3 环形缓冲区的真实代价

AddPoint(...) 在缓冲区满时会扩容,并把当前环形内容拷贝回 0 基数组:

  • 先临时拷出旧点。
  • push_back 扩一格。
  • memcpy 回连续内存。
  • 重置 m_BackPoint / m_FrontPoint。见 Runtime/Graphics/TrailRenderer.cpp:383-414

这意味着:

  • Trail 点数增长过程中会出现扩容拷贝尖峰。
  • 对“长寿命 + 高频采样”的 trail,这种尖峰不是理论风险,而是必然会发生。

4.4 过期删除与平滑消失

RemoveOldPoints(...) 会一直删除超时点,但保留 1 个额外点让尾巴平滑消失。见 Runtime/Graphics/TrailRenderer.cpp:420-458

渲染前,FlattenRingbuffer(...) 会:

  • 把环形缓冲区拍平成线性数组。
  • 在数组头部插入“当前物体位置”,避免尾迹头部和物体之间出现缝。见 Runtime/Graphics/TrailRenderer.cpp:887-909
  • 对最老点做插值,形成平滑死亡。见 Runtime/Graphics/TrailRenderer.cpp:911-922

因此 TrailRenderer 的视觉连续性比“脚本每帧 append 到 LineRenderer”要好,这是它存在的根本价值之一。

4.5 emitting=false 的真实行为

一个很容易误判的点:

  • AddPoint(...) 会把新点的 m_Visible 设为 m_Emitting。见 Runtime/Graphics/TrailRenderer.cpp:416-418
  • FlattenRingbuffer(...) 再把 m_Visible=false 的点转换成 widthMultiplier = 0.0f。见 Runtime/Graphics/TrailRenderer.cpp:890-907
  • 编辑器测试也验证了:emitting=false 后,可见点不会增长,但对象继续移动时仍会增加“不可见点”。见 Tests/EditModeAndPlayModeTests/Graphics/Assets/Editor/TrailRendererTests.cs:180-205

结论:

  • emitting=false 是“停止生成可见尾迹”,不是“停止记录轨迹成本”。
  • 如果你想真正省 CPU/内存,应该优先考虑禁用组件、缩短 time、增大 minVertexDistance,或者让对象不再触发位移更新。

4.6 什么时候适合用它

  • 轨迹天然来自物体移动。
  • 需要“按时间消失”的尾迹,而不是永久保留的折线。
  • 需要避免手写时间戳、手写首点补偿、手写尾端平滑死亡逻辑。

4.7 什么时候不适合

  • 想拿它当任意 polyline 容器。
  • 想精确控制每一帧点列且完全关闭自动行为。
  • 大量高速对象、长 trail、很小 minVertexDistance 同时出现的情况。

5. 性能问题拆解

5.1 共同问题:CPU 每次可见都要重建带状几何

LineRendererTrailRenderer 都是在 RendererCullingOutputReady(...) 里统计几何量、申请动态 VBO/IBO,再在 job 中调用 Build3DLine(...) 写出真正的顶点索引。见:

  • Runtime/Graphics/LineRenderer.cpp:523-643
  • Runtime/Graphics/TrailRenderer.cpp:786-955

这带来 4 个直接后果:

  • 顶点数越大,CPU 几何生成越重。
  • 摄像机越多,成本越高。
  • 阴影 pass 也会触发一轮几何准备。
  • 它们不适合作为“海量静态几何”的承载方式。

5.2 圆角 / 端帽会放大几何量

圆角和端帽不是 shader 小开关,而是真实新增顶点索引:

  • BuildSmoothCorner(...)Runtime/Graphics/LineBuilder.cpp:218-306
  • BuildCap(...)Runtime/Graphics/LineBuilder.cpp:308-357

经验上:

  • numCornerVerticesnumCapVertices 只要从 0 拉到 8、16、32,几何量会非常快地膨胀。
  • 如果线条很细、运动很快、屏幕上占比又不大,圆角通常不值回票价。

5.3 generateLightingData=true 会显著增大顶点带宽

未开启 lighting 数据时,顶点写入的是 LineVertex:位置 + 颜色 + UV。见 Runtime/Graphics/LineBuilder.h:10-15

开启后写入 LineVertexLit:位置 + 法线 + 切线 + 颜色 + UV。见 Runtime/Graphics/LineBuilder.h:17-24

按字段估算,单顶点数据量大约从 24B 增到 52B,未计平台额外对齐。这不只增加显存带宽,也增加 CPU 写顶点成本。

结论:

  • 只有确实需要逐像素受光的线/尾迹时再开。
  • 纯 UI 风格激光、指示线、残影通常没必要。

5.4 顶点过多会切到 32 位索引,还会丢失批处理机会

  • 两者都在 vertexCount > 65535 时切到 32 位索引。见 Runtime/Graphics/LineRenderer.cpp:565-566Runtime/Graphics/TrailRenderer.cpp:832-833
  • 两者只有在 m_VertexCount <= UInt16::max() 时才允许 batching。见 Runtime/Graphics/LineRenderer.cpp:252-255Runtime/Graphics/TrailRenderer.cpp:216-219,以及 274-291238-255

这意味着超长线条会同时带来:

  • 更大的 index buffer。
  • 更差的合批条件。

5.5 LineRenderer 的主要热点

热点 A:全量改点

SetPositions(...) 本身就是一次 CPU 拷贝,随后渲染阶段还要再走一次完整挤出建模。见 Runtime/Graphics/LineRenderer.cpp:145-160523-643

如果你每帧都更新整条长折线,成本实际上是:

  • 脚本/绑定拷贝一遍。
  • 原生数组保存一遍。
  • 几何生成再扫描一遍并写顶点索引。

热点 B:包围盒重算

CalculateWorldMatrixAndBoundsJob(...) 会遍历所有点找 min/max。见 Runtime/Graphics/LineRenderer.cpp:312-323

因此点数大时:

  • Transform 变化会带来 AABB 扫描成本。
  • 即使线本身视觉简单,超长点列也会拖慢更新。

热点 C:异常大 positionCount

测试里专门验证了 positionCount = 20 * 1024 * 1024 不崩。见 Tests/EditModeAndPlayModeTests/Graphics/Assets/Editor/LineRendererTests.cs:10-18

这只说明“接口尽量不 crash”,不说明这是合理预算。20M 点的内存、包围盒扫描、几何展开、索引切换都会非常重。

5.6 TrailRenderer 的主要热点

热点 A:点数增长由速度、寿命、采样距离共同决定

点数近似受这个关系影响:

活跃点数 ~= 轨迹寿命内的移动距离 / minVertexDistance

虽然这是根据 AddPointWithMinDistanceCheck(...)RemoveOldPoints(...) 推出来的近似式,不是源码里的显式公式,但对估算很有效。来源见 Runtime/Graphics/TrailRenderer.cpp:377-380420-458

因此最危险的组合是:

  • 高速移动
  • 很小的 minVertexDistance
  • 很长的 time

热点 B:删除旧点时可能触发整条 AABB 重算

当旧点被移除,m_RemovedVertexFromAABB 置为 true;下一次更新会遍历全部点重算 AABB。见 Runtime/Graphics/TrailRenderer.cpp:437-440321-340

这类成本在尾迹寿命较长、删除频繁时很常见。

热点 C:渲染前要把环形缓冲区拍平

RenderGeometryJob(...) 里会临时申请线性数组和宽度数组,再调用 FlattenRingbuffer(...)。见 Runtime/Graphics/TrailRenderer.cpp:925-947

也就是说 trail 的点列不是直接送给 Build3DLine(...),中间还有一层额外整理成本。

热点 D:emitting=false 不等于零成本

如上所述,继续移动时仍会新增不可见点。这类项目里很容易出现“画面没尾迹了,但 CPU 还在涨”的误判。


6. 选型建议

LineRenderer

当你的问题是:

  • “我已经有明确的点列。”
  • “我想画的是线,而不是运动历史。”
  • “我需要简化、loop、脚本直接读写点位。”

TrailRenderer

当你的问题是:

  • “点列应该由物体运动自动生成。”
  • “我需要按时间衰减。”
  • “我要的是拖尾,不是通用折线容器。”

两者都不该选

当你的问题是:

  • 海量实例。
  • 超长静态数据。
  • 需要稳定低 CPU 的大规模地图线网。
  • 需要碰撞/物理或复杂拓扑编辑。

这类情况通常应该转向:

  • 预烘焙 Mesh。
  • 自定义 Mesh 生成与缓存。
  • GPU 驱动方案。
  • VFX Graph / 粒子尾迹系统。

7. 实际优化建议

LineRenderer

  • 长线尽量离线烘成 Mesh,不要每帧全量 SetPositions
  • numCornerVertices / numCapVertices 从 0 开始,只在真有必要时增加。
  • generateLightingData 默认关。
  • 如果点很多但形状变化不大,优先用 Simplify(...)。见 Runtime/Graphics/LineRenderer.cpp:101-115
  • 超长单条线比多条中等长度线更容易触发 32 位索引和失去 batching。

TrailRenderer

  • 第一优先级是调大 minVertexDistance。这是最直接的点数控制器。
  • 第二优先级是缩短 time
  • 不要把 emitting=false 当成性能开关。
  • 对高速对象,先确认屏幕上是否真需要细密轨迹;很多时候视觉上 0.2 到 0.5 的距离采样就够了。
  • 如果对象会频繁开启/关闭尾迹,必要时考虑 Clear() + 禁用组件,而不是只改 emitting。见 Runtime/Graphics/TrailRenderer.cpp:463-475

8. 一个实用判断标准

如果你在做的是“线”:

  • 点由游戏逻辑直接给出,用 LineRenderer
  • 点由物体运动历史自然产生,用 TrailRenderer

如果你在做的是“很多线”:

  • 先别急着用这两个组件,先问自己能不能直接做 Mesh。

如果你在做的是“很贵的线”:

  • 先砍 corner/cap
  • 再砍 lighting
  • 再控点数
  • 最后再谈 shader 优化

因为从源码看,真正先把你拖慢的,通常不是 shader,而是 CPU 端几何生成和点数据规模。