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

一、需求:一张截图,一份清单
甲方的需求很简单:做一个综合能源补给站的 3D 可视化大屏。站里有加油加气站、LNG 加气站、重卡超充、乘用车换电站和办公楼。
能拿到手的资料也很简单:一份设施清单,加上一张地图卫星截图,红线圈出的就是这块地:

没有地址,没有 CAD,没有尺寸。所以整件事要分成几步来做:
- 当规划师:估出比例尺,排出每栋建筑的位置,规划车辆怎么进、怎么出;
- 定视觉:先出一张 AI 概念效果图给甲方确认(下图);
- 当建模师:在 Blender 里把规划「种」成三维模型,烘焙夜景灯光;
- 当前端:用 three.js 加载模型,接上仿真数据,让车流动起来;
- 当质检:确认实现和设计稿一致。

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

技术栈
| 环节 | 工具 |
|---|---|
| 页面 | Vue 3 + Vite,px→rem 等比缩放(postcss-pxtorem,设计稿 1920 宽) |
| 三维 | three.js:正交相机、UnrealBloomPass 辉光、RectAreaLight、InstancedMesh |
| 建模 | Blender 5.1,全部用 Python 脚本建模(bpy + bmesh),Cycles 烘焙地面光照 |
| 规划 | Python(PIL / NumPy / OpenCV):规划数据、规划图、航拍底图处理 |
| 车辆模型 | Sketchfab CC BY 4.0 模型(MAN TGX、Actros 牵引车、半挂车、轿车),脚本减面换材质 |
| 还原度检查 | 无头 Chrome 截图 + Python 叠图对比 |
二、整体流水线
整个项目坚持一条原则:所有产物都由脚本生成,不手改模型、不手改数据。规划数据只写在一个地方,其余产物都从它派生出来:
text
draw_plan.py(规划数据:唯一数据源)
├─→ plan-v2.png 规划图(给人看)
├─→ site.json Blender 布局
├─→ plan.json 页面数据(开场动画的分区色块、红线)
└─→ site.webp 去掉手绘红线、四周外扩的航拍底图
│
Blender(scripts/blender/energy/*.py)
├─→ station.glb 场站模型(树 / 路灯导出为 GPU 实例)
├─→ vehicles.glb 车辆预制(车轴位置写进 glTF extras)
├─→ ground_bake.webp Cycles 烘焙的地面光照
└─→ outline.json 沙盘轮廓、车道范围
│
three.js(EnergyScene)+ 仿真器(Simulator)+ Vue 面板
改一个罩棚的位置,只需要改 draw_plan.py 里的一行坐标,然后重跑一遍流水线。
三、从设计稿「反求」布局
最开始我是照自己画的规划草图建模的,结果和甲方确认的概念图对不上:沿路的设施顺序反了,地块形状不同,重卡车位的排法也不同。甲方认的是概念图,所以要反过来,从概念图推出规划。
3.1 标定设计稿的相机
概念图是 AI 生成的,没有相机参数,但它看起来是一张近似正交的等轴测图。正交相机有个很好用的性质:
地面上的点投到屏幕上是一个仿射变换:横向长度按比例 s 缩放,沿视线方向的长度再多压缩 sin(俯角) 倍。
设相机方位角为 θ,俯角为 φ,比例为 s(像素/米),地面点 (x, y, z) 投到屏幕上是:
python
import numpy as np
def project(p, az, el, s, c):
"""正交相机:世界点 (x, y, z) → 设计稿像素(简化示意)"""
th, ph = np.radians(az), np.radians(el)
X = -np.sin(th) * p[0] + np.cos(th) * p[1] # 屏幕横向
Y = (-np.sin(ph) * (np.cos(th) * p[0] + np.sin(th) * p[1]) # 地面纵深,被压扁
+ np.cos(ph) * p[2]) # 高度,直接抬升
return c + s * np.array([X, -Y])
概念图里有很多「地面上的矩形」:罩棚屋面、车位前沿、车棚前沿。它们在真实世界里是直角、互相平行的。把这些矩形的像素角点标出来,用最小二乘解出 θ、φ、s 和画面偏移,使「矩形投影后的角点」和「图上的角点」误差最小。加油罩棚长度按常见的 40 m 取值,用来确定尺度。
得到的结果:方位 -47.06° 、俯角 23.66° 、8.33 px/m,残差约 3 px。
3.2 反投影回地面
相机确定后,屏幕点 → 地面点就是解一个 2×2 线性方程。屋面上的点先按估计的高度扣掉抬升量,再反解:
python
def unproject(px, z, az, el, s, c):
"""设计稿像素 + 已知高度 z → 地面 (x, y)(简化示意)"""
th, ph = np.radians(az), np.radians(el)
X, Y = (px - c) / s
Y = -Y - np.cos(ph) * z # 扣掉高度带来的抬升
A = np.array([[-np.sin(th), np.cos(th)],
[-np.sin(ph) * np.cos(th), -np.sin(ph) * np.sin(th)]])
return np.linalg.solve(A, [X, Y])
把概念图里每栋建筑、每条道路、沙盘边缘的像素点都反投影回去,就得到了规划的米坐标。这些坐标写进 draw_plan.py,变成唯一的数据源:
python
# 沙盘外轮廓:超椭圆(中心 PLAT_C,半轴 a 沿路、b 进深,指数 n;n=2 为椭圆,越大越接近圆角矩形),含门前国道
PLAT_C = (120.0, 17.0)
PLAT_A, PLAT_B, PLAT_N = 125.0, 49.0, 2.2
def platform(n_pts=360):
"""超椭圆外轮廓点列(逆时针)"""
out = []
for i in range(n_pts):
t = 2 * math.pi * i / n_pts
c, s = math.cos(t), math.sin(t)
out.append((PLAT_C[0] + PLAT_A * math.copysign(abs(c) ** (2 / PLAT_N), c),
PLAT_C[1] + PLAT_B * math.copysign(abs(s) ** (2 / PLAT_N), s)))
return out
概念图里那种「圆角胶囊形」沙盘,用一个指数 2.2 的超椭圆就能拟合得很好。
规划坐标统一用 (u, v):u 沿门前道路向南,v 垂直道路、指向场内。Blender 和 three.js 各有一条固定的换算公式(three.js 里是 x = 110 - u, z = 40 - v)。三端共用一套坐标系,路线、锚点、标签就不会错位。

设计稿和甲方清单冲突时,以甲方的数量为准:概念图画了 12 个重卡位,甲方要 10 个,那就保留 10 个,把单个车位放宽到 6.4 m,整排长度和概念图保持一致。
四、Blender 脚本化建模
为什么不手工建模?因为布局改了好几轮,每次手工挪几十个对象是噩梦。用脚本建模的话,改完数据重跑一遍就行:
python
# Blender 里通过 MCP 执行
import energy.build as b; b.run() # 清空场景,按 site.json 重建,保存 .blend
import energy.export as e; e.export() # 导出 station.glb + vehicles.glb
脚本按职责分成几个模块:
| 模块 | 职责 |
|---|---|
palette.py |
材质与程序化贴图(沥青、混凝土、光伏板、价格屏......) |
assets.py |
预制件:加油机、罩棚、换电站、LNG 储罐、超充桩、路灯、树...... |
layout.py |
读 site.json,换算坐标,摆放全部要素,合并地面 |
lights.py / bake.py |
烘焙专用灯光与 Cycles 烘焙 |
vendor.py |
外部车辆模型的减面、换材质、实测车轴 |
命名就是接口 。three.js 是按对象名驱动模型的:pile_C01~pile_C10 是超充终端,bay_C01 是车位描边,anchor_fuel 是标签锚点,canopy_fuel_light 是罩棚灯面。改名会直接让前端找不到对象,所以这些名字写进了文档,当作约定来维护。
导出时有几个值得一提的选项:
python
bpy.ops.export_scene.gltf(
export_format="GLB",
export_image_format="WEBP", # 贴图转 WebP,体积小很多
export_extras=col_name == "VEHICLES", # 车轴位置(rig_*)走 glTF extras → three 的 userData
export_gpu_instances=True, # 几百棵树、路灯导出为 GPU 实例
# ......
)
五、夜景:把 Cycles 的光「烘」进 three.js
概念图的质感几乎全来自光:罩棚下被照亮的站坪、路灯下边缘清楚的暖色光斑、树下的投光,还有湿润路面上的灯光倒影。three.js 实时光照做不出这种效果,路灯有几十盏,实时阴影也扛不住。
5.1 地面光照烘焙
做法是:在 Blender 里给罩棚、路灯、树下各放一盏烘焙专用灯(不导出),用 Cycles 把最终画面 (COMBINED:贴图 × 光照 × 阴影)烘进一张地面贴图。three.js 里地面直接显示这张图:
js
_applyGroundBake(tex) {
tex.flipY = false // 与 glTF 的 UV 原点约定一致
tex.colorSpace = THREE.SRGBColorSpace
tex.channel = 1 // 走第二套 UV(Lightmap)
this.groundMat = new THREE.MeshStandardMaterial({
color: 0x000000, // 基色为黑:不受实时光影响
emissive: 0xffffff,
emissiveMap: tex, // 烘焙结果当自发光显示
emissiveIntensity: THEME.ground.intensity,
roughness: THEME.ground.roughness, // 低粗糙度 + 环境反射 → 湿润路面的光泽
metalness: 0,
envMapIntensity: THEME.ground.env
})
// ......
}
关键点是黑色基色 + 烘焙图当自发光:地面完全不吃实时光,看到的就是 Cycles 渲染的结果,同时还保留了 PBR 的环境反射,可以做出湿路面的光泽。
烘焙这一步踩了两个坑:
- 重叠的路面烘成一片黑 :地面由很多层多边形拼成(沥青、混凝土站坪、草地、标线),合并后用一套从正上方平铺的 Lightmap UV。上下重叠的面如果完全共面,Cycles 烘焙时光线会打到另一块面上,整片变黑。解决办法是给每一层错开 1.5 mm 的高度,后建的层盖住先建的层。
- 棱柱的侧面、底面和顶面抢像素 :平面投影的 UV 里,侧面和底面会和顶面映射到同一块像素。合并时只保留朝上的面(
normal.z >= 0.3)就好了。
整图 6144 宽、96 采样,在 M2 GPU 上大约要 3 分钟。MCP 调用会超时,所以把烘焙交给 bpy.app.timers 在 Blender 里异步执行,调用本身立即返回。
5.2 湿路面倒影
湿沥青上的灯光倒影有个规律:它总是沿着「光源脚下 → 观察者」的方向拉长。正交相机的视线方向是固定的,所以只要每帧把所有光带转到同一个角度即可:
js
/** 按相机水平方向转动所有光带(方向变化很小时跳过) */
update(camera, target) {
const yaw = Math.atan2(
camera.position.x - target.x,
camera.position.z - target.z
)
if (this._yaw !== null && Math.abs(yaw - this._yaw) < 0.002) return
this._yaw = yaw
this._q.setFromAxisAngle(new THREE.Vector3(0, 1, 0), yaw)
this.sources.forEach((s, i) => {
this._p.set(s.pos.x, 0.08, s.pos.z)
this._s.set(s.width, 1, s.length)
this._m.compose(this._p, this._q, this._s)
this.mesh.setMatrixAt(i, this._m)
})
this.mesh.instanceMatrix.needsUpdate = true
}
几十条光带(每盏路灯一条,罩棚彩色檐口每 3 m 一条)共用一个 InstancedMesh,用加法混合,实例颜色就是光色乘以强度,总共只有一次绘制调用。
5.3 发光与色调
- 色调映射用
NeutralToneMapping:ACES 会把高亮的彩色灯带褪成白色,Neutral 能保住青、紫、绿几种灯带的色相; - 自发光按材质单独给强度 :Blender 导出的自发光强度整体压低,最亮的几样(罩棚檐口、加油机灯带、车棚绿光)再在
THEME.glow.override里单独指定,保证它们在辉光里清楚地亮起来; - 光伏板的天光反射 :夜景里光伏板映着天空泛蓝,实时环境反射太弱,就给
M_pv加一层均匀的蓝色自发光来近似; - 雾 :远山和村庄按深度隐入夜色,但村庄的窗光材质关掉
fog,要让它们在夜色里透出来; - 后处理 MSAA :
EffectComposer默认的渲染目标没有抗锯齿,开辉光后边缘会发虚,给WebGLRenderTarget加上samples: 4就好了。

六、让三维画面和设计稿逐像素对齐
概念图是放在整屏效果图中间的(左右两侧是数据面板),场站要刚好落在面板之间。常见做法是按包围盒自适应,但那样永远对不准。
既然已经标定出了设计稿的相机,就可以直接用标定结果来算正交视锥:先指定世界里的锚点落在设计稿的哪个像素,再定下每米多少像素,left/right/top/bottom 就全确定了。
js
// 节选,有删减
_fitFrustum() {
const w = this.container.clientWidth
const h = this.container.clientHeight
const s = w / DESIGN_W // 页面按 1920 设计稿等比缩放
const { anchor, at, scale } = THEME.view // 锚点、锚点所在像素、像素/米
// 把锚点变换到主视角相机(probe)的相机空间
const a = new THREE.Vector3(...anchor).applyMatrix4(probe.matrixWorldInverse)
const k = 1 / scale // 米 / 设计稿像素
cam.left = a.x - at[0] * k
cam.right = cam.left + DESIGN_W * k
cam.top = a.y + at[1] * k
cam.bottom = cam.top - (h / s) * k
cam.updateProjectionMatrix()
}
这样得到的是一个不对称视锥 ,场站自然避开了左右面板,窗口缩放时也按设计稿宽度等比缩放。设计稿 1536 宽的 8.33 px/m,放进 1920 整屏的 1096 宽区域后换算成 5.94 px/m,正好就是 THEME.view.scale。
另外,_toDesign() 把世界坐标投影回设计稿坐标,HTML 标签(设施名称、超充桩详情浮窗)每帧按它定位,和三维对象始终对得上。
七、开场动画:从卫星图「长」出能源站
进入页面先播放一段 8.6 秒的开场动画,讲清楚「这个站是怎么来的」:

| 时间(秒) | 画面 |
|---|---|
| 0.0--0.8 | 俯视镜头,卫星底图淡入 |
| 0.6--2.4 | 用地红线逐段描出(按索引数 setDrawRange 逐段显示) |
| 2.0--4.2 | 各功能分区色块与名称依次落下 |
| 4.2--7.6 | 设施从地面「长」出来,由北向南扫过 |
| 4.6--8.4 | 镜头从俯视缓缓倾斜到主视角 |

「长出来」的实现很朴素:把所有高出地面的对象先压扁(scale.y ≈ 0),再按 x 坐标排出延迟,逐个用缓动恢复原高:
js
for (const r of this.risers) {
const g = ease(span(t, 4.2 + r.delay, 5.6 + r.delay))
r.o.scale.y = r.scale * Math.max(0.001, g)
}

这里有三个容易忽略的细节:
- 空对象的包围盒是空的 :
anchor_*标签锚点是没有几何的空对象,Box3为空,算出的中心是NaN。它混进延迟计算后,所有设施的scale都会变成NaN,整个「长出来」阶段全部不可见,结束时才突然出现。解决办法是用box.isEmpty()跳过它们; - 卫星图不够大 :截图只覆盖约 447 × 370 m,俯视镜头斜着看时屏幕角落会露出黑底。处理办法是用 OpenCV 把截图四周镜像外扩 32%,外扩部分越往外越模糊、越暗,最外一圈渐隐到页面背景色。顺便用
cv2.inpaint把截图上手画的旧红线和地图 UI 抹掉; - 俯视用对称视锥,倾斜时再过渡到设计稿的不对称视锥:开场时面板是隐藏的,不需要给面板让位,俯视阶段场站居中、国道横在画面下方;随着镜头倾斜,视锥宽高保持不变、中心线性插值到主视角的不对称视锥,方位角同时从「正对国道」转到设计稿的斜视角。
八、车辆:牵引车 + 挂车倒车入位
小车加油、CNG 出租车加气、换电车穿过换电站、LNG 重卡加气、LNG 槽车卸车,这些都是沿手写路线行驶、停车、离开。最有意思的是重卡倒车入位充电:

行驶模型是一个够用的运动学近似:
- 路线是一条 Catmull-Rom 曲线(向心参数化,转角不过冲),
s是前轴在曲线上的弧长位置; - 车身由前轴点
P(s)和后轴点P(s - 轴距)决定位置和朝向,转弯时车身自然沿切线跟随; - 挂车的牵引销挂在牵引车的鞍座上,挂车后轴组取曲线上更靠后的点,于是转弯时挂车会「内切」;
- 倒车 时,后轴点在前方
P(s + 轴距),挂车后轴组也取曲线上更前方的点:挂车领先沿曲线拐进车位,牵引车跟着推。
js
// 节选,有删减
place(curve, s, reverse = false) {
const F = pointAt(curve, s, new THREE.Vector3())
const r = this.rigs[0]
const wb = r.front - r.rear
const R = pointAt(curve, reverse ? s + wb : s - wb, new THREE.Vector3())
const h = F.clone().sub(R).normalize() // 车头朝向
const c = F.clone().addScaledVector(h, -r.front)
this.parts[0].position.copy(c)
this.parts[0].rotation.set(0, yaw(h.x, h.z), 0)
if (this.kind === "semi") {
// 鞍座 → 挂车后轴组:挂车绕牵引销转动
const t = this.rigs[1]
const K = c.clone().addScaledVector(h, r.fifth)
const back = r.front - r.fifth + t.kingpin - t.rear
const T = pointAt(curve, reverse ? s + back : s - back, new THREE.Vector3())
const g = K.clone().sub(T).normalize()
this.parts[1].position.copy(K.clone().addScaledVector(g, -t.kingpin))
this.parts[1].rotation.set(0, yaw(g.x, g.z), 0)
}
}
前轴、后轴组、鞍座、牵引销的位置,是 vendor.py 在 Blender 里对轮胎顶点聚类实测 出来的,写进 glTF extras,three.js 里从 userData.rig_* 读取。换一个车型模型,铰接也不会穿帮。
进站路线分两段:先沿车位前的通道越过目标车位 25 m,再沿一段 8 m 的倒车弧线退进车位。车停稳后插上充电线(TubeGeometry 沿二次贝塞尔曲线生成,发光材质随时间呼吸),车位描边变成绿色。
速度控制也很简单:按到下一个停车点的剩余距离算出刹车上限 √(2·a·d),再和限速取最小值(国道快、站内慢、倒车更慢),车就能平滑地加减速、刚好停在停车点上。
九、运营仿真:让数字和画面对得上
没有后端,数据全部来自一个纯 JS 的仿真器,按真实时钟运行:
- 每类补给(加油、CNG、LNG、换电)各有一条 24 小时车流曲线(早晚高峰、夜间货运),每秒按泊松过程决定要不要发一辆车;
- 10 个超充位各是一个状态机:
idle → arriving → charging ⇄ fault → leaving → idle。SOC 70% 前满功率,之后线性回落;偶尔「枪温过高」,降功率 60~90 秒,地面冒出红色告警波纹; - 库存会联动:加油扣减油罐,LNG 低于 35% 自动叫槽车补液,CNG 压力随售气下降、压缩机启动后回升。

一个让面板「可信」的小设计是:场景接住了这辆车,仿真器才计数。
js
/** 发起一次补给车次(场景接住才计数并扣库存) */
_trip(kind, now) {
const ok = this.onTrip({ kind, plate: this._plate(kind === "lng") })
if (ok === false) return // 车道占满,三维里放不下这辆车,就不计数
this.trips++
this.count[kind]++
// ......扣库存
}
这样面板上的今日补给车次和画面上进出的车,在节奏上是一致的。不会出现画面里空无一车,数字却在疯涨的情况。
另外,充电会话做了「演示加速」:一次充电几分钟就走完,保证画面上始终有重卡进出。其余车流则按真实车流率。
十、还原度检查:别靠肉眼
「看起来差不多」是最不靠谱的验收标准。我写了两个小工具:
bash
# 无头 Chrome 打开页面,等开场动画结束,把镜头固定在主视角,隐藏面板截图
node scripts/energy/capture.mjs https://localhost:8892 out.png --clean --design-frame
# 与设计稿对齐,输出三联图:设计稿 | 实现 | 边缘叠图
python3 scripts/energy/compare.py out.png
叠图里,设计稿的边缘描成品红,实现的边缘描成青色,重合的地方偏白,哪栋楼偏了一眼就能看出来:

位置对齐之后再对光影:按区域取平均色,和设计稿的同一区域比较亮度比,逐项调到接近 1:
| 区域 | 实现 / 设计稿 |
|---|---|
| 加油站坪 | 1.10 |
| 罩棚下 | 0.94 |
| LNG 站坪 | 0.92 |
| 国道 | 0.89 |
| 车位前通道 | 1.10 |
| 办公楼 | 0.92 |
| 光伏屋面 | 0.94 |
| 远山 | 0.96 |
这个过程中定位到了好几个只靠肉眼很难发现的问题。比如加油站坪「又亮又偏蓝」,用 --eval 逐个关掉候选光源对比后,确认是两个原因叠加:一是 RectAreaLight 在低粗糙度地面上的高光把站坪刷白了,二是青色檐口灯带的辉光把整片站坪染青了。两者分别压低强度后,站坪就回到了设计稿的暖白色。
十一、踩坑汇总
| 现象 | 原因 | 解决 |
|---|---|---|
| 烘焙后部分路面一片黑 | 重叠的地面多边形共面 | 每层错开 1.5 mm 高度 |
| 烘焙后建筑周边地面发黑 | 棱柱的侧面、底面在平面投影 UV 里抢像素 | 合并地面时只保留朝上的面 |
| 彩色灯带在辉光里变白 | ACES 色调映射压高光 | 改用 NeutralToneMapping |
| 开启后处理后边缘锯齿发虚 | 离屏渲染目标没有 MSAA | WebGLRenderTarget({ samples: 4 }) |
| 开场俯视时屏幕角落是黑的 | 卫星截图覆盖范围不够 | 镜像外扩 + 渐进模糊 + 渐隐到背景色 |
| 开场镜头偏到底图外面 | 场站中心按模型包围盒算,而包围盒包含了后方远山 | 改用沙盘外轮廓计算中心 |
| 设施「长出来」时全部不可见 | 空锚点的 Box3 为空,中心是 NaN,污染了延迟 |
跳过 box.isEmpty() 的对象 |
| 开场时卫星图上飘着绿色线条 | 充电线没跟着车辆一起隐藏 | 充电线、告警挂到车辆分组下 |
| 半挂车倒车时挂车方向反了 | 挂车后轴组仍取 s - back |
倒车段改取 s + back |
十二、总结
回头看,这个项目最值得分享的不是哪个炫酷的效果,而是几个朴素的做法:
- 单一数据源 + 全脚本生成:规划数据只写在一处,规划图、Blender 布局、页面数据都由它生成。改了五六轮布局,每一轮都只是重跑流水线;
- 把设计稿当「测量对象」:标定相机、反投影,把一张 AI 概念图变成可以精确还原的规格;
- 离线烘焙 + 实时叠加:静态的光影交给 Cycles,动态的部分(车灯、倒影、告警、辉光)交给 three.js,各做擅长的事;
- 用工具验收:截图、叠图、按区域测色,还原度从「感觉像」变成「数字接近 1」。
甲方给的是一张卫星截图,交出去的是一座会呼吸的数字能源站。
说明:场址为假定地块,运行数据为仿真数据。车辆模型来自 Sketchfab(CC BY 4.0),页面中已署名。