Unity程序化道路生成:C#样条曲线Mesh构建实战
发布时间:2026/10/1 1:18:29来源:尧图网络
在 Unity 里做一条路方法远不止一种用 Terrain 画笔在地形上抹用 ProBuilder 手动搭路面用第三方样条插件自动拉路或者自己写 C# 根据控制点生成一段完整的道路 Mesh。这些方案没有绝对的好坏只有适不适合当前项目。如果你的团队正在做开放世界地图、城市街道、赛车赛道、数字孪生或者需要运行时动态铺路的玩法道路制作就不仅仅是一个美术摆放问题而是牵扯到模型生成、导航烘焙、性能优化和数据管理的技术活。这篇文章会把道路制作拆成几种典型方案先用一张表格把各自的硬件要求、工作量和适用场景讲清楚然后重点演示如何用 C# 样条曲线生成一条可贴材质、可走 NavMesh、可动态调整的道路。整个过程不需要购买任何额外插件也不需要独立显卡只要有一台能正常开发 Unity 的电脑就行。你会看到这段核心生成代码、UV 展开思路、批量生成多段道路的脚本接口以及一份可以直接照着用的功能测试清单。1. 核心能力速览下面是 Unity 场景道路制作最常见的四种方案对比。实际选型时重点看你的道路是否规则、是否需要程序化生成、是否要大量道路以及团队是否能接受第三方插件的版权和版本维护成本。方案实现方式是否依赖第三方插件适合场景制作成本运行时生成Terrain 画笔绘制内置 Terrain 工具 纹理绘制否开阔地形、森林土路、山路低不支持ProBuilder 手动建模Package Manager 安装 ProBuilder 后拼接路面内置扩展包城市街道、高架桥、规整路口中不支持第三方样条插件Asset Store 导入道路插件拖控制点生成路网是复杂匝道、交叉口、大型道路系统低到中按插件功能自研样条 Mesh 脚本C# 计算样条曲线并生成 MeshFilter否程序化地形、动态道路、数字孪生中高支持从成本角度看Terrain 适合快速出原型ProBuilder 适合中等复杂度道路第三方插件适合多匝道和复杂互通而自研样条脚本的初期成本最高但换来的是完全可控的运行时生成能力。如果你的道路数量和位置会随着玩家行为变化例如沙盒建造、实时仿真或随机地图生成自研方案最稳妥。2. 适用场景与使用边界道路制作主要用于游戏场景、虚拟仿真和数字孪生项目。在游戏场景里道路需要满足视觉、物理碰撞和 AI 寻路三个要求在数字孪生里道路通常要对应真实地理坐标和交通规则在程序化生成项目里道路往往是在运行时根据数据源异步生成的。针对不同用途要注意以下边界道路的美观性不等于物理正确性。美术上一条平整的沥青路如果不处理碰撞体车辆会直接穿模。NavMesh 并不自动跟随 Mesh 生成。道路生成后需要设置 Navigation Static再执行 NavMesh 烘焙。如果使用真实地图数据必须确认路网和卫星影像的版权与授权范围不允许直接使用未经授权的商业地图切片。第三方道路插件的美化和脚本接口通常受授权限制商用前要仔细阅读条款。点云、地形高程数据、交通流量数据也可能涉及隐私或地区治理规范不能在公开项目里随意上线。简单说道路制作适合需要有“路网”感的场景不适合做超高精度公路设计。真正的道路工程设计需要专业 BIM 和 GIS 工具Unity 更适合把规划好的路网以可视化或交互方式呈现。3. 环境准备与前置条件自研样条道路脚本没有特殊硬件门槛。普通 Windows、macOS 或 Linux 开发机都可以建议使用 64 位系统。内存 8GB 以上更顺畅但这不是硬性要求。真正需要关注的是 Unity Editor 版本和项目渲染管线。先做一份环境检查清单安装 Unity Editor推荐使用 LTS 版本2019 LTS 及以上均可示例代码兼容 C# 6。项目类型建议选择 3D Core如果使用 URP 或 HDRP需要确认 Shader 是否支持给定材质。在 Project 窗口中创建Assets/Scripts目录用于存放道路生成脚本。如果有使用真实地形数据提前导入 Terrain 工具或原始高度图。检查窗口打开后确保没有编译错误再继续测试。磁盘预留至少 10GB 空间用于编译缓存、地形纹理和构建产物。不需要额外安装 CUDA、Python 或 Docker这些是给 AI 模型准备的东西和 Unity 道路制作无关。下面核心脚本会使用 UnityEngine 内置 API不需要外部依赖。4. 安装部署与启动方式4.1 创建 Unity 项目打开 Unity Hub新建项目模板选 3D Core。项目创建后默认场景中带有 Directional Light 和一个空 Lighting 环境。可以在Hierarchy窗口中创建一个 Plane 或 Terrain 作为测试地面。不需要在场景里摆放复杂模型。先建一个空场景后面所有道路都通过脚本动态生成这样能最快验证代码逻辑。4.2 安装 ProBuilder如果要做城市规整道路ProBuilder 是 Unity 官方的内置扩展包。安装方式打开Window Package Manager。在包列表中找到 ProBuilder或者通过左上角搜索。点击 Install等待导入完成。菜单栏出现Tools ProBuilder后表示安装成功。ProBuilder 不是必须安装的。如果你直接使用自研样条脚本可以完全跳过这一步。这个操作主要是给需要手动精细调整路面的项目准备的。4.3 创建测试场景结构在场景里创建一个空 GameObject命名为RoadTest用来存放后续生成的道路子物体。再创建一个Ground空物体并在其下创建一个 Plane用于模拟地面。这样道路和地形分离方便后面观察道路是否悬浮。5. 功能测试与效果验证5.1 Terrain 道路绘制测试如果你的需求只是视觉上的一条泥土路或草地路最快的方式是使用 Terrain。测试步骤在场景中创建Terrain对象。在 Inspector 中选中 Paint Texture导入两张纹理一张草地、一张道路。新建一个 Terrain Layer把道路纹理放上去然后使用 Brush 沿目标路径绘制。绘制完成后在 GameView 中从角色视角检查道路纹理是否与地形贴合。判断标准是道路纹理不撕裂、不出现明显接缝。常见问题是 Brush 硬度太高导致边缘生硬可以降低 Brush 的 Opacity重复涂抹几次。这种方式的好处是零脚本成本缺点是路网高程完全依赖 Terrain 高度图无法直接生成精确的交叉口碰撞体。5.2 样条道路 Mesh 生成测试这是本文最核心的验证环节。用 Catmull-Rom 样条计算道路中心线按横向偏移生成左右两边顶点然后构建三角形网格。好处是可以完全程序化生成道路并且每一步都可调试。先创建一个静态工具类用于样条计算using UnityEngine; public static class SplineUtils { public static Vector3 GetCatmullRomPoint(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { float t2 t * t; float t3 t2 * t; return 0.5f * ( 2f * p1 (-p0 p2) * t (2f * p0 - 5f * p1 4f * p2 - p3) * t2 (-p0 3f * p1 - 3f * p2 p3) * t3 ); } public static Vector3 GetCatmullRomTangent(Vector3 p0, Vector3 p1, Vector3 p2, Vector3 p3, float t) { Vector3 left GetCatmullRomPoint(p0, p1, p2, p3, Mathf.Max(0f, t - 0.01f)); Vector3 right GetCatmullRomPoint(p0, p1, p2, p3, Mathf.Min(1f, t 0.01f)); return (right - left).normalized; } }接下来生成道路 Mesh。为了让网格方向一致每个采样点根据切线方向用叉积计算路面的左右边缘点public static Mesh BuildRoadMesh(Vector3[] centers, float roadWidth) { if (centers.Length 2) { return null; } int vertexCount centers.Length * 2; Vector3[] vertices new Vector3[vertexCount]; Vector2[] uvs new Vector2[vertexCount]; int[] triangles new int[(centers.Length - 1) * 6]; float distance 0f; for (int i 0; i centers.Length; i) { Vector3 direction; if (i 0) { direction centers[i 1] - centers[i]; } else if (i centers.Length - 1) { direction centers[i] - centers[i - 1]; } else { direction centers[i 1] - centers[i - 1]; } Vector3 tangent direction.normalized; Vector3 right Vector3.Cross(Vector3.up, tangent).normalized * roadWidth * 0.5f; Vector3 left -right; vertices[i * 2] centers[i] left; vertices[i * 2 1] centers[i] right; if (i 0) { distance Vector3.Distance(centers[i], centers[i - 1]); } uvs[i * 2] new Vector2(0f, distance); uvs[i * 2 1] new Vector2(1f, distance); } int triangleIndex 0; for (int i 0; i centers.Length - 1; i) { int a i * 2; int b i * 2 1; int c i * 2 2; int d i * 2 3; triangles[triangleIndex] a; triangles[triangleIndex] c; triangles[triangleIndex] b; triangles[triangleIndex] b; triangles[triangleIndex] c; triangles[triangleIndex] d; } Mesh mesh new Mesh(); mesh.name GeneratedRoad; mesh.vertices vertices; mesh.uv uvs; mesh.triangles triangles; mesh.RecalculateNormals(); mesh.RecalculateBounds(); return mesh; }把上面两个方法放在同一个类里然后创建一个测试脚本挂到RoadTest对象上using UnityEngine; public class RoadTest : MonoBehaviour { public Material roadMaterial; public float roadWidth 6f; void Start() { Vector3[] centers { new Vector3(-20f, 0f, 0f), new Vector3(-10f, 0f, 0f), new Vector3(0f, 0f, 0f), new Vector3(10f, 0f, 0f), new Vector3(20f, 0f, 0f) }; Mesh roadMesh SplineUtils.BuildRoadMesh(centers, roadWidth); GameObject roadGO new GameObject(Road_Straight); roadGO.transform.SetParent(transform); MeshFilter meshFilter roadGO.AddComponentMeshFilter(); MeshRenderer meshRenderer roadGO.AddComponentMeshRenderer(); meshFilter.sharedMesh roadMesh; meshRenderer.sharedMaterial roadMaterial; } }点击 Play 后场景里应出现一条沿 X 轴的平面道路。用 Scene 视图旋转视角观察道路两侧边缘是否齐整。如果看不到 Mesh优先检查两个地方一是centers数组是否为空二是材质 Shader 是否正确。常见失败原因是三角形绕序方向不一致。可以打开 Scene 视图的Wireframe显示模式查看网格三角面是否可见。如果背面显示为半透明说明三角形绕序反了把BuildRoadMesh中的三角形索引顺序对应调换即可。5.3 路面材质与 UV 检查上面代码里已经写了 UV 生成逻辑。UV 水平方向是 0 到 1垂直方向是道路累计长度。这样可以让沥青纹理沿道路方向自然重复。测试时新建一个材质Shader 选择Standard或 URP 的Lit将一张道路贴图拖到 Albedo 上。然后挂到RoadTest的 roadMaterial 字段重新运行场景。观察路面贴图是否沿着道路方向平铺而不是横向拉伸。如果贴图平铺太密或太稀疏可以在BuildRoadMesh的 UV 计算中乘一个平铺系数。例如在distance上乘0.1f来控制密度水平方向也可以改成带偏移的 UV用于制作有连续斑马线或车道线的道路。5.4 碰撞与车辆通行测试道路只有视觉还不够。要让车辆在路面上行驶需要给道路对象添加碰撞体。最简单的是MeshCollider把生成的 Mesh 赋给它。注意MeshCollider对于车辆轮胎的物理反馈取决于网格精度低多边形道路会有明显颠簸需要使用平滑采样点和更多细分。在RoadTest脚本的Start方法中加一行MeshCollider collider roadGO.AddComponentMeshCollider(); collider.sharedMesh roadMesh;重新运行后在场景中放一个 Capsule 或简单车模给一个向前的速度观察它是否沿道路滑动。如果胶囊从路面边缘掉下去说明路宽不够或者道路中心线位置偏移。不要忘记把胶囊的碰撞体放在道路 Mesh 的 Surface 上方一点否则边界刚好接触时也容易穿模。5.5 NavMesh 导航烘焙测试如果游戏需要 NPC 或玩家自动寻路道路生成后要做额外两步。首先把生成的RoadTest对象设为Navigation Static即选中对象后在 Inspector 右上角勾选 Navigation Static。然后打开Window AI Navigation面板进入 Bake 页签把 Agent Radius 调成小于道路宽度的一半点击 Bake。烘焙完成后在 Scene 视图里可以看到蓝色区域。蓝色区域必须覆盖道路表面。常见的排查方向道路 Mesh 没有碰撞体烘焙时认为不可行走。Agent Radius 设置过大路面窄于两个 Radius 导致无法通行。道路对象没有加入足够大的Baking Area烘焙区域太小。这一步验证成功后道路才能让 NPC 使用 NavMeshAgent 自动走向目标点。6. 道路生成的 C# 接口与批量任务和 HTTP API 无关这里讲的是 Unity 内的 C# 接口和批量任务模型。通过封装一个RoadGenerator类可以接收配置并批量生成多条道路适合读取策划配置表或 JSON 批量铺路。using System.Collections.Generic; using UnityEngine; public class RoadGenerator : MonoBehaviour { [System.Serializable] public class RoadConfig { public string roadName; public Vector3[] controlPoints; public float width 6f; public int samplesPerSegment 10; } public ListRoadConfig configs new ListRoadConfig(); public Material roadMaterial; public bool buildCollider true; [ContextMenu(Rebuild All Roads)] public void RebuildAllRoads() { ClearGeneratedRoads(); foreach (RoadConfig config in configs) { GenerateRoad(config); } } public void ClearGeneratedRoads() { for (int i transform.childCount - 1; i 0; i--) { DestroyImmediate(transform.GetChild(i).gameObject); } } public void GenerateRoad(RoadConfig config) { ListVector3 centerLine new ListVector3(); for (int i 0; i 3 config.controlPoints.Length; i) { Vector3 p0 config.controlPoints[i]; Vector3 p1 config.controlPoints[i 1]; Vector3 p2 config.controlPoints[i 2]; Vector3 p3 config.controlPoints[i 3]; for (int j 0; j config.samplesPerSegment; j) { float t j / (float)config.samplesPerSegment; centerLine.Add(SplineUtils.GetCatmullRomPoint(p0, p1, p2, p3, t)); } } centerLine.Add(config.controlPoints[config.controlPoints.Length - 1]); Mesh roadMesh SplineUtils.BuildRoadMesh(centerLine.ToArray(), config.width); if (roadMesh null) { return; } GameObject go new GameObject(config.roadName); go.transform.SetParent(transform, false); MeshFilter filter go.AddComponentMeshFilter(); MeshRenderer renderer go.AddComponentMeshRenderer(); filter.sharedMesh roadMesh; renderer.sharedMaterial roadMaterial; if (buildCollider) { MeshCollider mc go.AddComponentMeshCollider(); mc.sharedMesh roadMesh; } } }使用方式是在场景中挂一个RoadGenerator对象然后在 Inspector 里添加多条道路配置配置controlPoints数组。通过右键菜单或代码调用RebuildAllRoads()可以统一重建。这种批量任务在执行长距离道路生成时建议放到一个协程里分段执行避免一帧内大量Mesh计算导致卡顿。如果是数据驱动的项目可以用 JSON 保存所有控制点。下面是一个简化配置示例实际字段需要和你的脚本序列化匹配{ roads: [ { roadName: MainStreet, controlPoints: [ { x: 0, y: 0, z: 0 }, { x: 20, y: 0, z: 0 }, { x: 40, y: 0, z: 10 } ], width: 6, samplesPerSegment: 10 }, { roadName: SideRoad, controlPoints: [ { x: 20, y: 0, z: 5 }, { x: 30, y: 0, z: 15 }, { x: 50, y: 0, z: 20 } ], width: 4, samplesPerSegment: 8 } ] }批量任务失败时常见的处理是在GenerateRoad方法外再加一个try-catch记录失败的道路名称和控制点数量然后把失败项重新加入队列继续生成下一条。这样可以避免一条坏数据导致整批道路全部回滚。7. 资源占用与性能观察道路生成对性能的影响主要来自三角形数量、Draw Call 数量、物理碰撞体复杂度和运行时内存分配。在编辑器里打开Window Analysis Profiler运行场景观察 CPU 模块中Scripts部分。如果生成道路的瞬间出现明显峰值说明一帧内创建了太多 Mesh。可以通过抽样采样和分段生成来降低峰值。三角形的具体数量取决于采样密度和路段长度。每条路段由若干个四边形组成一个四边形等于两个三角形。采样点越多道路越平滑但顶点和三角形数量也会线性上升。更合理的做法是在控制点之间按弧度自动调整采样点数量直线部分少采样弯道部分多采样。如果只是静态道路可以在编辑器阶段把生成好的 Mesh 保存为.asset文件运行时只读引用不重新生成。这样可以省掉脚本执行时间和运行时创建 Mesh 的开销。对于大量道路还要考虑 Draw Call。多个道路子物体共用同一个材质时如果它们的网格不合并Unity 会按子物体数量产生额外 Draw Call。使用Mesh.CombineMeshes把同一材质的道路合并成一个 Mesh可以把多个 Draw Call 合并成一个。合并后碰撞体也需要重新生成否则车辆只会和合并前的地面发生碰撞。渲染管线上URP 和 HDRP 的材质参数和光照模式会影响性能。在移动设备上更推荐使用 Mobile 或 URP Simple Lit 这类轻量 Shader避免大量实时阴影覆盖中远距离道路。把长距离道路拆成近中远三段远处用低多边形代理 Mesh近处使用完整 Mesh是控制场景道路性能的常用手段。8. 常见问题与排查方法下面是 Unity 场景道路制作过程中最常遇到的几类问题以及排查方向。问题现象可能原因排查方式解决方案道路 Mesh 在场景中不可见Material Shader 不正确、三角形绕序反打开 Wireframe 检查三角面调整三角形索引顺序或更换 Shader道路纹理严重拉伸UV 没有按距离生成或平铺系数过小查看 UV 编辑器中的和 out UV 分布在 BuildRoadMesh 中更新 UV 坐标车辆从道路上掉落没有碰撞体或路宽过窄检查 MeshCollider 和 Inspector 的 Rigidbody添加 MeshCollider 并调整路宽道路与地形高度不匹配道路顶点 Y 值固定为 0使用 Terrain.SampleHeight 或 Raycast 获取地形高度让控制点 Y 跟随地形高度NavMesh 没有生成对象未设置 Navigation Static检查 Navigation Static 和 Bake 区域勾选 Navigation Static 后重新烘焙批量生成时卡顿一帧内生成大量 Mesh使用 Profiler 查看 CPU 峰值改成协程分段生成或提前导出 asset第三方插件导入报错插件与 Unity 版本不兼容查看 Console 报错和插件文档使用适配当前版本的插件或降版本运行时生成道路后无法保存运行时创建的 Mesh 不会自动进入场景文件使用编辑器菜单生成并保存为 asset打包前执行一次静态构建如果脚本一直报错第一步先看 Console 窗口的堆栈是空引用还是MeshFilter未添加。把生成逻辑拆成BuildCenterLine、BuildRoadMesh、AttachRendering三个方法定位问题会快很多。9. 最佳实践与使用建议先做最小原型。不要一上来就接真实地图路网先用几个控制点生成一条直线路确认 Mesh 可见。再把控制点改成曲线验证拐弯处网格是否连续。最后再加入地形贴合和碰撞体。控制点建议沿行进方向排列并且尽量等间距。控制点间距过大的时候样条可能在拐弯处出现尖角可以增加samplesPerSegment让中心线更平滑。如果道路宽度比较宽弯道内侧会出现网格折叠需要在拐弯处额外细分。道路生成后一定要加足够的碰撞体。车辆物理不稳定的情况绝大多数不是 Mesh 本身的问题而是碰撞体和路面 Mesh 的缝隙过大。道路生成时的所有顶点都基于 Y0 平面如果地形有起伏就要采样地形高度而不是直接把 Y 设为 0。对于版权和安全合规尤其要注意道路两侧的绿化、指示牌、护栏、地标模型如果使用第三方资源确认资源许可。真实城市路网的坐标数据可能涉及敏感地理信息不要直接用于公开项目。数字孪生项目如果包含建筑外貌、人物身份、车辆牌照等信息发布前必须做脱敏和授权。涉及交通仿真、自动驾驶测试时只能在合规的测试环境和数据集上进行不能伪造真实交通事件。批量任务方面不要把道路数据直接硬编码在场景里。建议统一的配置资产或 JSON 文件方便策划调整和版本管理。每条道路使用独立 GameObject 节点命名规范明确例如Road_001、Road_Cross1、Road_Ramp_A这样后期排查问题能快速定位。10. 总结与下一步道路制作最值得投入的方向是自研样条 Mesh 生成因为一旦脚本跑通后面扩展开阔道路、交叉口、高架桥匝道都可以靠数据驱动生成不依赖手摆。最先应该验证的功能是道路 Mesh 能否正常渲染、能否贴出连续纹理、能否挂上碰撞体让车辆通行。最容易踩的坑有两个一是道路网格绕序错误导致看不见二是生成道路后没有设置 Navigation Static导致 NPC 寻路失效。建议把第 5 节的测试流程完整跑一遍再进入批量生成阶段。后续可以继续扩展几个方向用贝塞尔曲线替代 Catmull-Rom获得更灵活的切线和法线控制增加红绿灯和路牌逻辑把道路生成和程序化城市生成结合起来或者把控制点导入导出做成编辑器扩展让策划在不写代码的情况下编辑路网。整个方案的门槛不高关键是把第一节的思路吃透然后基于你自己的项目约束做取舍。建议收藏备用下次做地图时直接按这套流程来。
网站建设高端定制企业官网