先看设计定稿的效果图,这是我想做出来的样子。
然后诚实一点:网页真跑起来比这丑。材质没跟上,植被是几块色块,背面立面基本没细节。但它能转、能点、能从院区走进楼层,数字对得上账。
在线版(建议电脑打开):tongji-hospital-3d-dashboard
代码:github.com/Xxcool/tong…
先说清楚这项目是怎么来的:建模、写码、改 bug,主要是我在 Codex 里下任务、验收、打回重做;我负责判断"像不像"和"能不能用",它负责把活干出来。 源码 800 行左右,模型 18 万三角形 / 112 个 mesh,中间来回改了不少轮。
边界也说明一下:个人演示项目,不是医院官方的东西,数据全是模拟的,内部空间是示意设计,别拿它导航或做任何运营决策。
一、先别急着让 AI 写代码,先说怎么给它派活
我踩的第一个坑不在代码里,在流程上:如果直接甩一句"帮我做个医院三维大屏",Codex 会给你一个能跑的、漂亮的、但完全没有逻辑的东西——模型是随便摆的,数据是随机数,镜头写死的。
真正管用的是把活儿切成六个阶段,每个阶段给它一个可验证的交付物:
| 阶段 | 我做什么 | Codex 做什么 |
|---|---|---|
| 1. 调研收敛 | 定死只讲门诊和急诊,其他楼只做导览 | 查公开资料、整理空间关系、写调研记录 |
| 2. 整屏方案 | 拍板视觉方向 | 生成几套效果图供选 |
| 3. 代表区域 | 判断像不像 | 通过 Blender MCP 建模、导 GLB |
| 4. 样片验证 | 对着实拍挑刺 | 按反馈改材质、改结构 |
| 5. 逐区开发 | 定交互规则 | 写 React 面板 + Three.js 场景 |
| 6. 验收 | 提需求、截图核对 | 修布局、修性能、跑测试 |
两个关键点:
第一,范围必须提前砍。医院大屏最容易堆满能耗、安防、工单、设备,AI 也会顺着你的话往下堆。我一开始就砍到只剩门急诊两个业务区,医技、住院、儿童医院、停车楼只保留空间导览——不虚构自己讲不清的专题。
第二,判断标准要可验证。说"做得好看点"对 AI 毫无意义,说"入口弧线要以这张实拍照片为准,中段后退 16 米"才有意义。这条经验后面省了我不少返工。
二、AI 搞砸的第一件事:把入口弧线画反了
门诊入口是整个院区辨识度最高的部分:低层弧形入口在前,高楼群在后。
第一版出来我觉得挺像,还截了图。然后我把入口实拍近景翻出来对照——弧线方向反了。
真实的入口幕墙是中间向后退、两端向前伸。AI 做的是中间鼓出去、两边缩回来。俯视一看,整栋楼的气质全歪了。
它为什么会错?因为它从"弧形入口"这四个字出发,推了一个视觉上合理的曲面;而真实的建筑是一条我自己都没量过的曲线:y = -27 - 16 * (x/60)²,中间相对两端后退 16 米。AI 不知道这 16 米,我也不说,它就只能猜。
更麻烦的是善后。那条曲线是整栋楼的参照物:
- 屋顶弧形压条要跟着重新弯;
- 幕墙竖向构件要重新排布;
- 雨篷门洞要重新定位;
- 入口铺地和草地在曲线反向处裂了一条缝,得重新建模衔接。
参数本身改了几分钟,但屋顶压条、幕墙构件和铺地那道缝,修修补补花了小半天。这件事让我彻底改了一个判断:三维场景可不可信,跟单个模型精细度关系不大,跟模型之间接不接得上关系极大。 后来专门补了楼间连接裙房和铺地,院区才从"几栋摆在一起的楼"变成"一个院区"。
三、技术点一:让模型自带身份证,别用屏幕坐标猜
大屏最经典的翻车画面:点一栋楼,右边的数据跟这栋楼毫无关系。数据是数据,模型是模型,两张皮。
解决办法不是写更聪明的拾取逻辑,而是在建模阶段就把身份埋进模型。每个建筑、每个楼层组、每个分区面,导出时带上 userData,再额外挂一个空物体做锚点:
// 加载时按约定取锚点,取不到直接抛错,不允许"静默降级"
for (const id of [...regions.map(r => r.id), 'floor_f1', 'floor_f2']) {
const node = model.getObjectByName(`anchor_${id}`);
if (!node) throw new Error(`模型锚点缺失:${id}`);
anchors.set(id, node);
}
这个 throw 是我特意留的。早期我让它缺锚点时打条 warning 就算了,结果改完模型忘了同步,点击某些楼栋镜头纹丝不动,查了半小时才发现是名字改了。早失败比晚失败便宜得多。
拾取也有两个真实的坑:
坑一:Raycaster 不认 visible。 intersectObject 会把被隐藏的对象照样算进命中结果里——哪怕父级 visible = false。所以必须自己递归判断:
const isVisible = o => !o.visible ? false : o.parent ? isVisible(o.parent) : true;
不写这一句,楼层展开时点到的可能是被藏起来的隔壁楼。
坑二:拖拽会误触发点击。 用户按住鼠标转视角,松手时位移几乎是零,click 照样触发。所以我把拾取挂在 pointerup,并且只认位移小于 6 像素的那一下:
if (!origin || Math.hypot(e.clientX - origin[0], e.clientY - origin[1]) > 6) return;
const rect = renderer.domElement.getBoundingClientRect();
pointer.set((e.clientX - rect.left) / rect.width * 2 - 1,
-(e.clientY - rect.top) / rect.height * 2 + 1);
注意这里用的是 getBoundingClientRect() 而不是 window.innerWidth——这一点在后面适配那节会救你一命。
四、技术点二:镜头别手调,用包围盒 + 安全区算
第一版聚焦,我让 Codex 给每栋楼写死了一组相机参数。它写得很乖,参数也调得好看。
问题是院区里楼群体量差得太离谱——停车楼和门诊楼差好几倍,同一组参数,小楼看不清,大楼顶到面板后面。
后来改成让相机自己算。核心代码就几行,但里面有三个数字很值钱:
// 用相机基向量把包围盒 8 个角点投影到屏幕横竖方向
for (const x of [b.min.x, b.max.x])
for (const y of [b.min.y, b.max.y])
for (const z of [b.min.z, b.max.z]) {
const v = new THREE.Vector3(x, y, z).sub(center);
minX = Math.min(minX, v.dot(right)); maxX = Math.max(maxX, v.dot(right));
minY = Math.min(minY, v.dot(up)); maxY = Math.max(maxY, v.dot(up));
}
const pixelsPerUnit = el.clientHeight / (camera.top - camera.bottom);
goalZoom = Math.min(
1000 * scale / ((maxX - minX) * pixelsPerUnit), // 安全区宽 1000 逻辑像素
540 * scale / ((maxY - minY) * pixelsPerUnit) // 安全区高 540
) * 0.92;
三个数字:
- 1000 × 540:这是 1920×1080 画布里真正"没被 UI 占住"的那块区域。左边导览、右边详情、顶上标题、底下时段栏全占着位置。把建筑塞满整个浏览器,不代表用户看得见它。
- 0.92:留白系数。紧贴边缘的取景在静态截图上还行,动起来会显得憋。
- scale:因为整屏做了等比缩放,安全区也要跟着缩放,不能写死像素。
还有一个更细的坑:UI 的视觉中心不是画布几何中心。 面板布局让内容区整体偏上,直接对准几何中心会导致建筑偏下。所以目标点还得补一个纵向偏移:
goalTarget.copy(center).addScaledVector(
up, (top + 550 * scale - el.clientHeight / 2) / (pixelsPerUnit * goalZoom)
);
这个 550 是照着设计稿量出来的逻辑坐标。写死它不优雅,但比来回试参数靠谱——这类补偿值建议直接写注释说明出处,否则半年后自己都不知道为什么。
五、技术点三:楼层展开,取景要看终点
楼层展开更阴:
展开动画从收拢状态起飞。如果你拿"当前状态"算包围盒,镜头会按收拢时的大小取景,等楼层散开,屋顶早就飞出画面了。
做法是先把楼层组临时挪到展开终态,算一遍整体包围盒,再挪回去:
for (const [id, group] of floorGroups) {
group.visible = !zone || id === floor;
group.position.y = lerp(group.position.y, manifest.floors.explode[id], .09);
group.position.z = lerp(group.position.z, -manifest.floors.back[id], .09);
}
explode / back 两个偏移量写在场景清单 manifest.json 里,而不是写进代码——因为它们是模型的属性,不是页面的属性。改模型的人顺手就能改,不用来找前端。
顺带说个近景处理的细节:进到候诊区视角时,必须把医技、儿童医院、住院、停车、急诊这几栋藏掉,否则连接裙房会横在示意内装前面,透视上糊成一片。什么时候藏、藏哪些、退出来怎么恢复,全都跟着交互状态走,这部分逻辑比加载模型烦多了。
六、技术点四:假数据最容易翻车
大屏文章爱吹"实时数字孪生",我不吹。这个项目的数据就是四组写死的演示快照:08:00 开诊、10:00 门诊集中、15:00 午后回落、20:00 急诊关注。
但"假数据"不等于"随便的数据"。四个时段共用一个入口 getFrame(),总览必须从分项算出来:
total.waiting = outpatient.waiting + emergency.waiting;
total.open = outpatient.open + emergency.open;
听起来像废话。可大屏项目里"顶上大数字 172、左边门诊 148、右边急诊 24"对不上的情况太常见了——通常是三处各填各的假数。
观众未必说得出哪里不对,但一定会觉得"这屏是假的"。反过来,数字只要对得上账,观众会本能信任它,哪怕这些数字全是编的。
这是整个项目里我最意外的体感:可信度是可以被算出来的,模型做得再细也补不了数据逻辑的窟窿。
20:00 这一档还会联动切夜间。灯亮起来、天色调下去、Bloom 强度抬高,演示到"急诊关注",屏幕自己就沉了。
夜间设计目标:
实际跑出来(差距一清二楚,但联动是真的):
材质调色是靠命名约定做的:模型里的 TJ_glass、TJ_warmGlass、floorEdge 这些材质名对应一张昼夜色板,切夜间时遍历所有材质改颜色和自发光,warmGlass 额外点亮成暖黄。命名规范又一次代替了 if-else。
七、技术点五:整屏 scale 适配的三个坑
适配我选了最朴素的:1920×1080 逻辑画布 + 整体等比缩放。ResizeObserver 算比例,根节点 transform: scale(),非 16:9 的窗口留背景居中。
不用流式响应式的理由很简单:大屏是"锁定比例持续展示"的东西,三块面板的构图是设计出来的,不是算出来的。真放开流式布局,小窗口下面板挤成一坨。
但 scale 有三坑,一个没躲过:
坑一:点击坐标。 只要用 getBoundingClientRect() 归一化,scale 天然兼容——rect 自带缩放信息。用 window.innerWidth 就等着点不准吧。
坑二:层叠上下文。 transform 创建新的层叠上下文,position: fixed 放进缩放树里会变成相对容器定位。弹层别塞进去。
坑三:它不是响应式。 不换行、不重排,窗口一小,字等比例缩没。所以排版必须留安全边距。
还有个跟适配无关但非常阴间的问题:我用无头浏览器截图验证时,场景一直黑屏。查了半天,是启动参数带了 --disable-gpu——新版 headless Chrome 下 WebGL 上下文直接创建失败。去掉这个参数就好了。 做自动化截图的朋友先检查这条,能少走半天弯路。
八、Codex 到底能干什么,不能干什么
这才是我想重点说的。
它干得非常好、而且明显快过我的部分:
- Blender 里的批量操作——给 112 个 mesh 统一改材质、整理命名、拆件导出、写
bpy脚本批量处理; - 样板代码——GLB 加载、OrbitControls 配置、Bloom 后处理、ResizeObserver,一次成型;
- 校验脚本和测试——数据模型的一致性测试、锚点缺失检查,它写得比我还全;
- 文档和注释——
manifest.json的字段说明、构建命令、部署配置。
它明显不行、必须我来判断的部分:
- "像不像"。弧线画反、楼群太散、入口和铺地裂开——它看不出这些,因为它眼里只有几何体,没有"医院长什么样"这个概念。这类判断需要参照物,而参考照片在我手上。
- 跨文件一致性。改一个交互状态,它可能只改了 React 面板忘了 Three.js 那边,或者反过来。800 行分散在 8 个文件里,它每轮只看得见一部分。
- 验收标准本身。"用户点了楼栋,镜头飞过去,数据同步"——这句话谁都能说,但"镜头飞过去之后建筑不能被右侧面板压住"这种要求,得先被我想出来,它才有机会实现。
- 取舍。要不要上 R3F、要不要做巡游、楼层做几层——这些它会给建议,但从来不敢替你决定。
所以我现在派活的习惯是:每一轮都给它一个能自己验证的目标——跑测试、截图、量包围盒数值、检查锚点是否齐全。给"做得好看点"这种指令,等于把方向盘交出去还指望它认路。
一个很具体的例子:分层显隐的逻辑我一开始描述得很模糊("进入近景时不要挡住"),它实现得七零八落。后来我改成"当 mode === 'floor' && zone 时,medtech / children / inpatient / parking / emergency 五个 objectId 的 visible = false"——一句话,一次就对。
九、为什么没用 react-three-fiber
估计有人要问。R3F 很好,但我这次没用,原因是场景生命周期太重了:GLB 加载、材质替换、楼层展开、日夜切换、巡游、后处理、逐帧性能采样——几乎全是命令式、有状态的活。用 R3F,大部分逻辑最后还是得靠 ref + useFrame 绕回去,多背一层 reconciler,只为把 <mesh> 写成 JSX。
所以我选了原生 Three.js,全塞进一个 useEffect,用 propsRef 同步最新 props 避免重渲染打断渲染循环,对外只暴露 home / focus / zoom 三个方法。React 那一侧完全不需要知道场景内部怎么转。
代价也清楚:这个文件 378 行,再加动线、粒子就得拆了。声明式和命令式没有对错,只有你更愿意在哪一侧付账。
十、数字、边界,和还差什么
- 源码 800 行左右(8 个文件),
tsc --noEmit+ 自定义数据测试 + GitHub Pages 部署 - 模型 18 万三角形 / 112 mesh,正交相机 + ACES 色调映射 + 轻 Bloom,
dpr上限锁 1.5 - 目标设备性能还没单独验收——所以我不报帧率,本机能开不等于现场大屏能稳
- 模型精度离效果图还有明显差距,README 里放的是实拍截图而不是效果图
下一步是把院区拆成的 12 个资产包逐件精修:先拿门诊外观和候诊椅做两个样件,AI 生成工具出候选、回 Blender 校正。但道路、楼层板、交互定位面会继续留在 Blender 手动控制——生成结果整块上来,空间关系和交互绑定全毁。
最后
这项目踩的坑,几乎没有一个是"Three.js 不会用"级别的,全是"看起来合理"和"交互时真能用"之间的缝隙:弧线要和铺地接上,镜头要给 UI 让位,假数据也得对得上账。而这些缝隙,恰恰是 AI 看不见、只能靠人盯的地方。
所以想问正在用 AI 写代码的朋友一个问题:你把哪些判断留给了自己,又把哪些彻底交出去了?
我的答案是——凡是能用数字、截图、测试验证的,全交出去;凡是需要"对不对、像不像、值不值"的,一条都不交。
评论区聊聊你的分界线。
技术栈:React 19 + TypeScript + Vite + Three.js 0.186,建模走 Blender(MCP 辅助),GLB + manifest 数据驱动,主力工具 Codex。
觉得有点用就点个赞,模型精修和 AI 生成管线那段的坑,踩完接着写。