Unity `LineRenderer` 与 `TrailRenderer` 源码级分析
Unity 与 源码级分析 1. 先给结论 适合做什么: 中低顶点数的折线、激光、弹道预览、导航路径、编辑器调试线。 需要宽度曲线、颜色渐变、圆角、端帽、贴图沿线铺开的视觉线段。 点位由脚本明确控制,更新频率不高,或者虽然会更新,但数量和摄像机数量都可控。 不适合做什么: 超大规模折线集合,尤其是“很多对象 很多点 很多摄像机/阴影 pass”同时存在的场景。 需要碰撞、物理求解、精确拓扑编辑的“…
Unity LineRenderer 与 TrailRenderer 源码级分析
1. 先给结论
LineRenderer
适合做什么:
- 中低顶点数的折线、激光、弹道预览、导航路径、编辑器调试线。
- 需要宽度曲线、颜色渐变、圆角、端帽、贴图沿线铺开的视觉线段。
- 点位由脚本明确控制,更新频率不高,或者虽然会更新,但数量和摄像机数量都可控。
不适合做什么:
- 超大规模折线集合,尤其是“很多对象 * 很多点 * 很多摄像机/阴影 pass”同时存在的场景。
- 需要碰撞、物理求解、精确拓扑编辑的“真实绳子/道路/河流网格”。
- 每帧全量改写超长点列的场景。它本质上仍是 CPU 侧重建线带几何,不是 GPU 驱动的大规模折线系统。
TrailRenderer
适合做什么:
- 典型运动残影:刀光、拖尾、烟尾、飞弹尾迹、能量拖痕、角色高速移动后的视觉轨迹。
- “轨迹来自物体运动”而不是“轨迹来自外部数据流”的场景。
- 希望自动按时间衰减、按移动距离采样、自动平滑尾巴消失的效果。
不适合做什么:
- 手工编辑任意折线、精确控制每个点的生命周期、稳定复用固定拓扑的场景。
- 很长寿命、极小
minVertexDistance、高速移动同时大量实例并存的场景。 - 以为
emitting=false就没有代价的场景。源码上它仍会继续记录“不可见点”,只是这些点在生成宽度乘子时被压成 0。
2. 共用实现骨架
两者不是两套完全独立的系统,而是共用同一套“线带网格生成”管线:
- 共享参数结构:
LineParameters,包含widthCurve、colorGradient、numCornerVertices、numCapVertices、alignment、textureMode、textureScale、shadowBias、generateLightingData等。见Runtime/Graphics/LineBuilder.h:43-151。 - 共享几何生成函数:
Build3DLine(...),输入中心线点列,输出面片化后的带状三角形网格。见Runtime/Graphics/LineBuilder.cpp:437-552。 - 共享渲染回调类型:
DrawUtil::LineAndTrailDrawCallData,最终都是动态顶点/索引缓冲上的一段几何。见Runtime/Graphics/LineRenderer.cpp:523-620、Runtime/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:299、Runtime/Graphics/LineRenderer.cpp:367-375。- 包围盒计算时,如果是 world space,则直接按点列做世界 AABB;如果是 local space,则再乘当前 Transform。见
Runtime/Graphics/LineRenderer.cpp:297-350。
适用判断:
- 路径、激光、调试线通常更适合
useWorldSpace=true。 - 绑定在角色骨骼或武器节点上的局部线段,更适合
false。
3.3 几何生成方式
- 基础几何量:
vertexCount = size * 2,indexCount = (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 = N再SetPositions(N),并且 N 很大。 - 需要实例化海量同类线条的系统。
LineRenderer不是面向大规模实例合批设计的。
4. TrailRenderer 源码解读
4.1 它不是“脚本版 LineRenderer”
TrailRenderer 的本质是“运动采样器 + 线带生成器”:
- 每个点是
TrailPoint,包含m_Position、m_TimeStamp、m_Visible。见Runtime/Graphics/TrailRenderer.h:300-305。 - 数据存储是一个环形缓冲区,使用
m_FrontPoint、m_BackPoint、m_NumPoints管理。见Runtime/Graphics/TrailRenderer.h:326-334。 - 它会注册到 Transform change system,只要物体的全局 TRS 变化就参与更新。见
Runtime/Graphics/TrailRenderer.cpp:51-56、139-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 每次可见都要重建带状几何
LineRenderer 和 TrailRenderer 都是在 RendererCullingOutputReady(...) 里统计几何量、申请动态 VBO/IBO,再在 job 中调用 Build3DLine(...) 写出真正的顶点索引。见:
Runtime/Graphics/LineRenderer.cpp:523-643Runtime/Graphics/TrailRenderer.cpp:786-955
这带来 4 个直接后果:
- 顶点数越大,CPU 几何生成越重。
- 摄像机越多,成本越高。
- 阴影 pass 也会触发一轮几何准备。
- 它们不适合作为“海量静态几何”的承载方式。
5.2 圆角 / 端帽会放大几何量
圆角和端帽不是 shader 小开关,而是真实新增顶点索引:
BuildSmoothCorner(...)见Runtime/Graphics/LineBuilder.cpp:218-306BuildCap(...)见Runtime/Graphics/LineBuilder.cpp:308-357
经验上:
numCornerVertices和numCapVertices只要从 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-566、Runtime/Graphics/TrailRenderer.cpp:832-833。 - 两者只有在
m_VertexCount <= UInt16::max()时才允许 batching。见Runtime/Graphics/LineRenderer.cpp:252-255、Runtime/Graphics/TrailRenderer.cpp:216-219,以及274-291、238-255。
这意味着超长线条会同时带来:
- 更大的 index buffer。
- 更差的合批条件。
5.5 LineRenderer 的主要热点
热点 A:全量改点
SetPositions(...) 本身就是一次 CPU 拷贝,随后渲染阶段还要再走一次完整挤出建模。见 Runtime/Graphics/LineRenderer.cpp:145-160、523-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-380、420-458。
因此最危险的组合是:
- 高速移动
- 很小的
minVertexDistance - 很长的
time
热点 B:删除旧点时可能触发整条 AABB 重算
当旧点被移除,m_RemovedVertexFromAABB 置为 true;下一次更新会遍历全部点重算 AABB。见 Runtime/Graphics/TrailRenderer.cpp:437-440、321-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 端几何生成和点数据规模。