Unity道路制作全攻略:Terrain、Splines与程序化Mesh实战
发布时间:2026/10/1 2:39:46来源:尧图网络
很多Unity开发者在做地形场景时会遇到一个看似简单、实际上非常折磨人的环节道路制作。用Terrain的笔刷刷一条土路弯道接不顺用Cube拉伸铺设和地形起伏不贴合换第三方插件又担心授权、性能和学习成本。如果你正在经历这个阶段这篇文章会帮你把道路制作的思路彻底理清楚。先说一个我的判断在Unity里做道路真正的难点从来不是“把路面画出来”而是道路能否与地形自然融合、是否支持后续反复调整以及弯道、路口、UV方向这些细节是否经得起推敲。你选择了哪套方案直接决定了这几个难点是可控还是失控。本文会从底层原理讲起覆盖Terrain道路、Splines程序化生成、Mesh构建三种主流做法并给出一套完整可运行的示例代码希望能帮你少走弯路。1. Unity场景道路制作的核心问题1.1 为什么道路这么容易做“假”很多刚接触Unity场景搭建的开发者第一次尝试做道路往往是从Asset Store下载一个道路模型拖进场景或者用地形工具在地形上随手刷几下。这两种方式在截图里看还行一旦进入实际项目就有明显问题第一种问题出现在地面衔接上。道路模型是固定高度的几何体带起伏的地形不可能和它完美贴合。地形高一点路面就悬空低一点路面就陷进土里。第二种问题出现在可编辑性上。地形笔刷的道路纹理是一次性画到SplatMap里的画完之后想挪位置、改弯道弧度只能反复重刷效率很低。从项目整体看道路系统还牵涉到一条完整链条道路的物理碰撞、NavMesh寻路区域、车道标识纹理、路肩边缘、红绿灯与社会车辆的逻辑路径。如果道路制作方案没有选对后续每走一步都会回头补前面的坑。所以本文不是教你“随便把路做出来”而是帮你建立一套可持续修改的道路制作流程。1.2 道路制作方案的选型矩阵制作道路主流有三个方向Terrain工具方案、Splines曲线方案、完全程序化Mesh方案。我先用表格把核心差异列出来后面再逐一拆解。方案上手难度可编辑性地形贴合交叉路口性能开销适合场景Terrain笔刷低差好差低快速原型、远景地表Splines ProBuilder中较好需手动对齐一般低中大型开放场景程序化Mesh生成高好可通过代码采样贴合较复杂低需要复用、批量生成单看这个表格你可能会觉得程序化Mesh是最优解。实际上并非如此。程序化方案虽然灵活但开发周期长对数学基础有一定要求。如果你的目标只是做一个Demo场景Terrain笔刷在不追求质量的前提下完全够用。最稳妥的项目路径常常是先用Terrain或Splines快速搭出道路布局再用程序化手段对重点路段做精细化处理。2. Terrain地形道路制作踩坑实录2.1 Terrain道路的底层原理要理解Terrain笔刷为什么做道路容易踩坑得先知道它到底做了什么。Terrain的高度图控制地形起伏而地表纹理通过SplatMap记录每张纹理对应一个通道笔刷实际上是在修改某个通道的权重值。当你用Terrain的Paint Texture功能绘制道路时本质上是把道路纹理的alpha权重刷到目标区域。这个方案的优势是道路和地形天然贴合因为路面根本不存在独立几何体只是地表纹理的两层混合。缺点也很致命道路没有厚度、没有边缘体积而且无法承载车辆碰撞、路沿石这类物理信息。从项目实战角度看Terrain道路更适合做大场景里的野外道路、小径和远景贴图不适合做城市道路、高速路和需要车辆物理交互的道路。这个结论很多人做到一半才会意识到。2.2 Terrain刷道路的常规操作如果只是需要一条视觉上可行的道路操作路径如下在Hierarchy里创建Terrain然后在Inspector面板打开Terrain Settings找到Paint Texture标签。要先添加一个基础的草地纹理层再添加道路纹理层然后选中道路层用软边笔刷沿路径绘制。有一个容易被忽略的细节刷之前必须把Terrain的Heightmap分辨率调高否则道路边界会非常毛糙。同时建议使用多块地形拼合大地图时将相邻地形的SplatMap对齐否则跨地形接缝处纹理权重会突变。2.3 SetAlphamaps编程式铺设道路纹理如果觉得手刷效率太低可以通过代码一次性铺设道路纹理。这是Terrain方案里最实用的技巧核心API是TerrainData.SetAlphamaps。// 文件路径Assets/Scripts/TerrainRoadPainter.cs using UnityEngine; public class TerrainRoadPainter : MonoBehaviour { public Terrain terrain; public int roadTextureIndex 1; public float roadWidth 4f; public void PaintRoadAt(Vector3 worldStart, Vector3 worldEnd) { TerrainData data terrain.terrainData; float[,] heights data.GetHeights(0, 0, data.heightmapResolution, data.heightmapResolution); Vector3 terrainSize data.size; // 将世界坐标转换为地形局部坐标范围 [0,1] Vector2 localStart WorldToTerrainLocal(worldStart); Vector2 localEnd WorldToTerrainLocal(worldEnd); // sampleCount 决定道路上每个采样点的密度 int sampleCount Mathf.CeilToInt((localEnd - localStart).magnitude * data.alphamapResolution) 1; // 获取当前SplatMap float[,,] alphamaps data.GetAlphamaps(0, 0, data.alphamapResolution, data.alphamapResolution); for (int s 0; s sampleCount; s) { float t (float)s / (sampleCount - 1); Vector2 point Vector2.Lerp(localStart, localEnd, t); int mapX Mathf.FloorToInt(point.x * data.alphamapResolution); int mapZ Mathf.FloorToInt(point.y * data.alphamapResolution); // roadWidth 转换成alphaMap坐标下的半径 int radius Mathf.CeilToInt(roadWidth * 0.5f * data.alphamapResolution / terrainSize.x); for (int dx -radius; dx radius; dx) { for (int dz -radius; dz radius; dz) { int sampleX mapX dx; int sampleZ mapZ dz; if (sampleX 0 || sampleX data.alphamapResolution || sampleZ 0 || sampleZ data.alphamapResolution) continue; // 渐变边缘道路纹理权重从1衰减到0 float dist Mathf.Sqrt(dx * dx dz * dz); float edge 1f - Mathf.Clamp01(dist / radius); for (int layer 0; layer data.alphamapLayers; layer) { if (layer roadTextureIndex) alphamaps[sampleZ, sampleX, layer] Mathf.Max(alphamaps[sampleZ, sampleX, layer], edge); else alphamaps[sampleZ, sampleX, layer] Mathf.Min(alphamaps[sampleZ, sampleX, layer], 1f - edge); } } } } data.SetAlphamaps(0, 0, alphamaps); } private Vector2 WorldToTerrainLocal(Vector3 worldPos) { // 简化处理假设地形位于原点且无旋转缩放实际项目建议使用tileSystem TerrainData data terrain.terrainData; Vector3 terrainPos terrain.transform.position; Vector3 size data.size; float localX (worldPos.x - terrainPos.x) / size.x; float localZ (worldPos.z - terrainPos.z) / size.z; return new Vector2(Mathf.Clamp01(localX), Mathf.Clamp01(localZ)); } }这段代码的关键逻辑是把世界坐标的道路起终点投影到地形的alphamap坐标系上沿路径逐点采样并以渐变权重方式把道路纹理叠加到目标图层。修改roadTextureIndex可以指定使用哪一层纹理roadWidth控制道路宽度。实际使用时要注意GetAlphamaps和SetAlphamaps是重量级操作频繁调用会造成明显卡顿建议在编辑器模式下批量处理道路而不是在运行时每帧调用。2.4 Terrain方案的边界在哪里即便用了编程式铺设Terrain方案在几个场景下仍然受限。第一个是交叉路口。用纹理权重画出的十字路口在四车道汇聚处容易出现纹理重叠和边缘生硬你很难通过权重计算出一个自然的沥青弯道导流区域。第二个是道路数据导出。如果需要把道路信息交给其他系统使用比如交通流仿真或自动驾驶仿真Terrain纹理方式没有对应的路网结构数据需要额外的解析工作。所以在进入中大型项目之前就要想清楚你是只需要“看到路”还是需要“使用路模型”。如果是后者建议尽早切换到Splines或程序化Mesh方向。3. Splines曲线道路制作的核心原理3.1 Splines为什么是道路制作的最优解之一Unity 2022.2之后的版本把Splines包作为官方工具提供用于在场景中创建可编辑的样条曲线。相比Terrain笔刷Splines的最大价值在于道路被抽象成了路径数据。你不再直接修改像素而是调整一条曲线上的控制点路面Mesh、车辆路径、路边设施都可以依附这条曲线生成。举个例子你有一段山路需要绕开陡坡。Terrain方案要沿着山体反复刷权重每刷一次不满意又要重来Splines方案只需要移动曲线的控制点让路径贴合山腰所有依赖路径的物体自动跟随。这种“一次建模、多次复用”的特性正是大场景制作最需要的。3.2 Splines包的基础使用方法在Package Manager中安装Splines包后可以在GameObject菜单下找到Spline工具。先在场景中创建一条Spline然后沿着Path的切线方向拖出节点通过调整节点的Tangent控制弧度。这里特别提醒一个新手易错点Spline的默认坐标系是局部坐标当你把Spline挂到某个父物体下时如果父物体有旋转缩放道路Mesh的生成必须考虑矩阵变换。很多人手动生成道路时发现Mesh方向错乱多半是这个原因。4. 基于Splines程序化生成道路Mesh4.1 程序化生成的整体思路我们要做的事情可以拆成四步第一沿Spline路径等间距采样一系列点拿到每个采样点的位置和切线方向。 第二根据当前点切线方向计算出与切线垂直的左右方向沿左右方向扩展出让道路宽度。 第三把这排顶点按顺序连接成三角形网格并给顶点赋予U坐标让道路纹理沿着前进方向延伸。 第四根据地形高度或自定义高度规则对采样点进行Y轴修正确保道路贴合地面。这里的难点集中在第二步曲线转弯处如果直接用线性垂直方向计算左右边道路内外侧会出现不合理的宽度变化。更准确的做法是使用曲线的法向量与副法向量来构建局部坐标系这样弯道处的道路宽度才能保持均匀。4.2 完整道路生成器代码下面是一段基于Unity Splines包的核心代码。它从Spline采样路径点生成一个宽为roadWidth的道路Mesh并自动生成UV。// 文件路径Assets/Scripts/SplineRoadGenerator.cs using System.Collections.Generic; using UnityEngine; using UnityEngine.Splines; [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] public class SplineRoadGenerator : MonoBehaviour { public SplineContainer splineContainer; public float roadWidth 6f; public int segmentsPerSpline 50; public float heightOffset 0.02f; private MeshFilter meshFilter; void Start() { meshFilter GetComponentMeshFilter(); BuildRoadMesh(); } [ContextMenu(Rebuild Road)] public void BuildRoadMesh() { if (splineContainer null) { Debug.LogError(SplineContainer is not assigned.); return; } Spline spline splineContainer.Spline; int sampleCount Mathf.Max(2, segmentsPerSpline); ListVector3 leftVertices new ListVector3(); ListVector3 rightVertices new ListVector3(); ListVector3 centers new ListVector3(); for (int i 0; i sampleCount; i) { float t (float)i / sampleCount; Vector3 positionLocal spline.EvaluatePosition(t); Vector3 positionWorld splineContainer.transform.TransformPoint(positionLocal); Vector3 tangentLocal spline.EvaluateTangent(t); Vector3 tangentWorld splineContainer.transform.TransformDirection(tangentLocal); tangentWorld.y 0f; tangentWorld.Normalize(); if (tangentWorld.sqrMagnitude 0.0001f) { tangentWorld Vector3.forward; } Vector3 right Vector3.Cross(tangentWorld, Vector3.up).normalized; Vector3 leftSide positionWorld - right * (roadWidth * 0.5f); Vector3 rightSide positionWorld right * (roadWidth * 0.5f); leftSide.y heightOffset; rightSide.y heightOffset; leftVertices.Add(leftSide); rightVertices.Add(rightSide); centers.Add(positionWorld); } ListVector3 vertices new ListVector3(); Listint triangles new Listint(); ListVector2 uvs new ListVector2(); float totalLength 0f; float[] distances new float[sampleCount 1]; distances[0] 0f; for (int i 1; i sampleCount; i) { float segmentLength Vector3.Distance(centers[i], centers[i - 1]); totalLength segmentLength; distances[i] totalLength; } for (int i 0; i sampleCount; i) { float u totalLength 0.0001f ? distances[i] / totalLength : (float)i / sampleCount; vertices.Add(leftVertices[i]); vertices.Add(rightVertices[i]); uvs.Add(new Vector2(0f, u)); uvs.Add(new Vector2(1f, u)); } for (int i 0; i sampleCount; i) { int baseIndex i * 2; triangles.Add(baseIndex); triangles.Add(baseIndex 1); triangles.Add(baseIndex 2); triangles.Add(baseIndex 1); triangles.Add(baseIndex 3); triangles.Add(baseIndex 2); } Mesh mesh new Mesh(); mesh.name RoadMeshGenerated; mesh.vertices vertices.ToArray(); mesh.triangles triangles.ToArray(); mesh.uv uvs.ToArray(); mesh.RecalculateNormals(); mesh.RecalculateBounds(); meshFilter GetComponentMeshFilter(); meshFilter.sharedMesh mesh; } }把这段代码挂在一个带MeshRenderer和MeshFilter的空物体上将场景中的SplineContainer拖到SplineContainer字段点击Rebuild Road即可生成道路Mesh。代码中heightOffset的作用是让路面略微高于地形平面避免Z-fighting闪烁。4.3 代码关键细节解读先看EvaluatePosition和EvaluateTangent。这是Splines包最核心的两个API前者返回曲线上某点的局部坐标后者返回局部切线。两者都必须通过TransformPoint和TransformDirection转换到世界空间否则遇到旋转过的Spline容器就会错位。再看UV生成方式。道路纹理最怕“条纹状拉伸”原因是U方向在直线段和弯道段使用了完全相同的0到1映射。这段代码的做法是沿曲线累计长度把V坐标映射为当前点到起点的距离占比U坐标固定为0和1。这样道路纹理会沿着前进方向均匀展开弯道处的纹理不会因为弧长不同而扭曲。还需要注意一个容易被忽视的细节三角形索引顺序。这里使用左手定则下的逆时针顺序即从上方看Mesh时三角形面朝上。如果生成的Mesh在场景里看起来发黑或无法显示优先检查三角形绕序是否反了把索引顺序调换即可。4.4 让道路贴合地形起伏上面的代码默认把道路生成在一个水平面上没有处理地形高度。要让道路贴合地形有两种常见思路。第一种是在采样时用Terrain.SampleHeight查询地形高度把采样点的Y坐标改成地形高度。这种方式简单可靠适合地形起伏不特别剧烈的场景。// 伪代码片段通常放在 BuildRoadMesh 中采样循环里 float terrainHeight terrain.SampleHeight(positionWorld); Vector3 snappedWorld positionWorld; snappedWorld.y terrainHeight;需要注意直接用SampleHeight把道路粘到地形上在陡坡或悬崖地段会得到锯齿状道路因为采样点之间是线性插值的道路会像“贴在墙面上的胶带”。对于这种路段建议在生成后对Mesh做一次平滑或者在采样前对地形高度序列做低通滤波。第二种是使用自定义的曲线节点高度不依赖地形高度。这种方案适合桥梁、高架路。本质上是把道路的Y轴完全交给美术或策划在Spline节点上直接设置高度信息。4.5 交叉路口的程序化处理交叉路口是程序化生成道路里最复杂的问题。我的建议是对于项目初期版本不要把交叉路口写进道路Mesh生成器而是用独立的路口Mesh拼接。最简单的实现思路是在路口中心预留一个多边形区域四条道路各自在路口边界处截断再用一个圆滑的路口平面Mesh把道路出口连接起来。在你生成道路Mesh时只需在接近路口的最后一个采样点处生成垂直于道路的收口面然后交给路口的专用脚本来处理拼接。这个方案的优点是每条道路本身保持简单逻辑路口逻辑独立演进不会陷入“改了路口拖累整条路”的死局。5. 使用第三方工具的接地气经验5.1 EasyRoads3D适合什么项目市场上常见的道路插件有EasyRoads3D、Road Architect等。它们的核心思路类似通过编辑曲线生成道路模型并提供交叉路口的自动化处理。从实际项目经验看EasyRoads3D的成熟度较高适合中小团队的场景项目。它能很好地处理交叉路口附带多种道路样式和护栏、路肩等细节物体并且可以为NavMesh自动生成可寻路区域。缺点是插件引入后如果团队只有一两个人接触过别人接手项目时对自定义组件不熟悉反而增加了协作成本。我的判断是小团队做项目、大团队做工具。项目本身是艺术表现优先、追求效果快速落地用插件合理如果你的产品定位是底层引擎工具或技术组件自己沉淀一套道路生成工具更值得。5.2 插件和自研的道路共存问题很多项目会出现“既有插件道路又有自研道路”的情况。这种混合方案最容易出现两类问题纹理坐标无法统一、道路边缘高度不匹配。如果无法避免混合使用建议把道路数据层和表现层分离。所有道路的关键路径信息都存成独立的ScriptableObject里面记录节点坐标、宽度和道路类型。渲染用插件还是自研Mesh都从这份数据生成。这样哪怕后续换插件也不会牵一发动全身。6. 道路材质的正确配置6.1 道路Shader选择与参数道路材质和普通场景材质不太一样。道路表面通常需要控制在较低的光泽度范围同时要保证远处看没有明显的摩尔纹近处看又有足够的细节。如果项目使用URP或HDRP建议使用URP自带或HDRP自带的Lit Shader将Smoothness调低到0.1到0.3之间同时开启Normal Scale并加入一张细节法线贴图。道路的BaseMap要选用无缝道路纹理最好带沥青颗粒和细微裂纹的写实风格纹理。在纯文本渲染管线Built-in RP中可以直接使用Standard Shader把Metallic设为0Smoothness保持在0.2附近。6.2 道路边缘过渡处理新手最容易忽略的是道路边缘。一条好路从土路、碎石路到沥青路面之间应该有自然的过渡带。如果只有两层纹理硬切换远处看非常假。推荐的做法是在道路的两侧各增加一条肩带Mesh宽度约为道路宽度的10%到20%材质使用碎石或泥土纹理。这条肩带可以使用和道路相同的Spline采样逻辑生成只是宽度更大、高度略低这样就能和中间主路面形成自然的边缘层次。7. 运行结果与效果验证7.1 编辑器内验证代码挂载完成后点击Inspector中的Rebuild Road按钮观察Scene视图中的道路Mesh。好的结果是道路沿Spline路径平滑延伸弯道处左右边缘宽度均匀没有内折和塌陷。接着给道路Mesh指定一个带纹理的材质检查纹理方向是否正确。道路纹理的条纹应当沿前进方向铺开不会在弯道处出现扇形扭曲。如果出现箭头状纹理拉伸多半是UV方向或U值分配出错。7.2 运行时性能验证生成道路Mesh后可以在Game视图打开Frame Profiler观察Mesh的顶点数和三角形数量。对于常规的大场景道路系统单条道路的三角形数量建议控制在几千级别如果达到数万说明采样密度过高需要调低segmentsPerSpline。还可以利用Mesh的sharedMesh属性做GPU实例化。同一路段的Mesh如果不打算动态修改可以直接共享给多个相同外观的路段极大减少Draw Call。7.3 是否真的可靠的判断标准判断一套道路方案是否可靠有一个非常实用的标准修改道路路径后从生成到看到结果的时间开销与步骤数。Terrain手刷方案改一次要几分钟Splines程序化方案改一个控制点然后一键刷新只要几秒钟。如果你的项目里道路调整频繁后者的效率优势会极其明显。8. 常见问题与排查思路问题现象可能原因排查方式解决方案道路Mesh显示为黑色三角形绕序反了在Scene视图用Wireframe模式查看法线朝向调整三角形索引顺序统一改为逆时针道路纹理在弯道处扭曲采样点间距不均匀或UV未按弧长映射检查Spline采样t与实际距离的关系按累计距离分配UV而非按t值比例分配道路悬浮或陷入地形Y坐标未贴合地形检查生成时是否调用SampleHeight使用地形高度并加微小偏移Spline节点改了但Mesh不变未触发重建检查ContextMenu和绑定事件在编辑器脚本的OnValidate里自动调用BuildRoadMesh场景中有多个Spline容器生成的Mesh错乱局部坐标和世界坐标混用检查TransformPoint调用统一使用世界坐标进行顶点计算路宽在弯道内侧变窄仅用切线叉乘上向量得出副法线观察弯道两侧顶点距离改用曲线法向量副法向量构建局部坐标系第一行的问题是我见过最多的。很多初学者把顶点存成List之后只是按顺序生成了三角形索引忘记了正面朝向的要求最后在Scene视图里看到网格明明存在却完全黑面。遇到这种问题第一反应不要怀疑Material先把三角形索引顺序反过来试一次。9. 最佳实践与工程建议9.1 道路数据分层任何时候都建议把道路分为三个层数据层、表现层、逻辑层。数据层是道路的根本建议用ScriptableObject保存每个路段的路点列表、宽度、纹理、类型。表现层由Splines、Mesh、材质组成负责把数据渲染出来。逻辑层则是车辆AI、行人、NavMesh等功能的承载。这样分层后策划调整道路路径不需要碰代码美术更换路面材质也不会影响逻辑判定。项目后期维护成本会大幅下降。9.2 批量生成与LOD优化大场景中道路可能长达数公里一次性把整条路生成成一个大Mesh会带来两个问题一是一次性计算开销大二是无法做局部卸载。建议把道路按长度分段每段约50到100米生成独立Mesh。然后为每段生成两个LOD级别远距离使用低采样数量、低分辨率纹理的简化Mesh近距离使用精细Mesh。这样既保证了远景性能又保留了近景质感。9.3 版本兼容与团队协作如果你的项目还在使用旧版本Unity优先检查Splines包是否可用。如果不可用可以使用第三方曲线插件如Bezier Path Creator再套用本文的程序化Mesh生成思路原理是完全相同的。团队协作时道路生成脚本应该统一代码风格和命名规范。例如每个道路节点头部统一使用Road_前缀材质统一放入Assets/Art/Road/目录纹理张量设置统一在项目设置里定好。9.4 关于NavMesh的集成道路Mesh生成好后不要忘记把道路区域纳入NavMesh烘焙范围。最稳妥的做法是在道路Mesh的顶层添加一个NavMeshSurface组件设置Area类型为Walkable然后在Bake之前确认道路Mesh与地形之间没有过大的间隙。否则角色的寻路路径会出现绕过道路的诡异行为。10. 总结与后续学习方向道路制作在Unity场景搭建中属于“天花板不高、水很深”的环节。你可以很快学会一种方法但各种方法之间的取舍、细节优化和工程化封装才是决定项目质量的关键。这篇文章真正想传达的不是某一种方案的完整实现步骤而是希望你能够建立“数据驱动道路”的意识。如果你目前还在用Terrain笔刷做所有道路建议先实践一下文中的TerrainRoadPainter脚本感受代码控制纹理铺设的思路。然后尝试用Splines包生成最简单的道路Mesh掌握曲线采样和UV映射。最后在真实项目里慢慢加入地形贴合、交叉路口处理和LOD优化你就会发现道路制作从“一次性的苦力活”变成了“可复用的生成流程”。接下来值得深入的方向包括多路口动态拼接算法、立体交叉口与高架桥的道路高度规划、基于道路数据的交通流模拟以及道路Mesh与植被、建筑摆放的自动布局。掌握这些以后你看到的Unity场景道路就不再是一个静态模型而是一整套可以反馈、迭代的数据系统。建议收藏本文下次做场景时对照思路来过一遍遇到具体问题也知道去哪查。
网站建设高端定制企业官网