Codex 写 Three.js 大屏实录:800 行代码,和一条画反的弧线

先看设计定稿的效果图,这是我想做出来的样子。

然后诚实一点:网页真跑起来比这丑。材质没跟上,植被是几块色块,背面立面基本没细节。但它能转、能点、能从院区走进楼层,数字对得上账。

在线版(建议电脑打开):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,再额外挂一个空物体做锚点:

js 复制代码
// 加载时按约定取锚点,取不到直接抛错,不允许"静默降级"
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。所以必须自己递归判断:

js 复制代码
const isVisible = o => !o.visible ? false : o.parent ? isVisible(o.parent) : true;

不写这一句,楼层展开时点到的可能是被藏起来的隔壁楼。

坑二:拖拽会误触发点击。 用户按住鼠标转视角,松手时位移几乎是零,click 照样触发。所以我把拾取挂在 pointerup,并且只认位移小于 6 像素的那一下:

js 复制代码
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 给每栋楼写死了一组相机参数。它写得很乖,参数也调得好看。

问题是院区里楼群体量差得太离谱------停车楼和门诊楼差好几倍,同一组参数,小楼看不清,大楼顶到面板后面。

后来改成让相机自己算。核心代码就几行,但里面有三个数字很值钱:

ts 复制代码
// 用相机基向量把包围盒 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 的视觉中心不是画布几何中心。 面板布局让内容区整体偏上,直接对准几何中心会导致建筑偏下。所以目标点还得补一个纵向偏移:

ts 复制代码
goalTarget.copy(center).addScaledVector(
  up, (top + 550 * scale - el.clientHeight / 2) / (pixelsPerUnit * goalZoom)
);

这个 550 是照着设计稿量出来的逻辑坐标。写死它不优雅,但比来回试参数靠谱------这类补偿值建议直接写注释说明出处,否则半年后自己都不知道为什么。

五、技术点三:楼层展开,取景要看终点

楼层展开更阴:

展开动画从收拢状态起飞。如果你拿"当前状态"算包围盒,镜头会按收拢时的大小取景,等楼层散开,屋顶早就飞出画面了。

做法是先把楼层组临时挪到展开终态,算一遍整体包围盒,再挪回去:

js 复制代码
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(),总览必须从分项算出来:

ts 复制代码
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 生成管线那段的坑,踩完接着写。

相关推荐
Dawson Zhu1 小时前
【CUDA编程入门⑧】性能分析工具链:Nsight、Profiler与Roofline模型
人工智能·语言模型·架构·aigc·agi
欣欣之王来了1 小时前
v-bind语法糖::属性名的简写方式
前端·学习·架构·项目·vue3教程
2601_954005622 小时前
# ricon SVG 编辑器:工业图元的“军火工厂“
前端·物联网·数学建模·架构·编辑器·制造
codeniu2 小时前
一个人,5天,从想法到上线:我用 TRAE 跑通全链路开发的完整实录
前端
shaibdoio2 小时前
RPA自动化获客怎么落地?瞬维AI评论区工程实践
前端·自动化·rpa·长沙瞬维ai
计算机魔术师2 小时前
807亿亏配900%留存:ANT招股书把AI的拧巴摊在桌上了
前端
未来之窗软件服务2 小时前
web网络请求竞态问题与取消方案-东方仙盟
前端·状态模式·仙盟创梦ide·东方仙盟·网络取消
wshzd2 小时前
LLM之Agent(120)|DeepSeek-Harness(二十七)Workflow 引擎与 Ralph 循环
前端·数据库·人工智能
涛涛ing2 小时前
React Compiler 1.0 之后,状态管理库正在被重新定义
前端