热词列表里那条“blender+行政+高程数据建立3d模型”,我一看就知道说的是哪类案例——拿着真实地理数据在Blender里重建立体地形。oeasy这套Blender教程的第20集,正好是一个完整的“数据变模型”实战案例。这种完整案例和碎片教程最大的区别,是它逼着你走完从数据准备到最终导出的每一环,中间任何一步断掉,模型都出不来。对于刚接触Blender建模的人来说,跟着这种完整案例做一遍,比看几十个单独讲修改器或快捷键的视频都更有用,因为它能把零散的操作串成一条生产链路。
我按这个思路完整跑了一遍流程:先解决地形数据从哪来、怎么变成Blender能用的灰度图,再做地形网格,把行政区边界叠加上去,最后导出成多种格式。这篇文章把我实际踩过的坑、推荐的做法、还有那些热搜词里反复出现的“导出json”“FBX插件”“optix不显示”之类的问题,一次性说清楚。
1. 案例拆解:一个完整案例到底“完整”在哪
先别急着上手操作,把案例的框架看明白,后面每一步你就知道自己在做什么了。包含行政边界和高程数据的建模,本质上做的是“把真实世界的坐标数据,映射到三维软件的空间里”。
1.1 为什么拿真实地理数据当素材
很多人学Blender都是拿立方体、猴头练手,练完就忘。用真实地理数据做案例有个好处:它自带一套坐标系统和属性信息,你做出来的模型是能被验证的——对比卫星图、对比实际地形的起伏,模型对不对一眼就能看出来。
真实地理数据建模一般分两类:
- 高程数据(DEM):描述地表海拔高度的栅格数据,每个像素记录一个高度值,用它来生成地形起伏。
- 行政区划数据:描述省、市、区县边界的矢量数据,用它来给地形“画区域”。
把两者叠到同一个三维空间里,就得到了一张带立体起伏的行政区划图。这种模型在城市规划展示、文化遗产数字化、游戏地形底图、数据可视化大屏这些场景里非常常见。我见过不少团队做智慧城市项目时,第一步就是把城市地形和行政边界做出三维底图,再往上叠加业务数据。
用真实数据驱动建模还有一个好处,它能强迫你养成“先理解数据,再动手建模”的习惯。比如拿到一份高程TIFF文件,你不能直接拖进Blender就指望它变成山,你得知道它的坐标系、分辨率、无效值怎么处理。这套思路放到任何数据建模场景都通用。
1.2 案例覆盖的完整流程
整个案例可以拆成五个环节,每个环节都有独立的产出物:
| 环节 | 输入 | 处理方式 | 产出物 |
|---|---|---|---|
| 数据获取 | 公开的DEM数据、行政区划GeoJSON | 下载、裁剪、格式检查 | 合格的高程TIFF和边界文件 |
| 数据预处理 | DEM TIFF文件 | 坐标归一化、灰度映射、空值处理 | 灰度图(PNG)或顶点坐标数组 |
| 地形生成 | 灰度图或坐标数组 | 置换修改器或脚本生成网格 | 带起伏的地形模型 |
| 边界叠加 | 行政区划坐标 | 坐标换算、曲线创建、挤出、贴图遮罩 | 带边界的完整三维模型 |
| 渲染与导出 | 三维模型 | 材质、灯光、相机、格式转换 | 最终图像、FBX/OBJ/GLTF/JSON文件 |
这五个环节里,最容易卡住的不是Blender操作本身,而是第二和第四步——数据怎么进Blender、边界怎么和地形对齐。后面两章我详细拆。
2. 地形数据准备:从数字高程模型到灰度图
先说清楚一个底层逻辑:Blender本身不理解“海拔高度”这个概念,它只认识物体的坐标、顶点的位置、材质贴图的颜色。所以要把海拔数据变成三维地形,最常见的方式是利用置换修改器——它读一张灰度图,把图像中每个像素的亮度值当成顶点位移量。白色代表高、黑色代表低,中间灰阶是过渡高度。
2.1 数据源的取舍
公开可用的DEM数据源主要有几个体系:SRTM、ALOS、ASTER GDEM。这些数据集覆盖全球,分辨率通常是30米左右。对城市级地形建模来说,30米分辨率完全够用;但如果你想做一个小片区的精细地形,比如一座园区、一段河道的微地形,这个分辨率就不够了,需要找更高精度的测绘数据。
选数据源的标准就一条:模型覆盖范围和所需细节的比值。我个人的经验是:
- 覆盖一个城市(几十公里范围):30米分辨率足够
- 覆盖一个区县(十几公里范围):30米分辨率够用,但局部细节会显得“糊”
- 覆盖几公里的景区或园区:需要找LiDAR或更高精度的数据源,或者接受地形平滑化
下载数据后第一时间要做的是检查无效值。公开DEM数据常有Nodata区域(比如水体、云层遮挡),这些位置的像素值可能是-9999之类的大数。如果不处理,灰度映射的时候会把整个色彩范围拉爆,地形图变成一块白斑加一块黑斑。处理方式是把无效值统一改成0或者直接置为NaN。
2.2 用Python把DEM转成灰度图的实操
我习惯在进Blender之前,先用Python把DEM文件预处理成一张16位灰度PNG。这一步的核心是“归一化映射”,也就是把DEM里的实际海拔范围(可能从0米到3000米),线性映射到灰度区间(0到65535),这样置换修改器读取时才能获得完整的位移精度。
下面这段代码可以直接跑,依赖rasterio和PIL:
import rasterio import numpy as np from PIL import Image with rasterio.open("city_dem.tif") as src: dem = src.read(1) nodata = src.nodata dem = np.where(dem == nodata, 0, dem) # 把无效值和异常值剔除后再算范围 valid = dem[dem > 0] dem_min = float(np.min(valid)) dem_max = float(np.max(valid)) # 归一化到16bit灰度 norm = (dem - dem_min) / (dem_max - dem_min) * 65535 norm = np.clip(norm, 0, 65535).astype(np.uint16) Image.fromarray(norm).save("city_dem_16bit.png") print(f"海拔范围: {dem_min:.1f}m ~ {dem_max:.1f}m")这里有个细节值得注意:为什么要用16位而不是8位?因为8位灰度只有256个亮度等级,如果你手头的地形海拔高差很大——比如一座山从50米到2000米——分配到每个亮度级就有接近8米的高度步进,做出来的地形会呈现一层层的“台阶”断层。16位灰度有65536个等级,同样场景下每个等级只有约0.03米,完全看不出阶梯。
提示:如果置换出来的地形起伏过于夸张,不要改灰度图,直接在置换修改器里调低强度数值就可以,这比反复重新生成灰度图高效得多。
2.3 另一个思路:直接用坐标点生成地形
灰度图方案适合“有完整栅格文件”的情况。但有时候你手里的数据不是TIFF,而是一堆经纬度和高度记录,比如GPS采集点、点云数据、无人机测绘成果。这时候没必要非得转成灰度图再进Blender,直接让Python脚本在Blender里按坐标点生成网格更省事。这个方法我在下一章展开,因为行政边界叠加的时候也会用到同样的坐标换算思路。
3. 把灰度图变成起伏地形:两种建模路径
地形数据进Blender有两条主流路径,各有适用场景。一条适合新手快速出效果,另一条适合精确控制模型结构。
3.1 路径一:细分平面加置换修改器
这是最稳妥、对新手上手最友好的方案。操作逻辑很简单:Blender里新建一个平面,把它细分到足够多的面数,再叠加置换修改器读取灰度图,让顶点高度跟着图像亮度变化。
具体步骤:
- 在Blender中新建平面(默认尺寸2米),按
S放大,我这里习惯放到1000到2000之间,具体数值取决于你模型要表现的地理范围。 - 进入编辑模式,全选所有顶点,右键选择“细分”——这里建议用“细分”而不是“简单细分”,前者会做平滑插值,后者会让网格产生明显的块状感。
- 细分级数根据电脑性能来。注意,每次细分面数乘以4,初始平面2个三角形区域,细分10次会到上百万面,很多老电脑到这一部就开始卡了。稳妥做法是先细分7到8次,大概几万到几十万面,既保留地形细节又不会卡死。
- 给平面添加“置换”修改器,在纹理设置里新建一个图像纹理,加载刚才生成的灰度图。
- 调整置换强度。强度值就是最大位移高度。如果城市海拔高差300米,强度填300(Blender默认单位是米)。
- 如果发现地形反过来——山谷变成了山脊——把强度改为负数即可,这是法线方向不同导致的。
置换修改器里有个关键参数容易忽略:纹理坐标方式。默认是“局部坐标”,如果平面有旋转或缩放,图像映射会跟着扭曲,导致地形形状对不上。我通常先在修改器面板把纹理坐标改成“生成”模式,再调整平面的缩放,这样图像能按物体的原始空间正确铺开。
3.2 路径二:脚本直接生成顶点网格
如果你手里的数据本身是规则网格采样点,比如每个经纬度点对应一个高度值,我更推荐用Python脚本直接生成网格。这样做的好处是,每一个顶点的位置都由数据精确决定,不存在置换修改器那种“按图像亮度驱动”的间接误差。
转换逻辑是地理坐标到三维坐标的映射:
- 以数据左下角为原点
- 经度差乘以每度经度对应的米数得到X方向偏移(注意要乘cos纬度修正,因为经线在高纬度会收缩)
- 纬度差乘以每度纬度对应的米数得到Y方向偏移
- 高度值直接作为Z轴
一个简化的脚本长这样:
import bpy def create_terrain_from_grid(grid_data, cols, rows, lon0, lat0, lat_ref): import math verts = [] faces = [] # 每度纬度约111320米 meter_per_deg_lat = 111320.0 meter_per_deg_lon = 111320.0 * math.cos(math.radians(lat_ref)) for i in range(rows): for j in range(cols): lon, lat, h = grid_data[i * cols + j] x = (lon - lon0) * meter_per_deg_lon y = (lat - lat0) * meter_per_deg_lat z = h verts.append((x, y, z)) for i in range(rows - 1): for j in range(cols - 1): a = i * cols + j b = a + 1 c = a + cols + 1 d = a + cols faces.append((a, b, c, d)) mesh = bpy.data.meshes.new("terrain_from_data") mesh.from_pydata(verts, [], faces) mesh.update() obj = bpy.data.objects.new("terrain", mesh) bpy.context.collection.objects.link(obj) return obj用脚本建网格的好处还在于,它直接把行政边界的坐标换算逻辑也统一了。只要数据来源的经纬度范围一致,换算公式一致,地形网格和边界曲线放进场景后就是天然对齐的,不需要手动调整位置。
3.3 两条路径怎么选
| 方案 | 精度 | 性能开销 | 操作难度 | 适用场景 |
|---|---|---|---|---|
| 置换修改器 | 受图像分辨率和细分面数限制 | 中低 | 低 | 快速出效果、DEM灰度图现成 |
| 脚本生成网格 | 高,直接由数据精度决定 | 取决于顶点数 | 中高 | 精确建模、需要配合其他坐标数据 |
我的建议是:第一次做,先用置换修改器跑通全局,理解整个流程。等到需要精细控制模型结构时(比如要把建筑底面贴合地形、要做喷绘材质分类),再切换到脚本方案。
4. 行政区划数据叠加:让地形带上“属性”
地形有了起伏,但还只是一座“秃山”。行政边界的作用是把这座山切开,让每个区域拥有名字、颜色、独立的属性。这一步做完,模型才从“地形沙盘”变成“行政区划三维地图”。
4.1 边界线进入Blender的两条路
行政边界数据一般是GeoJSON或SHP格式,里面存的是一组组的经纬度坐标点。把边界数据弄进Blender,有两条路径:
第一条是直接导入。Blender原生不支持SHP,需要借助GIS类插件比如blender-osm,它可以从在线地图源获取边界和建筑物数据。用这类插件的优势是省事,缺点是依赖外部数据源,数据格式不确定,且边界和地形的对齐不一定精准。
第二条是手动转换。用Python读取GeoJSON,把经纬度列表转成三维坐标,再生成曲线对象。虽然要写脚本,但整个过程完全可控,而且和第3.2节的地形生成共用同一套坐标换算,结果不会出现错位。
我个人的选择是第二条。不是排斥插件,而是数据建模项目里,可控性往往比省事更重要。ORDER数据出问题的时候,你至少知道问题出在哪个环节。
4.2 手动坐标换算并生成边界的实操
下面这段代码演示了如何读取GeoJSON并生成Blender曲线:
import json import bpy import math from mathutils import Vector # 读取GeoJSON with open("districts.geojson", "r", encoding="utf-8") as f: data = json.load(f) lat0 = data["features"][0]["geometry"]["coordinates"][0][0][1] lon0 = data["features"][0]["geometry"]["coordinates"][0][0][0] meters_per_deg_lat = 111320.0 meters_per_deg_lon = 111320.0 * math.cos(math.radians(lat0)) def geo_to_xy(lon, lat): x = (lon - lon0) * meters_per_deg_lon y = (lat - lat0) * meters_per_deg_lat return (x, y) # 对每个区域创建一条闭合曲线 for feature in data["features"]: name = feature["properties"].get("name", "district") coords = feature["geometry"]["coordinates"][0] # 可能是多边形外环 polyline_points = [geo_to_xy(lon, lat) for lon, lat in coords] # 创建曲线 curve_data = bpy.data.curves.new(name, type='CURVE') curve_data.dimensions = '3D' spline = curve_data.splines.new('POLY') spline.points.add(len(polyline_points) - 1) for i, (x, y) in enumerate(polyline_points): spline.points[i].co = (x, y, 0, 1) # 留Z为0,后面再抬高 obj = bpy.data.objects.new(name, curve_data) bpy.context.collection.objects.link(obj)生成的曲线是在地形表面平面高度(Z=0),进入编辑模式后把所有控制点选中,沿Z轴微调抬高一点(比如0.5米),再给曲线一个挤出值,让它变成薄片,就能看到“贴”在地形上的区域块。
4.3 给不同区域分配颜色的实操
区域块的着色有两种方式,简单粗暴的和通用性强的。
简单粗暴:每个区域做成一个独立的挤出网格,分别赋予不同的材质。这种做法的好处是每个区域都是单独的物体,后续做点击交互、数据标注都方便;坏处是区域多了材质管理很麻烦。
通用性强:在材质编辑器里用“网格坐标”向量接入ColorRamp,把一张已经按区域着色的图像(比如在GIS软件里导出的分区色块图)作为蒙版,映射到地形表面。这种方式适合“只看颜色分区”的场景,因为不产生额外几何体,渲染压力小。
我做这类模型时,通常两种结合:地形主体用一张整体色块贴图,重点区域单独生成挤出体并配独立材质。这样既有整体效果,又有局部可交互性。
5. 拿到模型之后:导出格式与跨工具交付
模型建完了,画面上看起来也像模像样了,剩下的问题就是怎么把它交出去。热搜词里那一串“blender导出json”“blender导出sketchup文件”“FBX插件”说明,大量人卡在导出这一步。这里有坑,而且不少。
5.1 导出格式按场景选
| 格式 | 适用场景 | 注意事项 |
|---|---|---|
| FBX | 游戏引擎(Unity/Unreal)、动画传递 | 注意轴方向,Blender是Z轴向上,其他软件可能是Y轴向上 |
| OBJ | 通用静态模型、大部分DCC软件 | 不带骨骼动画,带材质需要配套MTL文件 |
| GLTF/GLB | Web3D、AR/VR、Three.js | 材质PBR兼容性最好,推荐Web场景使用 |
| STL | 3D打印 | 只保留网格,材质信息全部丢弃 |
| SKP | SketchUp场景衔接 | 需要第三方插件,导出效果受插件免费版限制 |
| JSON | 数据可视化、自定义程序 | 通常通过Python脚本自定义导出属性 |
不同项目交付的格式差异很大。给游戏团队我一般导FBX,导之前确认一次轴向;给Web前端团队我导GLB;给SketchUp用户导出SKP时,我提前跟他们说明免费插件导出的文件可能会出现材质丢失,需要他们在SketchUp里重新赋一次材质。
5.2 把地形连同属性导出成JSON
热搜词里“blender如何导出json”出现的频率不低。其实Blender内置的导出格式没有JSON,但数据可视化项目(比如echarts、three.js加载)经常需要。这时候直接用Python脚本自动生成JSON就很方便,而且可以自定义要输出的属性。
import bpy import json obj = bpy.context.active_object mesh = obj.data # 如果地形被应用了修改器,先复制一份并应用 applied_obj = obj.copy() applied_obj.data = obj.data.copy() bpy.context.collection.objects.link(applied_obj) # 读取世界坐标系下的顶点坐标 verts = [] for v in applied_obj.data.vertices: world_co = applied_obj.matrix_world @ v.co verts.append([round(world_co.x, 2), round(world_co.y, 2), round(world_co.z, 2)]) faces = [] for p in applied_obj.data.polygons: faces.append([idx for idx in p.vertices]) # 如果有自定义属性,一并输出 custom_props = {k: v for k, v in obj.items() if not k.startswith("_")} out_data = { "name": obj.name, "vertices": verts, "faces": faces, "properties": custom_props } with open("terrain_export.json", "w", encoding="utf-8") as f: json.dump(out_data, f, ensure_ascii=False, indent=2) print(f"导出完成:{len(verts)} 个顶点,{len(faces)} 个面")这个脚本输出的JSON是前端友好型的,顶点数组和面索引数组可以直接喂给Three.js的BufferGeometry,也可以在echarts的custom series里做三维地图底图。如果需要更小的体积,可以把indent去掉,顶点坐标保留一位小数。
5.3 那些绕不开的插件与格式兼容问题
热搜词里有一条“blender导入导出fbx模型插件better fbx importer & exporter v6.3.5”,说明很多人对Blender原生FBX模块不满意。说实话,如果你只是导出一个静态地形网格,原生FBX导出完全够用。但如果涉及骨骼动画、多个UV通道、特定游戏引擎的命名规则,第三方插件确实更稳。
用第三方插件的时候,最烦的就是版本匹配。Blender 3.x和4.x的API有差异,老插件直接装进新版Blender可能整个Addon都加载失败。装插件之前先看支持的Blender版本号,这比文档里任何设置都重要。插件的功能和设置反而不用调太细,默认选项能应对大多数场景。
“blender不显示optix”这条热搜我也顺便说一句。OptiX是NVIDIA显卡的硬件光追加速技术,Blender用它做渲染加速。模型没问题但渲染面板里不显示OptiX,通常三个原因:显卡驱动太老,Blender版本和驱动不匹配,或者在Blender偏好设置里没有勾选对应的计算设备。排查顺序建议先更新显卡驱动,再检查偏好设置里的Cycles渲染设备和GPU勾选,最后看Blender版本日志。
6. 整套跑下来,最值得记录的三个雷
数据建模和纯三维建模不太一样,它的问题往往不是操作不会,而是数据本身或者数据与软件的衔接出了岔子。我跑这套案例时遇到三个比较典型的雷,记录下来给大家做个参考。
6.1 地形“反了”或“变平了”的根因
第一次用置换修改器加载灰度图,地形完全没变化,或者整片模型像被压平了一样。排查顺序很重要:先看置换强度数值是不是为0或者太小——我有一次就是强度填了0.01,地形起伏只有零点几的位移,肉眼根本看不出来;再看灰度图的位深,有些8位灰度图压缩后细节丢失严重,大块区域亮度一样,地形就会平得像镜面;最后看纹理坐标映射模式,如果平面有旋转,图像可能被映射到错误的轴向,地形看着就是歪的。
如果地形方向反了,强度改成负数即可。这不是什么玄学,就是你的平面法线方向默认朝上,置换修改器按法线方向往外推,负数就是反方向推。
6.2 面数失控导致操作卡顿
置换修改器要生效,必须给平面足够多的细分面。但很多新手不知道细分级数是指数级增长:一个平面细分1次变4面,细分5次变1024面,细分10次已经超过100万面。到100万面的时候,转动视口都开始掉帧。
我的建议是先用低细分跑通流程,比如细分7次约16万面,观察效果。确认地形方向和强度没问题后,再局部增加细分或者使用多级精度修改器。还有一个优化技巧:在微调阶段,把置换修改器临时隐藏不渲染,只在你需要确认效果的时候开启预览。这样操作流畅度能提升不少。
如果模型最终要放到游戏引擎里,导出前必须减面。用“Decimate”修改器里的“塌陷”模式,配合“平面度”参数,可以在地形主体保持基本形态的前提下,把面数砍到原来的三分之一甚至更少。
6.3 导出FBX/GLTF后贴图丢失
模型在Blender里渲染得好好的,导出FBX到Unity或者GLB到网页后,贴图全部丢失,变成一片灰白。这个问题分两种情况:一种是你根本没把纹理嵌入到导出文件里,另一种是贴图路径暴露了你本机的绝对路径。
GLB格式本身支持嵌入纹理,导出时面板下方有个“储存”选项,选“打包”就能把图片嵌入文件。FBX导出时虽然有嵌入贴图选项,但有些DCC软件不认,更稳妥的做法是把模型使用的贴图文件和FBX放在同一个文件夹里,用相对路径引用。导出后我还习惯用文本编辑器打开FBX看一眼贴图路径字段,确认没有出现“C:\Users\xxx”这样的绝对路径。
第三方插件导出SKP时贴图丢失更常见,免费版插件对材质支持很有限,基本只导出颜色,不导出纹理映射。所以给用SketchUp的同事交付时,我一边发SKP文件,一边连同贴图文件夹打包,并提醒他材质需要重赋。
我个人的习惯是,每次跑完这套流程,工程文件按版本号存三份:原始数据版、模型建模版、导出发布版。原始数据版指的是还没进Blender的数据文件和处理脚本;模型建模版是带完整修改器和材质节点的Blend文件;导出发布版是已经应用修改器、减面、整理好命名、准备导出的文件。这样每次改完一个环节,不会因为误操作毁掉整个工程。做数据建模,留后路往往比一步到位更重要。