基于OpenStreetMap与Godot的程序化3D城市生成实战 1. 项目概述从地图数据到游戏世界的桥梁最近在做一个开放世界小游戏的Demo需要快速搭建一个看起来像模像样的城市街区环境。手动在Blender里拉方块、铺道路效率太低而且缺乏真实感。直接用现成的3D城市资产包风格不匹配且缺乏程序化生成的灵活性。于是我把目光投向了OpenStreetMapOSM——这个全球志愿者共同维护的免费开源地图数据库。它包含了海量的道路、建筑、水系等地理信息简直就是一座等待被“游戏化”的宝库。结合Godot引擎强大的3D渲染和脚本能力我们完全可以将这些二维的地理数据快速、自动地转换为一个可交互、可探索的3D游戏场景。这不仅仅是“偷懒”更是一种高效的原型设计和内容生产管线。这个流程的核心价值在于“数据驱动”。你不再需要从零开始雕刻每一个街角而是通过处理真实世界或你自定义的地图数据批量生成场景基础框架。这对于需要真实城市布局的驾驶模拟、开放世界探索、策略游戏地图甚至是AR/VR应用的原型验证都极具意义。整个过程可以分解为几个清晰的步骤获取OSM数据、解析并提取我们关心的要素如道路轮廓、将这些2D多边形转换为3D网格、最后在Godot中赋予材质并设置碰撞。下面我将以一个具体的街区为例带你走完全程并附上可以直接运行的GDScript代码。2. 核心思路与工具链选型在动手之前我们需要明确技术路线。整个管线可以概括为“数据获取 - 数据处理 - 引擎导入”三个阶段。每个阶段都有多种工具可选我的选择基于Godot生态的亲和度、开发效率以及学习成本。2.1 为什么选择OpenStreetMap与Godot的组合首先OpenStreetMap是免费的其数据采用ODbL协议只要署名即可在商业项目中使用这对独立开发者和小团队非常友好。其次OSM数据结构化程度高以XML或PBF格式存储包含了丰富的标签tags例如highwayresidential表示居民区道路buildingyes表示建筑。我们可以根据这些标签精确筛选出需要生成3D模型的要素。选择Godot引擎则是因为其轻量、开源以及用GDScript或C#进行快速原型开发的便利性。Godot的MeshInstance节点、SurfaceTool类以及ArrayMesh资源为我们从代码动态生成几何体提供了完美的底层支持。相比一些重型引擎在Godot里实现自定义网格生成逻辑更加直观和直接。2.2 数据处理工具链Osmium, GDAL与GeoJSON原始的OSM数据文件.osm或.pbf是面向地理信息系统的我们需要将其转换为更易于程序处理的格式。我选择的工具链是Osmium Tool一个强大的命令行OSM数据处理工具。我们用它来根据边界框Bounding Box下载数据并进行初步的过滤例如只保留道路和建筑。GDAL/OGR地理空间数据转换的瑞士军刀。我们将使用其ogr2ogr命令把过滤后的OSM数据转换成Godot脚本更容易解析的GeoJSON格式。GeoJSON使用标准的JSON结构描述地理特征每个特征如一条道路都包含其几何形状点、线、面和属性这与Godot的JSON解析功能是天作之合。自定义GDScript解析器在Godot项目中我们将编写一个脚本读取GeoJSON文件遍历其中的每一个“特征”Feature根据其几何类型LineString用于道路Polygon用于建筑和属性调用Godot的网格生成API创建对应的3D模型。这个工具链的优势在于本地化、可脚本化不依赖特定的在线API虽然也可以使用Overpass API在线查询更适合集成到自动化的构建流程中。注意确保你的开发环境已安装Osmium Tool和GDAL。在Ubuntu上可以通过apt-get install osmium-tool gdal-bin安装。Windows用户可以从OSGeo4W或独立安装包获取。3. 实战第一步获取与处理OSM数据让我们以生成一片简单的街区为例。假设我们想要纽约市曼哈顿中城的一小块区域。3.1 使用Osmium下载并过滤数据首先我们需要确定区域的边界框。你可以通过OpenStreetMap官网右键点击“显示地址”来获取坐标。例如我们选定一个范围西经-74.01北纬40.75到西经-73.99北纬40.76。打开终端使用以下Osmium命令下载该区域数据并仅保留道路和建筑# 下载原始数据.osm.pbf格式更紧凑 osmium extract -b -74.01,40.75,-73.99,40.76 planet-latest.osm.pbf -o manhattan_area.osm.pbf # 从原始星球文件中提取指定区域。如果你没有整个星球文件可以使用osmium get从在线源获取需网络。 # 更常用的方式是通过Overpass API但这里演示本地过滤。 # 假设我们已有一个包含目标区域的小型.osm文件使用osmium tags-filter进行过滤 osmium tags-filter manhattan_area.osm.pbf w/highway w/building -o filtered_manhattan.osm.pbfw/highway表示筛选带有highway标签的要素所有道路w/building筛选建筑。-o指定输出文件。3.2 将OSM数据转换为GeoJSON接下来使用GDAL的ogr2ogr命令将过滤后的PBF文件转换为GeoJSON。我们需要分别处理道路和建筑因为它们的几何类型和后续处理方式不同。# 转换道路数据通常是LineString类型 ogr2ogr -f GeoJSON -select name,highway roads.geojson filtered_manhattan.osm.pbf lines # 转换建筑数据通常是Polygon类型 ogr2ogr -f GeoJSON -select name,building,height buildings.geojson filtered_manhattan.osm.pbf multipolygons-f GeoJSON指定输出格式。-select用于选择需要保留的属性字段这里我们保留了名称、道路类型和建筑高度如果OSM数据中有的话。最后的lines和multipolygons是OGR驱动中对应的图层名。现在你得到了roads.geojson和buildings.geojson两个文件。用文本编辑器打开你会看到类似下面的结构这就是Godot脚本将要解析的“食谱”{ type: FeatureCollection, features: [ { type: Feature, properties: {highway: residential, name: Main St}, geometry: { type: LineString, coordinates: [[-74.005, 40.751], [-74.004, 40.752], ...] } } // ... 更多道路 ] }4. 在Godot中解析GeoJSON并生成3D道路万事俱备进入Godot实战环节。创建一个新的3D项目然后我们开始编写核心脚本。4.1 项目结构与节点设置在场景中创建一个名为World的Spatial节点作为根节点。然后为其添加一个脚本命名为osm_loader.gd。我们还需要准备两个简单的材质一个用于道路如深灰色一个用于建筑如浅棕色。将roads.geojson和buildings.geojson文件放在项目的res://data/目录下。4.2 编写GeoJSON解析与道路生成代码以下是osm_loader.gd的完整代码包含了详细的注释。我们将分步解析extends Spatial # 导入必要的材质 export var road_material: Material export var building_material: Material # 定义常量将经纬度坐标转换为Godot世界坐标的比例尺和原点偏移。 # 因为经纬度差值很小需要放大才能看到。原点用于将区域中心对准Godot世界原点。 const SCALE 1000.0 # 放大倍数 var origin_lat 40.755 # 区域中心纬度 var origin_lon -74.005 # 区域中心经度 func _ready(): generate_city() func generate_city(): # 1. 生成道路 var roads_data load_geojson(res://data/roads.geojson) if roads_data: generate_roads(roads_data) # 2. 生成建筑下一步实现 # var buildings_data load_geojson(res://data/buildings.geojson) # if buildings_data: # generate_buildings(buildings_data) func load_geojson(file_path: String): # 读取并解析JSON文件 var file File.new() if file.open(file_path, File.READ) ! OK: push_error(Failed to open file: file_path) return null var json_text file.get_as_text() file.close() var json_result JSON.parse(json_text) if json_result.error ! OK: push_error(JSON parse error in file_path : json_result.error_string) return null return json_result.result func generate_roads(data: Dictionary): # 确保是FeatureCollection类型 if data.get(type) ! FeatureCollection: push_error(Invalid GeoJSON: not a FeatureCollection) return var features data.get(features, []) for feature in features: var geom feature.get(geometry) var props feature.get(properties, {}) if geom and geom.get(type) LineString: var coords geom.get(coordinates, []) if coords.size() 2: continue # 至少需要两个点构成线段 # 根据道路类型决定宽度 var road_type props.get(highway, ) var road_width 5.0 # 默认宽度 match road_type: motorway, trunk: road_width 12.0 primary: road_width 10.0 secondary: road_width 8.0 tertiary, residential: road_width 6.0 service, footway: road_width 3.0 # 生成这条道路的网格 create_road_mesh(coords, road_width, props.get(name, Unnamed Road)) func create_road_mesh(coords: Array, width: float, road_name: String): # coords: [[lon1, lat1], [lon2, lat2], ...] # 将GeoJSON中的每个线段转换为3D网格带一个四边形条带 var st SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) # 设置材质 if road_material: st.set_material(road_material) # 辅助函数将经纬度转换为Godot中的3D坐标 (X, Z, Y0) func project(lon: float, lat: float) - Vector3: # 简单的墨卡托投影近似适用于小范围区域 var x (lon - origin_lon) * SCALE var z (lat - origin_lat) * SCALE * -1.0 # 反转Z轴使北向为正Z return Vector3(x, 0.0, z) # 遍历坐标点生成连续的三角形带 for i in range(coords.size() - 1): var point_a project(coords[i][0], coords[i][1]) var point_b project(coords[i1][0], coords[i1][1]) # 计算线段的方向和法线用于向两侧扩展形成宽度 var segment point_b - point_a if segment.length() 0.001: continue # 忽略过短的线段 var dir segment.normalized() var normal Vector3(-dir.z, 0.0, dir.x) # 在XZ平面上的垂直向量 var half_width width * 0.5 var a_left point_a normal * half_width var a_right point_a - normal * half_width var b_left point_b normal * half_width var b_right point_b - normal * half_width # 为这个四边形添加两个三角形 (A_left - A_right - B_left) 和 (B_left - A_right - B_right) # 三角形1 st.add_vertex(a_left) st.add_vertex(a_right) st.add_vertex(b_left) # 三角形2 st.add_vertex(b_left) st.add_vertex(a_right) st.add_vertex(b_right) # 生成网格资源并创建场景节点 var mesh st.commit() var mesh_instance MeshInstance.new() mesh_instance.mesh mesh mesh_instance.name Road_%s % road_name # 添加静态体碰撞方便后续角色行走或车辆行驶 mesh_instance.create_trimesh_collision() add_child(mesh_instance)代码关键点解析坐标转换 (project函数)这是核心。GeoJSON中的坐标是经纬度单位是度而Godot世界坐标是米。我们通过一个简单的线性缩放和偏移来近似转换。SCALE因子将度数差放大到合适的米制尺度。-1.0乘以Z坐标是因为在Godot中通常将XZ平面作为地面且Z轴正向可能与传统地图的北向相反这里调整以使北向对应Godot的Z轴。道路宽度匹配根据OSM的highway标签值我们为不同类型的道路分配不同的宽度。这是一个非常基础的分类你可以根据项目美术需求进一步细化。网格生成逻辑对于道路线状要素我们将其视为一系列连续的线段。对每一段我们计算其垂直方向法线然后向两侧各扩展half_width得到四个角点从而构成一个四边形。两个三角形拼接成一个四边形条带最终所有线段连接成连续的道路网格。碰撞体生成create_trimesh_collision()方法为网格自动生成一个精确的三角网格碰撞体。这对于需要精确碰撞检测如车辆模拟的场景很重要但顶点数多时性能开销较大。对于大型地图你可能需要考虑简化的碰撞形状或LOD细节层次。运行场景你应该能看到灰色的道路网络出现在Godot的3D视口中。虽然现在还是平坦的但城市的骨架已经立起来了。5. 进阶生成带高度的3D建筑模型道路是城市的血管建筑则是城市的肌肉。生成建筑比道路复杂一些因为建筑是多边形面并且我们希望能赋予它们高度。5.1 解析建筑多边形并挤出高度修改generate_city函数取消注释建筑生成的代码部分。然后添加以下函数func generate_buildings(data: Dictionary): if data.get(type) ! FeatureCollection: push_error(Invalid GeoJSON for buildings) return var features data.get(features, []) for feature in features: var geom feature.get(geometry) var props feature.get(properties, {}) if geom and geom.get(type) Polygon: var coordinates_array geom.get(coordinates, []) # GeoJSON的Polygon坐标是三维数组第一个元素是外环后续是内环洞 var exterior_ring coordinates_array[0] if coordinates_array.size() 0 else [] if exterior_ring.size() 3: continue # 至少需要三个点构成多边形 # 尝试从属性中获取建筑高度默认为一个随机值或固定值 var height_str props.get(height, ) var height 10.0 # 默认高度10米 if height_str.is_valid_float(): height float(height_str) elif height_str.is_valid_integer(): height float(int(height_str)) # 如果OSM中没有height标签可以尝试根据building:levels估算这里简化处理 var levels props.get(building:levels, 1) if levels.is_valid_integer(): height int(levels) * 3.0 # 假设每层3米 # 生成建筑网格 create_building_mesh(exterior_ring, height, props.get(name, Building)) func create_building_mesh(ring_coords: Array, height: float, building_name: String): # ring_coords: [[lon1, lat1], [lon2, lat2], ...]首尾点相同闭合环 # 步骤1. 生成底面多边形 2. 挤出侧面 3. 生成顶面 var st SurfaceTool.new() st.begin(Mesh.PRIMITIVE_TRIANGLES) if building_material: st.set_material(building_material) func project(lon: float, lat: float) - Vector3: var x (lon - origin_lon) * SCALE var z (lat - origin_lat) * SCALE * -1.0 return Vector3(x, 0.0, z) # 底面在Y0 # 1. 将经纬度坐标转换为底面顶点Vector2数组忽略Y var base_vertices [] for coord in ring_coords: # 注意GeoJSON多边形首尾点相同我们生成网格时通常不需要重复的最后一个点 base_vertices.append(Vector2(project(coord[0], coord[1]).x, project(coord[0], coord[1]).z)) # 移除最后一个与第一个重复的点如果存在 if base_vertices.size() 0 and base_vertices[0].distance_to(base_vertices[-1]) 0.001: base_vertices.remove(base_vertices.size() - 1) if base_vertices.size() 3: return # 2. 使用Godot的Geometry类对多边形进行三角剖分生成底面三角形 var triangles Geometry.triangulate_polygon(PoolVector2Array(base_vertices)) if triangles.size() 0: push_warning(Failed to triangulate building polygon: building_name) return # 可能是凹多边形或自相交Geometry.triangulate_polygon无法处理复杂情况 # 3. 添加底面三角形顶点Y坐标为0法线朝下 for i in range(0, triangles.size(), 3): var idx0 triangles[i] var idx1 triangles[i1] var idx2 triangles[i2] var v0_3d Vector3(base_vertices[idx0].x, 0.0, base_vertices[idx0].y) var v1_3d Vector3(base_vertices[idx1].x, 0.0, base_vertices[idx1].y) var v2_3d Vector3(base_vertices[idx2].x, 0.0, base_vertices[idx2].y) # 注意顶点顺序绕序决定了法线方向。这里我们希望底面法线向下。 st.add_vertex(v0_3d) st.add_vertex(v2_3d) # 交换顺序以翻转法线 st.add_vertex(v1_3d) # 4. 生成侧面四边形条带分解为三角形 var vertex_count base_vertices.size() for i in range(vertex_count): var j (i 1) % vertex_count var bottom_left Vector3(base_vertices[i].x, 0.0, base_vertices[i].y) var bottom_right Vector3(base_vertices[j].x, 0.0, base_vertices[j].y) var top_left Vector3(base_vertices[i].x, height, base_vertices[i].y) var top_right Vector3(base_vertices[j].x, height, base_vertices[j].y) # 每个侧面由两个三角形组成 # 三角形1 (bottom_left - top_left - bottom_right) st.add_vertex(bottom_left) st.add_vertex(top_left) st.add_vertex(bottom_right) # 三角形2 (bottom_right - top_left - top_right) st.add_vertex(bottom_right) st.add_vertex(top_left) st.add_vertex(top_right) # 5. 生成顶面底面向上平移高度 for i in range(0, triangles.size(), 3): var idx0 triangles[i] var idx1 triangles[i1] var idx2 triangles[i2] var v0_top Vector3(base_vertices[idx0].x, height, base_vertices[idx0].y) var v1_top Vector3(base_vertices[idx1].x, height, base_vertices[idx1].y) var v2_top Vector3(base_vertices[idx2].x, height, base_vertices[idx2].y) # 顶面法线向上保持正确的绕序 st.add_vertex(v0_top) st.add_vertex(v1_top) st.add_vertex(v2_top) # 生成网格并添加到场景 var mesh st.commit() var mesh_instance MeshInstance.new() mesh_instance.mesh mesh mesh_instance.name Building_%s % building_name mesh_instance.create_trimesh_collision() # 建筑也需要碰撞 add_child(mesh_instance)建筑生成的关键细节高度获取OSM数据中建筑高度可能存储在height标签米或building:levels标签层数中。代码中尝试解析这两个属性并提供了一个估算逻辑每层3米。这是一个需要根据实际数据源和项目风格调整的地方。多边形三角剖分Godot的Geometry.triangulate_polygon方法可以将一个简单的凸多边形分解为三角形。但是它无法处理凹多边形或带洞的多边形。对于复杂的建筑轮廓如L形、带中庭这个简单方法会失败。在实际项目中你需要引入更健壮的三角剖分库例如poly2tri的GDScript或C绑定或者在数据预处理阶段如使用QGIS将复杂多边形拆分为多个凸多边形。法线方向在添加顶点时三角形的绕序顺时针或逆时针决定了面的法线方向进而影响光照。我们确保底面法线朝下通常不可见侧面和顶面法线朝外。性能考量每个建筑都生成独立的MeshInstance和Trimesh碰撞体。当建筑数量成百上千时这会严重影响性能。一个重要的优化是批量合并Mesh Merging将材质相同的多个建筑的网格数据合并成一个大的ArrayMesh然后只用一个MeshInstance和碰撞体来表示。这可以大幅减少Draw Call。Godot的ArrayMesh和SurfaceTool也支持追加append_from操作可以用于实现批量合并。6. 优化、问题排查与扩展思路基础功能实现后我们面临的是优化和实际应用中的各种问题。6.1 常见问题与解决方案速查表问题现象可能原因解决方案场景中一片空白什么都没有生成。1. GeoJSON文件路径错误或未加载。2. 坐标转换比例尺SCALE过大或过小模型在视野外。3. 原点origin_lon/lat设置错误所有顶点坐标偏移过大。1. 使用print()或push_error()检查文件加载和JSON解析结果。2. 将SCALE调小如100并在Godot编辑器中拉远摄像机寻找。3. 尝试将原点设置为数据区域的大致中心点。可以先打印第一个顶点的转换后坐标进行调试。道路或建筑位置错乱、扭曲。1. 经纬度坐标顺序错误。GeoJSON标准是[经度, 纬度]有时容易与[纬度, 经度]混淆。2. Z轴方向未反转导致地图上下颠倒。1. 确认coord[0]是经度coord[1]是纬度。2. 检查project函数中z坐标的计算是否乘以了-1.0。建筑生成失败控制台有三角剖分警告。1. 建筑多边形是凹多边形或自相交。2. 多边形顶点顺序绕序不正确。1. 在数据处理阶段使用QGIS等工具检查并修复几何错误或拆分为凸多边形。2. 确保多边形顶点是逆时针顺序GeoJSON标准要求外环逆时针。可以使用Geometry.is_polygon_clockwise()检测并反转。帧率很低尤其是建筑多的时候。1. 每个建筑都是独立的MeshInstance和碰撞体Draw Call爆炸。2. 三角网格碰撞体过于复杂。1.实施网格合并将同材质的建筑合并为单个MeshInstance。2.简化碰撞为建筑使用简单的Box或ConvexPolygon碰撞体而非Trimesh。3.实现LOD远处建筑使用简化模型或甚至不渲染。建筑没有高度全是扁平的。1. OSM数据中没有height或building:levels标签。2. 属性解析失败高度值始终为默认值。1. 检查GeoJSON文件确认属性字段是否存在。可以使用在线GeoJSON查看器。2. 在代码中添加调试打印输出解析到的高度值。考虑使用基于建筑面积的估算高度作为后备方案。6.2 性能优化实战网格合并针对大量建筑导致的性能问题这里提供一个简单的网格合并思路。我们可以在生成每个建筑网格时不立即创建MeshInstance而是将SurfaceTool的数据暂存起来最后统一提交。# 在脚本顶部定义用于合并的SurfaceTool和材质映射 var building_surface_tool: SurfaceTool null var merged_building_material: Material null # 修改 generate_buildings 和 create_building_mesh 函数 func generate_buildings(data: Dictionary): # ... 数据验证 ... building_surface_tool SurfaceTool.new() building_surface_tool.begin(Mesh.PRIMITIVE_TRIANGLES) merged_building_material building_material if merged_building_material: building_surface_tool.set_material(merged_building_material) for feature in features: # ... 解析每个建筑 ... # 不再调用 create_building_mesh而是调用一个将几何数据追加到 surface_tool 的函数 append_building_to_surface_tool(exterior_ring, height) # 所有建筑遍历完成后生成合并的网格 commit_merged_buildings() func append_building_to_surface_tool(ring_coords: Array, height: float): # 这个函数的内部逻辑与之前的create_building_mesh类似 # 但不是调用 st.commit() 和创建 MeshInstance # 而是将 add_vertex 等操作应用到全局的 building_surface_tool 上。 # 注意需要确保每个建筑的顶点数据是独立的或者正确处理顶点索引。 # 一种简单但有效的方法是为每个建筑重新开始一个“批次”但使用同一个SurfaceTool。 # 由于我们使用PRIMITIVE_TRIANGLES直接连续添加顶点即可。 # ... [此处省略具体的顶点计算和添加代码逻辑与原create_building_mesh的三角形添加部分完全一致] ... pass # 实际代码需将原函数中 st.add_vertex 替换为 building_surface_tool.add_vertex func commit_merged_buildings(): if building_surface_tool: var merged_mesh building_surface_tool.commit() var merged_instance MeshInstance.new() merged_instance.mesh merged_mesh merged_instance.name Merged_Buildings merged_instance.create_trimesh_collision() # 注意合并后的碰撞体仍然是一个整体 add_child(merged_instance)这种方法将成百上千个Draw Call减少到1个性能提升立竿见影。代价是失去了对单个建筑的独立控制如单独销毁、改变材质。你需要根据游戏玩法权衡。6.3 扩展思路让城市活起来生成了静态的模型只是第一步一个鲜活的游戏城市还需要更多地形与高程OSM本身不包含地形高程数据。你可以结合SRTM或ASTER GDEM等数字高程模型DEM数据在生成道路和建筑顶点时将Y坐标高度替换为对应位置的海拔值从而创建出有起伏的城市。程序化贴图与细节为建筑和道路应用更丰富的材质。可以根据OSM的building:material、surface等标签或者根据建筑高度、类型程序化地分配不同的墙面、屋顶纹理。使用Shader可以实现窗户细节、昼夜变化等。植被与街道设施利用OSM的naturaltree、amenitybench等点数据在相应位置实例化树木、长椅等模型。导航网格生成利用生成的道路和建筑轮廓使用Godot的NavigationMesh资源为AI角色生成可行的行走路径。流式加载对于大型城市不可能一次性全部加载。需要将地图分块Tile根据玩家位置动态加载和卸载OSM数据块及对应的3D模型。这个从OSM到Godot 3D城市的流程为你提供了一个强大而灵活的场景生成起点。它剥离了繁琐的手工建模让你能更专注于游戏玩法本身。记住获取到的OSM数据质量参差不齐预处理和错误处理是保证生成效果的关键。现在你可以用世界上任何一个角落的地图快速构建你的游戏舞台了。