
前言
做大屏的同学应该都接过这种需求:「做一个动态的、炫一点的可视化大屏,下周客户来看。」
以前这种活儿,找设计、找素材、调特效,没个三五天下不来,最后还经常被评价「感觉不够高级」。这次我换了个做法:从出设计稿、到建 3D 模型、再到做成能跑的大屏,全程和 AI(Claude Code)一起做,一天时间把一个「光储充一体化超充站」的数字孪生大屏跑了起来。
和之前那个纯代码建模的校园大屏不同,这次刻意走了另一条路:模型在 Blender 里建,Three.js 只负责展示和驱动。原因很简单------充电站里车、桩、光伏板、储能柜这些东西,用 Three.js 的几何体拼出来很难有「真实感」。
这篇文章会聊聊:
- 设计稿怎么定:两版几何体草图被否掉之后,改用 AI 生图出概念稿
- 怎么让 AI 通过 MCP 直接操控 Blender,用 Python 脚本「写」出整座场站
- 从 Sketchfab 下载的车辆 / 充电桩模型,怎么批量减面、换材质、统一朝向
- Three.js 里正交相机适配设计稿、光照烘焙、辉光调校、光伏反光着色器
- 没有后端,运行数据怎么仿真得「像真的」
- 一路踩过的坑
技术栈:Vue 3 + Vite + Three.js r186 + Blender 5.2(Python 脚本建模)。
一、效果与功能
页面是 1920×1080 的大屏,中间是悬浮在暗场里的三维场站沙盘,左右两侧和底部是数据面板:

场站是一个大型「光储充」一体化超充站:
| 区域 | 内容 |
|---|---|
| 快充区 | 3 排光伏车棚,30 根双枪直流快充桩(B01--B30),60 个车位 |
| 超充区 | 4 根液冷超充终端(A01--A04),桩顶带状态光环 |
| 能源区 | 6 台储能柜(合计 2 MWh)、2 台箱式变压器、配电柜,钢丝网围栏 |
| 服务楼 | 两层剖切:一层司机之家,二层监控中心(大屏墙、值班台) |
| 出入口 | 道闸、价格立柱、斑马线 |
功能上主要是这几块:
| 功能 | 说明 |
|---|---|
| 状态联动 | 车位描边、桩灯按「充电中 / 空闲 / 故障 / 离线」变色,桩顶悬浮状态图标 |
| 车辆进出 | 仿真驱动车辆滑入车位、插枪充电、充满驶离,道闸同步抬杆 |
| 故障告警 | 故障车位红色波纹扩散,右侧告警列表同步 |
| 点击详情 | 点任意桩或车位,弹出各枪 SOC、功率、电量、时长,每秒刷新 |
| 能量节点 | 四角「光伏 / 储能 / 电网 / 充电桩」节点用连线指向场站里对应设备,随镜头实时跟随 |
| 分时电价 | 按真实时钟切换谷 / 平 / 峰 / 尖时段,储能「谷充峰放」 |
| 无人值守 | 空闲时镜头缓慢左右摆动,人工拖拽后 15 秒自动恢复 |
二、设计稿:几何体草图做不出「高级感」
一开始我让 AI 用 Three.js 几何体快速拼了两版设计稿(场站 + HTML 面板合成),结果都被我否了------布局没问题,但就是「丑」,一眼就是程序员画的方块:
后来我找了一张光储充 EMS 大屏的参考图,让 AI 先拆解参考图的风格(悬浮沙盘、正交等轴俯视、暗场蓝调、四角能量节点、青绿红三色发光),整理成生图提示词,再用 AI 生图出中央场站的概念图,最后把面板合成上去:
这一步的经验是:视觉设计稿交给生图模型,别用代码几何体凑。生图模型对材质、光影、氛围的把握远比几何体草图强,而且出图快,改风格只要改提示词。设计稿定下来之后,后面的建模、调光都是「照着设计稿还原」,目标非常明确。
三、整体架构:Blender 出模型,Three.js 只做展示
整条管线是这样的:
arduino
AI 概念图(设计稿)
│ 照着还原
▼
Blender Python 脚本 ──(MCP 执行)──► station.blend
│ export.py │ bake.py(Cycles 烘焙地面光照)
▼ ▼
station.glb + cars.glb ground_bake.webp
│
▼
Three.js 场景(按对象名驱动) ◄── 仿真器(车辆、功率、告警)
│
▼
Vue 3 数据面板(SVG 图表)
目录结构:
bash
scripts/blender/charging/ Blender 建模脚本(约 2400 行 Python)
├── build.py 入口:清场 → 建模 → 预览渲染 → 保存 .blend
├── lib.py 几何工具:box / 圆柱 / 管线 / 棱柱 / UV
├── palette.py 材质与程序化贴图(numpy 画沥青、光伏、屏幕...)
├── assets.py 预制件:充电桩、雨棚、储能柜、箱变、树、道闸...
├── building.py 两层剖切服务楼
├── layout.py 场站总平面:按坐标摆放所有东西、统一命名
├── vendor.py 外部模型(Sketchfab)标准化
├── bake.py Cycles 烘焙地面光照贴图
└── export.py 导出 GLB
src/views/charging/ 页面(约 4200 行)
├── index.vue 页面外壳:布局、取数、事件转接
├── scene/ChargingScene.js 三维场景(不依赖 Vue)
├── scene/pvSheen.js 光伏反光着色
├── scene/theme.js 配色、相机、辉光参数
├── sim/simulator.js 运行仿真
├── data/station.js 场站静态数据(桩号、电价、设备)
└── components/*.vue 各个面板
两边靠对象名 约定连接:Blender 里叫 pile_B01(下挂 _body / _screen / _status)、bay_B01_N、gate_in_arm、ess_01,Three.js 就按这些名字找到对象去改颜色、做动画。改名必须两边一起改,这条写进了项目的 CLAUDE.md 里,防止 AI 后续「顺手」改坏。
四、Blender 建模:用 Python 脚本「写」出一座场站
为什么是脚本,而不是手动建模
Blender 官方有一个 MCP 插件(Blender Lab MCP),装上之后 AI 可以直接往 Blender 里发 Python 代码执行。但我没有让 AI「在 Blender 里点点点」,而是把整个建模过程写成仓库里的 Python 脚本,AI 只负责执行:
python
import sys; sys.path.insert(0, "<repo>/scripts/blender")
import charging.build as b
b.run() # 清空场景 → 重建全部模型 → 保存 models/charging/station.blend
import charging.export as e
e.export() # 导出 public/charging/station.glb + cars.glb
好处非常明显:
- 可重复 :
.blend和 GLB 都是「构建产物」,随时一键重建,不怕手滑改坏 - 可评审:模型改动就是代码 diff,和前端代码一起进 git
- 好迭代:「雨棚加宽到 11.2 米」就是改一个常量,重跑 5 秒出结果
场站总平面全部是坐标常量:
python
SITE = (-48.0, 30.0, -28.0, 36.0) # 场地范围(x0, x1, y0, y1),78 × 64 m
BASE_H = 3.2 # 沙盘厚度
FAST_ROWS = [24.0, 6.0, -12.0] # 三排快充桩岛的 y 坐标
FAST_X0, FAST_N, BAY_W, BAY_D, ISLAND = -44.0, 10, 2.8, 5.5, 1.2
# 雨棚宽度:罩住桩岛和两侧车位的大部分,排间留约 7 m 行车道
CANOPY_W = 11.2
预制件用 bmesh 拼。比如储能柜:柜体、门板、门缝、百叶、角柱灯带,一个函数搞定,材质按槽位分:
python
def ess_cabinet(M):
"""
液冷储能柜(250 kWh):2.6 × 1.7 × 2.7,正面朝 -y。
浅灰绿色哑光柜体(不发光),前后各 3 扇门板(门缝 + 下部百叶 + 把手),侧面散热百叶;
发光只在细节上:四角竖向灯带、顶沿一圈灯带、门缝透光,外加正面一块小状态屏
材质槽:0 状态屏 / 1 柜体 / 2 框架与深色细节 / 3 绿色灯带
"""
W, D, H, z0 = 2.6, 1.7, 2.5, 0.18
bm = bmesh.new()
lib.add_box(bm, (W, D, H), (0, 0, z0 + H / 2), bevel=0.03, mat=1) # 柜体
# ......门缝灯、百叶、把手、角柱灯带
return lib.make_mesh("ess_cabinet", bm, [M["ess_face"], M["ess_body"], M["ess_frame"], M["ess_led"]])
贴图也是程序化画的:沥青路面、地砖、光伏电池片、桩屏 UI、监控大屏,都是 numpy 直接画像素再存 PNG,导出 GLB 时转 WebP 打包进去。
每次改完,AI 会从大屏的相机角度渲一张 Eevee 预览图回来对照设计稿:

Blender 5 的几个坑
- 新建材质 / 世界不再自带节点 :
bpy.data.materials.new()之后node_tree是空的,要自己补 Principled BSDF + Material Output - 中文界面下节点名会被翻译 :按
nodes.get("Principled BSDF")找不到,统一改成按node.type == "BSDF_PRINCIPLED"查找 - 合成器 API 变了 :辉光要用
scene.compositing_node_group+ Glare 节点,Eevee 已经没有内置 Bloom - MCP 调用会超时,但 Blender 会继续跑完:Cycles 渲染 / 烘焙动辄几十秒,MCP 请求超时报错不代表失败,等输出文件出现就行
五、外部模型:Sketchfab 下载的车和桩怎么「标准化」
场站结构自己建,但车是真的建不过来------车身曲面、轮毂、车灯这些,自己用 bmesh 拼出来就是玩具车。所以车辆和快充桩机身用了 Sketchfab 上的免费模型(CC BY 许可,署名见文末)。
问题是下载下来的模型五花八门:单位不统一(有的是米,有的高 194 个单位)、朝向不统一(车头有朝 -y 的、有朝 +x 的)、面数离谱(大众朗逸一辆车 12 万面)、还带着完整的内饰。
于是写了一个 vendor.py,用一张「规格表」把每个模型标准化:
python
SPEC = {
# 特斯拉:车头在 -y(前大灯的位置),整体高 194 个单位 → 实车 1.44 m
"sedan": dict(
dir="tesla_model_3", height=1.44, rot=90, paint="CAR_PAINT",
drop=("Material.015",), replace={"Glass": "glass", "chrome": "chrome"}, decimate=0.6,
),
# 大众朗逸:米制;后备箱内衬在 +y → 车头 -y,转 90°;约 12.5 万面,删内饰后约 7 万再减面
"lavida": dict(
dir="2020_volkswagen_e-lavida_phev", height=None, rot=90, paint="carpaint",
drop=("inner_map", "inner_chair_map", "inner_chair_2", "inner_chair_3", "inner_door_1", "trink"),
replace={"glass": "glass", "black_glass": "glass"}, decimate=0.2,
),
# ......宝马 X6M、日产轩逸、充电桩
}
每个模型的处理流程都一样:
- 导入 glTF,把所有部件合并成一个网格,世界变换烘进顶点
- 按材质删掉内饰:大屏是俯视,座椅、仪表台根本看不到,删掉直接省一半面数
- 半透明车窗换成不透明深色玻璃:内饰删了,原来的半透明玻璃会透出空车厢
- 绕 z 轴旋转统一朝向,按目标高度缩放到米,原点放到底面中心
- 贴图缩到 512(车在大屏上只占几十像素)
- Decimate 减面,最终每辆车约 1.4 万面
朝向、内饰在哪,都不是猜的------先导入一遍,按材质统计每组面的包围盒和中心点,比如「红色自发光材质在 +x → 那是尾灯 → 车头朝 -x」,再据此填规格表。

车漆:一份几何 + 运行时换色
一开始每种车型 × 6 种漆色各导出一份,cars.glb 直接膨胀到 12 MB------6 种颜色就存了 6 份一模一样的几何。
改成每款车只导出一份,车漆统一用一个叫 M_car_paint 的材质,Three.js 加载后按漆色克隆材质,摆车时替换:
js
// 车漆:每款车只导出一份,车漆材质 M_car_paint 按漆色克隆(每种颜色一份,所有车共享)
this.paints = {}
this.carPrefabs.traverse((o) => {
if (!o.isMesh) return
if (o.material.name === "M_car_paint" && !this.paints.white) {
for (const [k, c] of Object.entries(PAINT_COLOR)) {
const m = o.material.clone()
m.color.setHex(c)
this.paints[k] = m
}
}
})
// 摆车时:克隆预制(共享几何),只把车漆换掉
const obj = prefab.clone()
obj.traverse((o) => {
if (o.isMesh && o.material.name === "M_car_paint") o.material = this.paints[car.paint]
})
cars.glb 从 12 MB 降到 2.6 MB,同屏 40 多辆车、全场约 90 万面,M2 上稳定 60 帧。
六、Three.js 展示:让实时画面接近设计稿
1. 正交相机「对准」设计稿里的那块区域
设计稿里场站不是居中铺满的:左右各让出 440px 给面板,底部让出 200px 给通栏。所以相机不能简单地 fitToBox,而是要把场站投影到设计稿坐标里的一个目标框:
js
fit: { x: 450, y: 80, w: 1020, h: 790 }, // 设计稿 1920×1080 下的像素坐标
做法是:用主视角先算一遍场地 8 个角点在相机空间的包围范围,再反推正交视锥的 left / right / top / bottom,让包围范围刚好落在目标框里:
js
const scale = Math.max((maxX - minX) / fit.w, (maxY - minY) / fit.h)
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
const fx = fit.x + fit.w / 2
const fy = fit.y + fit.h / 2
cam.left = cx - fx * scale
cam.right = cx + (DESIGN_W - fx) * scale
cam.top = cy + fy * scale
cam.bottom = cy - (Hd - fy) * scale
页面用 rem 等比缩放,场景也按「实际宽 / 1920」换算,所以不管屏幕多大,场站永远落在面板中间那块空位里。四角能量节点的连线端点,也是把场站里设备的世界坐标投影回设计稿坐标得到的,镜头摆动时连线跟着动。
2. 地面光照:Cycles 烘焙进贴图
实时渲染里,雨棚下的灯光、灯带溢光、树和楼的软阴影都很贵。于是用 Blender 的 Cycles 把地面光照烘焙成一张 2048 的贴图,Three.js 里地面直接用无光照材质贴上去:
js
/**
* 地面换成 Cycles 烘焙的光照贴图:雨棚光斑、灯带溢光、建筑与树的软阴影都已「画」在贴图里,
* 用无光照材质直接显示,走第二套 UV(导出时的 Lightmap UV,three 里是 channel 1)
*/
tex.flipY = false // 与 glTF 的 UV 原点约定一致
烘焙时要先把车、充电线、告警波纹这些动态物体隐藏,否则它们的影子会被「烙」在地上。

3. 光伏板:正交相机下怎么做出「光影感」
这是调得最久的一处。设计稿里的光伏板有大片斜向反光、深浅不一的拼块感;实时画面里却是整片同一种蓝。
原因在正交相机:所有视线平行,一整片平面对环境的反射处处相同,实时光照根本算不出明暗变化。
解决办法分两步:
- Blender 里给每块板写一个随机明暗(顶点色 0.6~1.0),模拟真实阵列里每块板反光角度、批次色差的不同
- Three.js 里给光伏材质叠一层「天光反射」 :用
onBeforeCompile往标准材质里插一段着色器,按世界坐标算出几道斜向反光带,跨越整片阵列连续,并随时间缓慢漂移
glsl
// 沿对角线的坐标:反光带垂直于这个方向
float d = dot(vPvWorld.xz, vec2(0.78, 0.62));
float t = uTime * 0.12;
// 两道窄而亮的反光带 + 一道宽而柔的天光
float narrow = pow(0.5 + 0.5 * sin(d * 0.21 - t * 1.6), 12.0);
float narrow2 = pow(0.5 + 0.5 * sin(d * 0.33 + 2.1 - t * 1.1), 16.0);
float wide = pow(0.5 + 0.5 * sin(d * 0.075 + 1.7 - t), 3.0);
// 大尺度明暗起伏,打破规整
float drift = 0.5 + 0.5 * sin(vPvWorld.x * 0.045 - vPvWorld.z * 0.11 + 0.8);
float sheen = (1.1 * narrow + 0.8 * narrow2 + 0.5 * wide) * (0.5 + 0.5 * drift);
// 反光落在深色板上弱、浅色板上强(shade 来自顶点色)
totalEmissiveRadiance += sky * sheen * (0.15 + 0.85 * shade * shade) * 0.5;
叠加量写进自发光,不受场景灯光强弱影响。pow(..., 12.0) 把正弦波压成窄窄的亮带,两组不同频率、不同速度的亮带叠起来,画面里同时能看到好几道,还会慢慢移动------大屏上看起来就是「光在板子上流动」。
左边是改之前,右边是改之后(雨棚也同时加宽、改成单坡):

4. 辉光:灯带别糊成一片
暗场科技风离不开辉光(UnrealBloomPass),但一开始灯带全都糊成一团。排查下来是两件事叠加:
- 自发光材质被大量网格共享,强度被反复相乘 。Blender 里按 Eevee 调的自发光强度在 Three.js 里偏亮,需要统一压低,但如果遍历网格直接
*= 0.4,共享同一材质的 200 个网格就会让它乘 200 次。要按材质去重:
js
// (简化版)材质被许多网格共享,调整强度时每份材质只处理一次,否则会被反复相乘
const seen = new Set()
this.station.traverse((o) => {
const m = o.material
if (isEmissive(m) && !seen.has(m)) {
m.emissiveIntensity *= THEME.glow.emissiveScale
seen.add(m)
}
})
- 辉光阈值和灯带亮度没配合好 。灯带亮度远高于阈值,超出部分全部泛光。最后把参数集中到
THEME.glow里统一调:
js
glow: {
bloom: { strength: 0.32, radius: 0.2, threshold: 1.2 },
edgeTop: 1.9, // 沙盘顶边细灯带
edgeBottom: 1.4, // 沙盘底边细灯带
emissiveScale: 0.22, // 其余自发光相对 Blender 强度的倍数
wallWash: 0.55, // 侧壁底部泛光
floorHalo: 0.42 // 地面轮廓光晕
}
沙盘底座按设计稿改成「顶边一条清晰的蓝色细线 + 侧壁底部向上渐隐的蓝光 + 地面一圈柔和光晕」,后两者是沿圆角矩形轮廓生成的加色混合光带,用一张渐隐贴图控制衰减。另外渲染目标开了 4 倍 MSAA------后处理会绕开画布自带的抗锯齿,不开的话边缘全是锯齿:
js
const rt = new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType, samples: 4 })
this.composer = new EffectComposer(renderer, rt)

七、运行数据:没有后端,怎么仿真得像真的
大屏没有接后端,所有数据来自一个纯 JS 仿真器。原则是:跟着真实时钟走,规律符合行业常识,细节有随机但可复现。
分时电价:按一天的时段切换谷 / 平 / 峰 / 尖,顶栏实时显示当前电价:
js
export const PERIODS = [
[0, 7, "valley"], [7, 10, "flat"], [10, 12, "peak"], [12, 17, "flat"],
[17, 19, "peak"], [19, 21, "sharp"], [21, 23, "peak"], [23, 24, "valley"]
]
储能谷充峰放:谷时满功率充电,峰 / 尖时段放电,午间平时段吸收光伏,同时受 SOC 上下限约束:
js
function essPlan(t) {
const p = periodAt(t)
if (p === "valley") return -600 // 负为充电
if (p === "sharp") return 900 // 正为放电
if (p === "peak") return 800
if (t >= 12 && t < 15) return -300
return 0
}
光伏出力:日出 6:36、日落 18:36 之间的正弦曲线,乘一个天气系数,所以傍晚打开大屏会看到光伏功率很低,这是对的。
车辆会话 :每把枪走一个状态机------arriving → charging → full → leaving → idle。到站 3 秒后插枪(刚好是车辆滑入车位的动画时长),充电功率随 SOC 回落(80% 以下接近满功率,之后线性降到 30%),充满后停几秒驶离。为了大屏上一直有车进出,充电过程做了时间压缩。
随机数用固定种子(mulberry32),每次打开页面的初始车位分布都一样,方便演示和截图;故障桩、离线桩也是固定的,保证告警列表里永远有内容可看。
仿真器只通过回调把状态变化推给场景:
js
new ChargingScene({ canvas, container, sim, onPick, onAnchors })
// sim.onGun = (gun) => scene.updateGun(gun) 枪状态变了 → 改车位颜色、摆车 / 开走
八、踩坑合集
- 共享材质被反复相乘:GLB 里自发光材质被几百个网格共享,按网格遍历调强度会乘几百次。凡是「按比例调整材质参数」,一定要按材质去重
cars.glb体积膨胀:同一辆车 6 种漆色导出 6 份几何。漆色交给运行时换材质,体积从 12 MB 降到 2.6 MB- 正交相机 + 平面 = 没有反射变化:想要光影感只能自己在着色器里「画」出来
- 后处理吃掉抗锯齿:EffectComposer 的离屏渲染目标要手动开 MSAA
- 烘焙时动态物体要隐藏 :否则车辆的影子会永久留在地面贴图上;烘焙失败也要在
finally里恢复显示 - 烘焙 UV 判断顶面别用法线 :bmesh 刚建好时法线还没更新,
f.normal不可靠,按顶点高度判断 - SVG 坐标出现 NaN:标签页切到后台时容器宽度为 0,缩放比为 0,投影换算全变 NaN,要跳过这一帧
- 哈希路由切换不会重新加载页面:调试时改了 GLB,只改 URL 的 hash 不会重新加载,得强制刷新
九、总结
回头看,这个项目里 AI 参与了几乎每一个环节:
| 环节 | 以前 | 这次 |
|---|---|---|
| 设计稿 | 找设计师 / 自己凑 | 拆解参考图风格 → AI 生图 → 合成面板 |
| 3D 建模 | 学 Blender、手动建模 | AI 写 Python 脚本,通过 MCP 在 Blender 里执行 |
| 外部模型 | 手动导入、调方向、减面 | 规格表 + 脚本批量标准化 |
| 调效果 | 反复改参数肉眼对比 | 截图和设计稿对照,指出差异再改 |
但我最大的感受是:人的作用变成了「定方向 + 挑毛病」。设计稿丑不丑、光伏板有没有光影感、储能柜是不是整体都在发光------这些判断还是得自己来。比如「光伏板颜色全是同一种蓝,没有设计稿那种光影感」这一句话,AI 就能定位到是正交相机导致反射没有变化,再给出顶点色 + 着色器的方案。
几个可以复用的经验:
- 视觉稿交给生图模型,代码几何体只适合画线框图
- 建模写成脚本 ,
.blend和 GLB 当构建产物,模型改动进 git - 两端靠命名约定连接,Blender 负责「长什么样」,Three.js 负责「怎么动」
- 能烘焙的光照就烘焙,实时只算真正会变的部分
- 辉光参数集中管理,阈值、强度、各类灯带亮度放在一处调
附:模型署名
场景中的车辆与快充桩机身使用了以下 Sketchfab 模型(经减面、换材质后使用):
| 模型 | 作者 | 许可 |
|---|---|---|
| Tesla Model 3 | David_Holiday | CC BY 4.0 |
| Low Poly BMW X6M Competition | SharkyStudios | CC BY 4.0 |
| EV Charging Station | np-dev | CC BY 4.0 |
| 2020 Volkswagen e-Lavida PHEV | Ddiaz Design | CC BY-NC-SA 4.0 |
| 2018 Nissan Sylphy EV Zero Emission | Ddiaz Design | CC BY-NC-SA 4.0 |
后两款为非商业许可,本项目为非商业演示。