
前言
「数字孪生楼宇」几乎是智慧园区类大屏的标配需求:客户希望从城市一路点进园区、点进某栋楼、某一层,最后看到某个房间里的某台空调。
这类需求的难点不在数据,而在于五级都要好看:城市要有立体感,园区要有夜景氛围,楼宇要能剖开看到每一层,楼层要能看清工位,房间要能点到具体设备。任何一级做得像「几何体拼的方块」,整个大屏的观感就塌了。
这次我和 AI(Claude Code)一起,把这五级完整做了出来:

这篇文章会聊聊:
- 五级钻取的整体架构:哪些用 Three.js 实时画,哪些交给 Blender 建模烘焙
- 城市级:OSM 街道边界做成立体地图
- 园区级:按真实地图布局建模,用 Cycles 把夜景灯光「烘」进地面
- 楼宇级:58 层的写实塔楼,怎么做到 60 帧 / 秒
- 楼层级、房间级:一份布局数据,Blender 和前端两边共用
- 一路踩过的坑(有几个真的很隐蔽)
技术栈:Vue 3 + Vite + Three.js r186 + Blender 5.2(Python 脚本建模 + Cycles 烘焙),数据全部是本地演示数据,无后端。
一、效果与功能
页面是 1920×1080 的大屏,五级之间通过顶栏面包屑和场景里的入口按钮来回切换。
1. 城市级:成都高新区立体地图

- 高新区南区 5 个街道立体拉伸,入场时地图从下方升起、光柱一根根长出来
- 每根光柱是一栋高层楼宇,高度对应真实楼高;悬浮街道整块高亮并弹出街道卡片
- 金色光柱是重点园区「成都金融城双子塔」,点击卡片进入园区

2. 园区级:金融城双子塔夜景

- 按 OpenStreetMap 的真实布局建模:双子塔、9 栋鹅卵石形办公楼、椭圆会议中心
- 路灯光斑、楼体溢光、树影都烘焙在地面里;车流、湖面倒影、告警波纹是实时的
- 悬浮楼栋高亮,点击弹出楼栋卡片,双子塔可以「进入楼宇」

3. 楼宇级:58 层全息剖切

| 功能 | 说明 |
|---|---|
| 透明塔身 | 蓝色玻璃幕墙 + 竖梃层线网格,透出每一层烘焙过的室内 |
| 电梯光柱 | 核心筒里一道蓝光,光点是正在运行的轿厢 |
| 地下剖口 | 底座正面挖开,露出 B1 车库、B2 配电 / 冷冻机房、B3 水泵房,告警设备红色闪烁 |
| 楼层抽屉 | 选中某层,它像抽屉一样从塔身里抽出(金色边框),标签上可「进入楼层」 |
| 四种视图 | 楼层剖切 / 透视外立面 / 机电系统 / 人员热力 |



4. 楼层级:去顶俯视

- 掀开天花板看整层:核心筒(6 部电梯、2 部楼梯、卫生间)、开放办公、会议室、经理室、茶水间、弱电机房
- 上下各两层线框楼层做「爆炸剥离」
- 四种视图:房间(按类型着色)/ 设备点位 / 温度热力 / 工位占用(在岗、预约、空闲)

5. 房间级:会议室与资产

- 透明吊顶,能看到里面的空调、风管、喷淋管
- 15 件资产(空调、灯盘、烟感、喷淋、摄像头、AP、门禁......)都能悬浮、点选,右侧弹出资产卡片、实时曲线、运维记录
- 按暖通 / 安防 / 消防 / 照明筛选,告警设备(烟感电量低)红色呼吸

二、设计稿:先出概念图,再照着还原
和之前做充电站大屏一样,设计稿没有用代码几何体去拼,而是先让 AI 生图出四张概念图(园区、楼宇、楼层、房间),再按概念图出整屏稿:

有了概念图,后面每一级都是「照着设计稿还原」:每做完一版就截图和设计稿并排对比,逐条找差异。这篇文章里的对比图都是这么来的。
三、整体架构:实时的交给 Three.js,好看的交给烘焙
五级的视觉要求差别很大,所以每一级的「实时 / 烘焙」比例也不同:
| 级别 | 模型来源 | 光照 | 实时部分 |
|---|---|---|---|
| 城市 | Three.js 程序生成(OSM 街道边界拉伸) | 实时 | 全部 |
| 园区 | Blender 脚本建模(OSM 布局) | 地面 Cycles 烘焙 | 窗灯、车流、水面、标签 |
| 楼宇 | 玻璃壳 Three.js + 楼层室内 Blender | 室内 Cycles 烘焙 | 玻璃、电梯光柱、抽屉、告警 |
| 楼层 | Blender(按布局数据建模) | Cycles 烘焙 | 房间着色、设备图标、热力、工位 |
| 房间 | Blender | 壳体与家具烘焙 | 资产设备实时光照、可点选 |
整条管线是这样的:
bash
OpenStreetMap(街道边界 / 楼栋轮廓 / 楼高 / 道路 / 水系)
│ Node 脚本(build-building-geo.mjs / build-park-layout.mjs)
▼
layout.json ──────────────► 前端数据 data/*.js(城市地图、园区布局、塔楼轮廓)
│
│ Blender Python 脚本(通过 MCP 让 AI 直接在 Blender 里执行)
▼
.blend ──► Cycles 烘焙(贴图 + 顶点色)──► OIDN 降噪 ──► 色调映射
│
▼ glTF 导出(meshopt 压缩)
public/building/*.glb ──► Three.js 加载,MeshBasicMaterial 直接显示烘焙结果
几个原则:
- 建模写成脚本 ,
.blend和 GLB 都是构建产物,改模型就是改 Python 脚本 - 能烘焙的光照都烘焙:室内几十盏灯的漫反射、软阴影,实时渲染算不起,烘进贴图之后运行时只是贴图采样
- 会变的东西才实时算:选中高亮、告警闪烁、电梯运行、数据着色
- 两端靠对象名连接 :Blender 里叫
dev_AC-3205-01的对象,前端就按这个名字找到它、绑定资产数据
页面本身只负责切换级别,当前级别同步到路由参数,任意一级都能直接打开:
shell
#/building?level=city
#/building?level=park
#/building?level=building&b=tower_S
#/building?level=floor&b=tower_S&f=32F
#/building?level=room&b=tower_S&f=32F
四、城市级:OSM 街道边界做成立体地图
城市级的立体地图移植自之前项目里的一个 mini3d 地图组件:GeoJSON 墨卡托投影 → 拉伸成带侧面的街道块 → 顶面贴底图、侧面渐变 → gsap 时间线编排入场动画。
数据来自两处:街道边界用 OSM 的行政区划,区县轮廓用 DataV GeoAtlas,高层楼宇的高度用 Overpass 查询 OSM 里的 height / building:levels。一个 Node 脚本把它们处理成前端直接用的数据文件。
入场动画就是一条 gsap 时间线:
js
const tl = gsap.timeline()
tl.addLabel("focusMap", 1.5)
tl.addLabel("bar", 3)
tl.addLabel("park", 3.6)
// 相机推进
tl.to(this.camera.instance.position, { duration: 2, x: ex, y: ey, z: ez, ease: "circ.out" })
// 地图从下方升起、放大
tl.to(this.focusMapGroup.position, { duration: 1, x: 0, y: 0, z: 0 }, "focusMap")
tl.to(this.focusMapGroup.scale, { duration: 1, x: 1, y: 1, z: 1, ease: "circ.out" }, "focusMap")
// ...... 光柱在 "bar" 依次长出,园区卡片在 "park" 落下
一个小细节:地图下方的大标题是 CSS3D 立牌,完全竖直会显得很「平」,完全正对屏幕又失去了立体感。最后的做法是在「竖直」和「正对相机」两个姿态之间插值,只转 10%:
js
const TITLE_TILT = 0.1 // 0 = 竖直,1 = 正对屏幕
const upright = label.quaternion.clone()
label.lookAt(new Vector3(...this.cameraConfig.end))
label.quaternion.copy(upright.slerp(label.quaternion, TITLE_TILT))
五、园区级:按真实地图建模,把夜景烘进地面
1. 布局来自 OSM
园区是天府国际金融中心:西侧双子塔(218 m、58 层,斜切屋顶),中间 9 栋鹅卵石形的办公楼和一座椭圆会议中心。一个 Node 脚本从 OSM 拿到楼栋轮廓、道路、草地、水系,输出 layout.json 给 Blender,同时输出一份前端数据。
OSM 只有平面和高度,没有立面造型。参考实景照片补了竖向信息:塔身向上收分、圆肩斜顶、鹅卵石楼的圆弧银色檐口。
2. 地面光照用 Cycles 烘焙
夜景大屏的质感一大半来自光:路灯在地上的暖色光斑、楼体对周围的溢光、树和楼的软阴影。这些实时渲染算不出来,所以在 Blender 里把地面的「最终画面」烘焙成一张 2560×4096 的图,Three.js 里地面直接用无光照材质显示:
js
groundTex.channel = 1 // 第二套 UV(Blender 里的 Lightmap UV)
const groundMat = new THREE.MeshBasicMaterial({ map: groundTex })

烘焙之外,实时部分只补几样会动的:车流(实例化的光点沿道路跑)、窗灯(四档亮度)、湖面(着色器里做波纹和倒影高光)、告警波纹。

道路、人行道、草地的颜色都调过几轮。有个教训:夜景烘焙里,材质明度要按「和相邻地物拉开」来定,不能按真实颜色定。真实的沥青和草地在夜里几乎一样黑,烘出来整片地面糊成一团。

六、楼宇级:58 层的写实塔楼怎么做
这是整个项目最难的一级。设计稿里塔身是一块发蓝光的玻璃体,透过玻璃能看到每一层亮着的工位、会议室、设备层。
1. 第一版的问题:程序化楼层没有「写实感」
最早每一层都是 Three.js 程序生成的:楼板、工位用几何体拼,用自发光模拟室内灯光。结构没问题,但看着就是「发光的方块」,没有灯光照在桌面、地面上的明暗变化。
2. 改成 Blender 建模 + 烘焙楼层「变体」
58 层不可能每层单独建模烘焙。解法是做少量变体,按楼层数据挑一个摆上去。每座塔 9 种:
| 变体 | 说明 |
|---|---|
off_100 ~ off_40 |
5 种办公层,按亮灯比例 100 / 85 / 70 / 55 / 40%(12 个分区按比例点亮) |
lobby / retail |
1~2F 大堂、3~4F 商业 |
plant |
设备层(空调机组、风管) |
sky |
顶层空中会所 |
另外斜屋顶切过的 53~58F 每层单独建模(家具按屋面裁剪),再加上地下 B1~B3 机房和斜屋面下的屋顶机房。
每个变体拆成两个对象:
- 楼板 + 核心筒墙:面积大、明暗细节多,烘到 2048² 贴图
- 家具、灯盘、天花 :上千个小面,烘到面角顶点色
为什么家具不烘贴图?Smart UV 展开时上千个小家具会切出上万个 UV 岛,岛之间要留缝,贴图被缝隙挤满,楼板反而分不到像素。家具很小,逐顶点的明暗就够了。
前端按楼层数据(类型、入驻率)给每层挑变体,相邻两层避免一模一样:
js
export function pickVariant(floor, below) {
if (floor.kind === "lobby") return floor.index <= 2 ? "lobby" : "retail"
if (floor.kind === "plant") return "plant"
if (floor.kind === "sky") return "sky"
const occ = (floor.occupancy ?? 60) / 100
const ranked = OFFICE.slice().sort(
(a, b) => Math.abs(a[1] - occ) - Math.abs(b[1] - occ)
)
return ranked[0][0] === below ? ranked[1][0] : ranked[0][0]
}
塔身向上收分,所以每层摆放时按该层高度的收分系数缩放;斜屋顶下的几层再挂一个裁剪平面,把伸出屋面的部分切掉。

3. 竖向比例:故意压扁
真实塔高 218 m、平面约 52 × 44 m,细高比 4.2,放进画面中部像一根针。设计稿的塔身高宽比约 2.6,所以标准层层高按 2.2 m 示意(真实 3.76 m),裙楼、地下层另给层高。这个「示意比例」在 Blender 脚本和前端常量里必须一致,否则楼层会对不上玻璃幕墙。
斜屋顶也比 OSM 的真实数据陡:设计稿屋顶高差约为塔宽的 0.44,真实只有 0.18,看起来几乎是平顶。最后按设计稿取值,屋面斜切过最上面 6 层。
4. 玻璃:竖梃网格画在着色器里
塔身是整栋一张 MeshPhysicalMaterial 玻璃,在 onBeforeCompile 里注入竖梃和层线。关键是按屏幕像素宽度画线 (fwidth),远看近看都是一像素左右的细线,不会糊成灰色网格:
glsl
// 细线:t 每过一个整数一条线,宽约 1.5 像素,抗锯齿
float gridLine(float t, float w) {
float fw = max(fwidth(t), 1e-4);
return 1.0 - smoothstep(0.5 * fw, 1.5 * fw + w, abs(fract(t + 0.5) - 0.5));
}
// 竖梃按放样几何的 uv.x(周长,米)每 4.5 m 一根;层线按世界高度取整
float grid = gridLine(vGridU / uMull, 0.015);
grid = max(grid, uFloorK * gridLine((vWorldP.y - uBaseY) / uFloorH, 0.02));
同一个着色器里还注入了:抽屉抽出那层的玻璃挖空、悬浮层青色、告警层红色呼吸、轮廓菲涅尔亮边。

5. 地下机房和屋顶机房
底座正面的剖口里是三层机房:B1 车库和冷水机组,B2 配电柜(其中一台告警)、UPS、变压器,B3 水泵和水箱。冷水机组带筒体、水室法兰、竖管闸阀和压力表;水泵带散热筋和联轴器护罩。告警配电柜本身是红色自发光,烘焙时会把周围地面和相邻柜子照红,比实时加一盏红灯自然得多。
斜屋面下原本是空腔,后来补了屋顶机房:顺坡钢梁、梁底灯带、冷却塔、擦窗机。

6. 清晰度:别让辉光毁掉画面
做完之后有个反馈:「外墙灯光和电梯光柱太模糊,看不清墙体」。查下来是三个原因叠加:
EffectComposer默认的渲染目标不带 MSAA ,renderer 上开的antialias不起作用,一像素的竖梃会发虚、闪烁- 辉光门槛太低,玻璃轮廓、每层的外沿光线全超过门槛,每条线都晕开一圈
- 电梯光柱太亮,它是不做深度测试、叠在整栋楼最前面的一层光
修法:
js
// 后处理渲染目标开 4 倍 MSAA
this.composer = new EffectComposer(
renderer,
new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType, samples: 4 })
)
// 辉光只给真光源:门槛 0.95,强度、半径都压小
this.bloom = new UnrealBloomPass(new THREE.Vector2(256, 256), 0.22, 0.2, 0.95)
同时把层线、轮廓光的颜色压到辉光门槛以下,电梯光柱收窄成几条清晰的井道。

七、楼层级:一份布局,两边共用
楼层级要做的事情比楼宇级多:房间要能悬浮、点选、着色,工位要能显示占用状态,设备要标点位。这些都需要知道「房间在哪、工位在哪」。
如果 Blender 里建一份、前端再写一份坐标,两边迟早对不上。所以把布局抽成一个纯 Python、不依赖 bpy 的脚本 plan.py:
- 按塔楼真实的椭圆轮廓,沿平面主轴建一个局部坐标系
- 中央放核心筒,外面一圈 1.6 m 走道,走道到幕墙之间按角度切成 7 个区(开放办公 A/B、洽谈室、大会议室、总经理室、茶水间、弱电机房)
- 工位岛沿幕墙弧线排成三排,每岛 6 人,避开结构柱
- 空调、烟感按网格布点,摄像头、AP、门禁按房间布点
它有两个用途:
bash
# 命令行运行:生成前端数据 floorPlan.js(房间多边形、玻璃隔断、工位、设备点位)
python3 scripts/blender/tower/plan.py
python
# Blender 里:detail.py 按同一份布局建模
P = plan.build_plan("tower_S")
for d in P["desks"]: ...
两边吃同一份数据,前端的房间着色、工位色块、设备图标和烘焙出来的模型天然对齐。

光影、降噪、材质
楼层是近景,第一版烘焙出来问题很多:光照太平、地面有颗粒、地面是纯色。改了三件事。
灯具重新布置:每组工位正上方一条吊线灯,走道和房间用筒灯,机房用暖橙色灯盘。吊顶压暗(反照率高会把光打匀),这样才有「灯下亮、角落暗」。
浮点烘焙 → 降噪 → 色调映射:楼板先烘成浮点 EXR(不截断超过 1 的高光),用 Blender 合成器的 OIDN 降噪,再按一条曲线压成 PNG:
python
def _tone(x, k=EXPOSURE):
"""色调曲线:1 - e^(-k·x),暗部近似线性、高光平滑压到 1 以内"""
return 1.0 - np.exp(-k * np.maximum(x, 0.0))
这样灯下的光斑不会被截断成一片白,改曝光也只需重跑这一步,不用重新烘焙。
地面程序纹理:方块地毯、错缝石材、木地板、架空地板、地砖都用 Blender 的砖块纹理 + 噪声节点生成,和光照一起烘进贴图。

八、房间级:烘焙壳体 + 实时设备
房间级的特点是:场景要好看,设备要能点。这两个需求是冲突的------烘焙进贴图的东西没法单独高亮。
所以房间模型拆成三部分导出:
| 部分 | 处理 | 用途 |
|---|---|---|
room / room_v |
地面墙面烘贴图、家具烘顶点色 | 好看:灯光照在长桌、班椅、木格栅上的明暗 |
dev_<编号> × 15 |
不烘焙,带原材质导出 | 能点:实时光照,悬浮青色、选中金色描边、告警红色呼吸 |
room_bakeonly |
吊顶、筒灯、窗外城市光 | 只在烘焙时照亮室内,不导出 |
设备对象名就是资产编号模板(dev_AC-3205-01),前端按名字找到对象、绑定资产数据。所有楼层的大会议室共用一个模型,编号里的房号按实际房间替换。
每个设备头顶一根细线 + 圆形图标 + 名称读数标签(CSS2D),图标可以直接点;按分类筛选时,不在当前分类的设备变成半透明,也不能被点到。

九、踩坑合集
1. 凹多边形三角化出了「翻面三角形」
楼层地面烘出来,走道上有一块块斜边笔直的黑三角。查到最后是建模函数的问题:bmesh 新建的面法线是零向量,耳切法三角化按法线投影,凹多边形(外圈房间在核心筒转角处有凹口)就切出了伸到多边形外面、朝下的三角形,和相邻地面叠在一起互相挡光。
python
f = bm.faces.new(verts)
f.normal_update() # 三角化前必须先算法线
bmesh.ops.triangulate(bm, faces=[f], quad_method="BEAUTY", ngon_method="EAR_CLIP")
2. 共面叠放的面会抢同一块 UV
先铺整片底板、再在上面叠房间地面,看起来没问题,但 Smart UV 打包时会把共面重叠的面塞进同一块区域,烘焙结果互相覆盖。地面要互不重叠地拼满 。园区地面也踩过同样的坑:道路、人行道、草地是重叠的层,后烘的会覆盖先烘的,最后改成从下往上生成,同一层逐条抬高一点点(Z_STEP = 0.0004)。
3. 两片墙贴在一起会烘成全黑
地下机房的隔墙一开始是两个背靠背的单面墙,烘焙时互相遮挡,整面墙全黑。改成有厚度的实墙就好了。
4. 顶点色烘焙是 HDR
灯盘自发光强度十几,烘出来的顶点色也是十几,直接导出在 Three.js 里整片泛白。导出前要压到 1 以内(楼宇级等比截断,楼层级、房间级用和贴图一样的色调曲线)。
5. 字节型顶点色层存的是 sRGB
numpy 写字节型颜色时自己先转了一次线性,导出后几乎全黑。同理,numpy 画的 sRGB 贴图,判断亮度前要先换算成线性值------草地贴图 sRGB 0.05 看着不暗,线性值不到 0.01,烘出来就是一片黑。
6. 烘焙颗粒:OIDN 降噪
Blender 的烘焙没有内置降噪。办法是借合成器:Image 节点 → Denoise 节点 → 输出,用 Workbench 空渲染一帧写回文件。注意色彩管理要临时换成 Standard,否则 AgX 会改变烘焙颜色。
7. 导出脚本里的死循环,把 Blender 卡死了
导出时一边遍历集合、一边往同一个集合里加副本,遍历永远结束不了,Blender CPU 100%、内存一直涨,只能强退。遍历前先取快照:
python
for ob in list(col.objects): # 不要直接 for ob in col.objects
...
8. 长时间烘焙让 MCP 调用超时
一次正式烘焙要十几分钟,MCP 调用会超时。改成在 Blender 里注册一个定时器启动烘焙,MCP 调用立刻返回,烘焙完写一个日志文件,外面轮询这个文件:
python
bpy.app.timers.register(job, first_interval=0.5)
十、性能与体积
| 项目 | 数据 |
|---|---|
| 楼宇级模型 | tower_S.glb 约 9.9 MB、tower_N.glb 约 8.8 MB(每塔 9 种变体 + 6 层屋顶层 + 地下 + 屋顶机房) |
| 楼层级模型 | 每塔约 1.5 MB(4096² 贴图 + 顶点色) |
| 房间级模型 | 约 1.8 MB |
| 园区模型 | 约 5 MB + 1 MB 地面烘焙图 |
| 帧率 | 五级都稳定在 60 帧 / 秒(M2 MacBook,1080p) |
几个让它跑得动的关键点:
- GLB 用 meshopt 压缩 (Blender 5.2 的 glTF 导出直接支持,前端用
MeshoptDecoder,不用额外部署 Draco 的 wasm) - 烘焙内容一律
MeshBasicMaterial,不参与实时光照,也不导出法线 - 58 层楼共用 9 个变体的几何和材质,每层只是一个带缩放的 Mesh 引用
- 树、路灯用 GPU 实例化
十一、总结
做完回头看,整个项目最大的工作量不在写代码,而在和设计稿对比 → 找差异 → 改的循环:园区改了 7 轮,楼宇改了 7 轮,楼层、房间也各改了几轮。AI 在这里的价值很明显:
| 环节 | 做法 |
|---|---|
| 设计稿 | AI 生图出概念图,照着还原 |
| 建模 | AI 写 Blender Python 脚本,通过 MCP 直接在 Blender 里执行 |
| 烘焙调光 | 改参数 → 后台烘焙 → 截图对比 → 再改 |
| 前端 | 场景、着色器、面板组件 |
| 排查问题 | 从「走道上有黑三角」一路查到「三角化前没算法线」 |
但判断「好不好看」「哪里和设计稿不一样」还是得靠人:光照太平、玻璃发灰、光柱太糊、房间颜色盖住了地面......这些都是一句话的反馈,AI 负责把它变成具体的改法。
几个可以复用的经验:
- 建模写成脚本 ,
.blend和 GLB 当构建产物,模型改动可追溯、可重建 - 能烘焙的光照就烘焙,实时只算真正会变的部分
- 需要交互的东西单独导出,烘焙和可点选不要混在一个对象里
- 布局数据只写一份,建模脚本和前端从同一份数据生成
- 辉光只给真光源,后处理记得给渲染目标开 MSAA
说明:楼栋外形、高度、园区布局来自 OpenStreetMap(© OpenStreetMap contributors,ODbL);楼里的业态、房间布局、设备型号与运营数据均为演示数据。