甲方只给了一张卫星截图,我用 Three.js + Blender 建了一座 3D 能源站

一个「综合能源补给站」数字孪生大屏的完整实现:从一张卫星截图出发,反求设计稿相机、脚本化建模、Cycles 烘焙夜景、牵引车倒车入位、运营仿真,最后让三维画面和设计稿逐像素对齐。

甲方的需求很简单:做一个综合能源补给站的 3D 可视化大屏。站里有加油加气站、LNG 加气站、重卡超充、乘用车换电站和办公楼。

能拿到手的资料也很简单:一份设施清单,加上一张地图卫星截图,红线圈出的就是这块地:

没有地址,没有 CAD,没有尺寸。所以整件事要分成几步来做:

最终效果,左边是甲方给的,右边是交付的(同一个三维视角):

整个项目坚持一条原则:所有产物都由脚本生成,不手改模型、不手改数据。规划数据只写在一个地方,其余产物都从它派生出来:

改一个罩棚的位置,只需要改 draw_plan.py 里的一行坐标,然后重跑一遍流水线。

最开始我是照自己画的规划草图建模的,结果和甲方确认的概念图对不上:沿路的设施顺序反了,地块形状不同,重卡车位的排法也不同。甲方认的是概念图,所以要反过来,从概念图推出规划。

概念图是 AI 生成的,没有相机参数,但它看起来是一张近似正交的等轴测图。正交相机有个很好用的性质:

地面上的点投到屏幕上是一个仿射变换:横向长度按比例 s 缩放,沿视线方向的长度再多压缩 sin(俯角) 倍。

设相机方位角为 θ,俯角为 φ,比例为 s(像素/米),地面点 (x, y, z) 投到屏幕上是:

概念图里有很多「地面上的矩形」:罩棚屋面、车位前沿、车棚前沿。它们在真实世界里是直角、互相平行的。把这些矩形的像素角点标出来,用最小二乘解出 θ、φ、s 和画面偏移,使「矩形投影后的角点」和「图上的角点」误差最小。加油罩棚长度按常见的 40 m 取值,用来确定尺度。

得到的结果:方位 -47.06°、俯角 23.66°、8.33 px/m,残差约 3 px。

相机确定后,屏幕点 → 地面点就是解一个 2×2 线性方程。屋面上的点先按估计的高度扣掉抬升量,再反解:

把概念图里每栋建筑、每条道路、沙盘边缘的像素点都反投影回去,就得到了规划的米坐标。这些坐标写进 draw_plan.py,变成唯一的数据源:

概念图里那种「圆角胶囊形」沙盘,用一个指数 2.2 的超椭圆就能拟合得很好。

规划坐标统一用 (u, v):u 沿门前道路向南,v 垂直道路、指向场内。Blender 和 three.js 各有一条固定的换算公式(three.js 里是 x = 110 - u, z = 40 - v)。三端共用一套坐标系,路线、锚点、标签就不会错位。

设计稿和甲方清单冲突时,以甲方的数量为准:概念图画了 12 个重卡位,甲方要 10 个,那就保留 10 个,把单个车位放宽到 6.4 m,整排长度和概念图保持一致。

为什么不手工建模?因为布局改了好几轮,每次手工挪几十个对象是噩梦。用脚本建模的话,改完数据重跑一遍就行:

命名就是接口。three.js 是按对象名驱动模型的:pile_C01~pile_C10 是超充终端,bay_C01 是车位描边,anchor_fuel 是标签锚点,canopy_fuel_light 是罩棚灯面。改名会直接让前端找不到对象,所以这些名字写进了文档,当作约定来维护。

概念图的质感几乎全来自光:罩棚下被照亮的站坪、路灯下边缘清楚的暖色光斑、树下的投光,还有湿润路面上的灯光倒影。three.js 实时光照做不出这种效果,路灯有几十盏,实时阴影也扛不住。

做法是:在 Blender 里给罩棚、路灯、树下各放一盏烘焙专用灯(不导出),用 Cycles 把最终画面(COMBINED:贴图 × 光照 × 阴影)烘进一张地面贴图。three.js 里地面直接显示这张图:

关键点是黑色基色 + 烘焙图当自发光:地面完全不吃实时光,看到的就是 Cycles 渲染的结果,同时还保留了 PBR 的环境反射,可以做出湿路面的光泽。

整图 6144 宽、96 采样,在 M2 GPU 上大约要 3 分钟。MCP 调用会超时,所以把烘焙交给 bpy.app.timers 在 Blender 里异步执行,调用本身立即返回。

来源:稀土掘金 原文