从一座城市钻进一个房间:Three.js + Blender 做一个五级钻取的数字楼宇大屏

前言

「数字孪生楼宇」几乎是智慧园区类大屏的标配需求:客户希望从城市一路点进园区、点进某栋楼、某一层,最后看到某个房间里的某台空调。

这类需求的难点不在数据,而在于五级都要好看:城市要有立体感,园区要有夜景氛围,楼宇要能剖开看到每一层,楼层要能看清工位,房间要能点到具体设备。任何一级做得像「几何体拼的方块」,整个大屏的观感就塌了。

这次我和 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 直接显示烘焙结果

几个原则:

  1. 建模写成脚本 ,.blend 和 GLB 都是构建产物,改模型就是改 Python 脚本
  2. 能烘焙的光照都烘焙:室内几十盏灯的漫反射、软阴影,实时渲染算不起,烘进贴图之后运行时只是贴图采样
  3. 会变的东西才实时算:选中高亮、告警闪烁、电梯运行、数据着色
  4. 两端靠对象名连接 :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. 清晰度:别让辉光毁掉画面

做完之后有个反馈:「外墙灯光和电梯光柱太模糊,看不清墙体」。查下来是三个原因叠加:

  1. EffectComposer 默认的渲染目标不带 MSAA ,renderer 上开的 antialias 不起作用,一像素的竖梃会发虚、闪烁
  2. 辉光门槛太低,玻璃轮廓、每层的外沿光线全超过门槛,每条线都晕开一圈
  3. 电梯光柱太亮,它是不做深度测试、叠在整栋楼最前面的一层光

修法:

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 负责把它变成具体的改法。

几个可以复用的经验:

  1. 建模写成脚本 ,.blend 和 GLB 当构建产物,模型改动可追溯、可重建
  2. 能烘焙的光照就烘焙,实时只算真正会变的部分
  3. 需要交互的东西单独导出,烘焙和可点选不要混在一个对象里
  4. 布局数据只写一份,建模脚本和前端从同一份数据生成
  5. 辉光只给真光源,后处理记得给渲染目标开 MSAA

说明:楼栋外形、高度、园区布局来自 OpenStreetMap(© OpenStreetMap contributors,ODbL);楼里的业态、房间布局、设备型号与运营数据均为演示数据。

源码:github.com/Dani-bin/sc...

相关推荐
纸片人2 小时前
甲方只给了一张卫星截图,我用 Three.js + Blender 建了一座 3D 能源站
three.js·数据可视化
数脉3 小时前
数据血缘的八个典型企业应用场景
数据挖掘·数据分析·数据可视化
妖精的羽翼3 小时前
Web3D(Three.js)应用分级 + 学习路线规划(由浅入深,贴合接单 + 求职)
学习·three.js
踩着两条虫21 小时前
我在浏览器里“盖”了一个村:Vue + Three.js 大屏开发踩坑记
前端·vue.js·three.js
SL_staff1 天前
BI不该只服务高管:JVS如何让仓库管理员自主配置库存预警
spring boot·数据分析·数据可视化
子龙_1 天前
来在网页上用threejs画个蛋白质吧
前端·webgl·three.js
Q26433650231 天前
【有源码】基于Hadoop+Spark的低能见度易雾站点识别与可视化分析系统-面向气象保障的低能见度监测数据挖掘与风险画像系统
大数据·hadoop·数据挖掘·spark·毕业设计·课程设计·数据可视化
天理智能科技2 天前
物流仿真项目投入成本怎么算?制造企业收益测算思路
数据可视化
龙亘川2 天前
AI 协同赋能城市治理:支撑政协数字化履职的技术路径探析
大数据·人工智能·智慧城市·开源软件·数据可视化