一张户型图做成浏览器 3D 漫游:house.json 统一模型、1.2 m 剖切图纸与分阶段验收

先说结论

  • 整栋房子只有一份几何数据。图纸、墙体升起、3D 线图、材质漫游、小地图都从同一个 house.json 生成,不另画平面,所以永远对得上。
  • 图纸就是模型本身。在离地 1.2 m 处水平剖切,用视场 8° 的长焦相机从正上方俯视;剖到的墙用模板缓冲封顶涂黑,轮廓线在屏幕空间检测生成。
  • 帧率靠逐项排查 。室内从 15--20 fps 到 60 fps,靠的是四项改动:阴影按需重算、只开最近 6 盏点光源、只画本层 11 m 内家具、去掉透射材质。60 fps 只在一台 Apple M4 笔记本上实测过。
  • 照片对位只看结构线。墙角、门框、顶角线对齐,家具不作依据。
  • 换工具时交接项目,不交接对话。让上一个工具写交接提示词:先读哪几份文件、已确认什么、先出方案再动代码。

背景与分工

输入是一套公开挂牌的两层独立屋的户型图和几十张照片。这是演示项目,不是房源广告;本文不使用原始房源照片。

产出是一个纯静态网页:开场是一张黑线图纸,滚动时墙从纸上升起、线稿绕房子转一圈,然后镜头降到门口,以人眼高度逐个房间走一遍。全程可自动播放,5 分 49 秒。

分工是这一次的实际做法,不是两家的固定能力边界:

路线 工具 产出
共同准备 Codex 空间校定:22 个空间、30 个门窗构件、37 组家具、32 条照片机位
A 作图 Codex(调用 GPT 的图像能力) 黑线平面图、彩色专业平面图、8 张 3D 效果图
B 代码 Claude Code(Claude Opus 5.5) 统一模型、3D 漫游、滚动驱动的落地页

技术栈:three.js 0.170.0、GSAP 3.12.5、Lenis 1.1.13,本地离线副本,纯静态文件,不需要构建。

路线 B 分 8 个阶段(P1--P8),每个阶段先出方案、确认后再写,结束时写一份验收记录。下面按工程主题讲,不按阶段讲。

一、统一模型:house.json

所有几何只有一个来源。结构大致如下:

json 复制代码
// house.json 结构示意:字段做了简化,数值为示意,不是项目数据
{
  "levels": [
    { "id": "M", "name": "主层", "cut_height": 1.2 },
    { "id": "L", "name": "下层", "cut_height": 1.2 }
  ],
  "walls": [
    { "id": "MW001", "level": "M", "kind": "ext", "rect": [0.0, 0.0, 6.0, 0.25] }
  ],
  "openings": [
    { "id": "S01", "level": "M", "type": "entry", "sill": 0.0, "head": 2.0, "swing": "-z" }
  ],
  "rooms": [
    { "id": "M01", "level": "M", "name": "主卧", "label_m": [4.2, 3.6], "polygon": [[0,0],[4.2,0],[4.2,3.6],[0,3.6]] }
  ],
  "furniture": [
    { "id": "FU01", "room": "M06", "at": [5.0, 2.0], "size": [2.1, 0.9, 0.86], "face": "+x", "model": "sofa" }
  ],
  "assumptions": [
    { "item": "主层净高", "value": "...", "basis": "..." }
  ]
}

当前数据:118 段墙、41 处门窗、25 个空间、9 条假设,家具 77 条。

几条做法:

  1. 假设单独成字段 。层高、墙厚、窗台高、楼梯踏步这些原图上没有的量,逐条写进 assumptions,带依据。交付时"哪些是量出来的、哪些是猜的"一目了然。
  2. 先核尺度,再建模。从原图量出比例尺 133.96 px/m,20 个有标注的空间里 18 个与标注偏差在 ±3% 以内。层间高最初按 2.70 m 算,核对后改为 2.60 m。
  3. 其他产物只读它。图纸、线图、漫游、小地图都不另存几何,改一处尺寸,所有视图一起变。

二、图纸即模型:1.2 m 剖切 + 长焦俯视 + 模板缓冲封顶

网页开场的"黑线图纸"不是贴图,是同一个三维场景换了一个相机:

  • 剖切:在离地 1.2 m 处放一个水平裁剪面,切掉上半部分。
  • 相机:视场 8° 的透视相机,从很高处正对下方。视场足够窄,透视变形几乎看不出来,效果接近正投影的平面图。
  • 涂黑:剖到的墙体截面要像制图一样涂实。
js 复制代码
// 示意:思路表达,不是项目源码
const cut = new THREE.Plane(new THREE.Vector3(0, -1, 0), floorY + 1.2); // 保留 1.2 m 以下
renderer.localClippingEnabled = true;
wallMaterial.clippingPlanes = [cut];

const fov = 8; // 长焦
const cam = new THREE.PerspectiveCamera(fov, aspect, 1, 2000);
const halfH = planDepth / 2;
cam.position.set(cx, floorY + halfH / Math.tan(THREE.MathUtils.degToRad(fov / 2)), cz);
cam.up.set(0, 0, -1);  // 让图纸"上北"
cam.lookAt(cx, floorY, cz);

截面涂黑踩过一次坑:一开始用"背面涂黑"(被切开的实体能看到内部背面,就把背面画黑),结果分不清哪里真的被剖开,整张图变黑。改用标准的模板缓冲封顶:

js 复制代码
// 示意:模板缓冲封顶的通用做法,不是项目源码
// 1) 背面 +1、正面 -1,只写模板不写颜色
const back  = { side: THREE.BackSide,  stencilZPass: THREE.IncrementWrapStencilOp };
const front = { side: THREE.FrontSide, stencilZPass: THREE.DecrementWrapStencilOp };
// 两个 pass 都设 stencilWrite: true, stencilFunc: THREE.AlwaysStencilFunc,
// colorWrite: false, depthWrite: false, clippingPlanes: [cut]

// 2) 在剖切面位置画一块平面,只在模板值 != 0 处出色(即实体内部)
const capMat = new THREE.MeshBasicMaterial({
  color: 0x111111,
  stencilWrite: true,
  stencilRef: 0,
  stencilFunc: THREE.NotEqualStencilFunc,
});

另一个坑在楼梯纵剖面:原定剖切位置正好与下层一道隔墙重合,墙被整段涂黑,剖面移开 0.30 m 解决。

第一版图纸是"正确但不好看",不如作图路线的黑线图。没有换回图片(换成图片,墙就不能从纸上升起),而是让模型改用二维制图符号:每件家具按它在 house.json 里的位置、尺寸、朝向生成平面符号,窗用标准窗符号;墙体升起前再换成三维家具。

三、线稿:检测图 + 轮廓 + FXAA

所有线稿(图纸、3D 线图、漫游里还没"上色"的房间)都是逐帧在屏幕空间检测出来的:先画一张记录法线、深度、构件编号的检测图,再在相邻像素间找突变,画成轮廓。

glsl 复制代码
// 示意:屏幕空间轮廓检测的通用写法,不是项目源码
float edge(vec2 uv) {
  vec4 c = texture(detectTex, uv);              // rgb = 法线, a = 深度
  float id = texture(idTex, uv).r;              // 构件编号
  float e = 0.0;
  for (int i = 0; i < 4; i++) {
    vec2 o = offsets[i] * texel;
    vec4 n = texture(detectTex, uv + o);
    e = max(e, step(normalThreshold, 1.0 - dot(c.rgb, n.rgb)));
    e = max(e, step(depthThreshold, abs(c.a - n.a)));
    e = max(e, step(0.5, abs(id - texture(idTex, uv + o).r)));
  }
  return e;
}

后期看到室内的线"毛毛叉叉"。原因:之前为了帧率,漫游段把检测图降到半分辨率,放大到全屏后斜线出现 2--3 px 的台阶。处理:

  • 检测图恢复全分辨率;
  • 描线合成之后再加一遍 FXAA。

也试过把渲染分辨率提到 2 倍,线更细,但帧率降到 53--55 fps,没有采用。

四、材质扫光

同一个模型有两种状态:线稿和真实材质。进入房间时,一道暖色光带从门口向外扩散,扫过的地方换成材质、描边淡出;往回滚,又可以逆向回到线稿。

glsl 复制代码
// 示意:按到门口的距离做显现,不是项目源码
float d = distance(worldPos.xz, doorPos.xz);
float reveal = 1.0 - smoothstep(radius - bandWidth, radius, d); // radius 随滚动进度变化
vec3 color = mix(paperColor, litColor, reveal);
float line = edgeStrength * (1.0 - reveal);
color += warmBand(d, radius);                                   // 光带本身

因为 radius 由滚动进度决定,往回滚就是半径变小,扫光自然倒放,不需要单独写"回退"。

五、照片机位标定与停留

漫游里每到一个停留点,镜头先转身环视(大房间约 150--160°),再落到当初拍照的机位,照片渐显,和三维画面对上。有效果图的场景渐显效果图,没有的渐显实拍。

标定方法:

  1. 把模型线稿叠在照片上,只按结构线对位:墙角、顶角线、门窗洞、壁炉。家具是相近款,不作依据。
  2. 从一张照片的远墙上下比例反推,拍摄相机高度约 1.07--1.10 m、竖向视场约 76°,所有停留点统一用这组值,只调位置和朝向。
  3. 照片机位单独存一份标定数据,和 house.json 分开。
json 复制代码
// 停留点示意:字段与数值均为示意
{ "stop": "Txx", "photo": "照片编号", "pos": [0.0, 0.0], "height": 1.08, "yaw": 0, "vfov": 76 }

过程中的修正:精确标定从 8 个增加到 14 个;有一个停留点第一次朝向标错,重标为 222°;两扇门改了开向,避免挡住镜头。最终 19 个停留点里 14 个照片对位、5 个是区域级停留;路线离墙最近 0.18 m。

视角在最后一轮按人的反馈调过:眼高 1.5 m、行走俯角 8°、下楼 45° 朝下;线稿阶段绕房子转一整圈。

六、帧率:15--20 fps → 60 fps

第一次跑通时,室内只有 15--20 fps。逐项排查后改了四处:

改动 原来 现在
阴影 每帧重算一张 4096 阴影图 只在场景状态变化时重算
点光源 18 盏全开 只开离镜头最近的 6 盏
家具绘制 全部 只画本层 11 m 内
水晶吊灯 透射材质(最大瓶颈) 普通半透明材质
js 复制代码
// 示意:用 three.js 的通用 API 表达这四项思路,不是项目源码
renderer.shadowMap.autoUpdate = false;
function onSceneStateChange() { renderer.shadowMap.needsUpdate = true; }

function updateLights(camPos) {
  const near = pointLights
    .map(l => [l, l.position.distanceTo(camPos)])
    .sort((a, b) => a[1] - b[1])
    .slice(0, 6);
  pointLights.forEach(l => (l.visible = false));
  near.forEach(([l]) => (l.visible = true));
}

function updateFurniture(camPos, level) {
  for (const f of furniture) {
    f.visible = f.userData.level === level && f.position.distanceTo(camPos) < 11;
  }
}

chandelier.material = new THREE.MeshStandardMaterial({ transparent: true, opacity: 0.6 });

测试条件:只在一台 Apple M4 笔记本上实测,Chrome(ANGLE Metal),窗口 1600×900,设备像素比 1 和 2 下都是 60 fps,最长单帧 18 ms。配置弱的电脑可能达不到。

七、加载:88 MB → 28.5 MB

首次打开要下载 88 MB,在模拟 50 Mbps 网络下约 14 s 才能开始滚动。改法:

  • 字体不再阻塞首屏,开场画面立即显示;
  • 贴图改用 WebP 副本,79.6 MB 降到 19.3 MB,原文件不动;
  • 贴图等模型就绪后再下载,按"室外 → 主层 → 下层"排队,在后台线程解码;图纸和升起阶段只用线稿,不需要等贴图;
  • HDRI 改为异步,到了再补上窗外背景和环境反射。

结果:

指标 优化前 优化后
开场画面显示 约 3.7 s 0.23 s
可以开始滚动 约 14 s 3.8 s
总下载量 88 MB 28.5 MB
js 复制代码
// 示意:按优先级排队的贴图加载,不是项目源码
const queue = [...textures.outdoor, ...textures.mainLevel, ...textures.lowerLevel];
await modelReady;                       // 几何先到,图纸阶段只要线稿
for (const t of queue) {
  const bmp = await loadImageBitmap(t.webpUrl); // createImageBitmap 在后台解码
  applyTexture(t, bmp);
  progress.update();
}

八、素材与小坑

家具、贴图、环境光全部来自 Poly Haven 的 CC0 素材:30 个模型、16 套贴图、1 张 HDRI,161 个文件约 110 MB。原定的另一个素材站连不上,才全部换过来;下载改成 8 路并行。

  • 室外 HDRI 照进室内,草地把天花映绿 → 室内改用中性环境光。
  • 外墙砖的漫反射贴图文件名是 diffuse(其他资源都是 diff),一直没加载,外墙偏暗 → 已修正。

九、两个工具之间的交接提示词

图片做完要换到 Claude Code。没有把对话整段贴过去,而是告诉 Codex"图片任务完成,下一步转 Claude Code",让它写交接提示词。结构是:先读 4 份文件、先出方案、确认后再写代码。

可以照这个结构写自己的交接提示词:

text 复制代码
# 交接提示词结构(示意,不是本项目原文)
你接手一个已进行到一半的项目。

【先读这些文件】
1. 项目说明:目标、受众、设备、交付物
2. 工作规则:每步先出方案,确认后再执行;不改原始资料
3. 已确认的结果:空间校定表、已交付图纸清单
4. 当前进度:做到哪一步、还有哪些未决问题

【这次的任务】
用一句话说清下一阶段要交付什么。

【要求】
- 读完先用自己的话复述目标和约束
- 先给方案(分阶段、每阶段怎么验收),等我确认
- 确认后再写代码;每个阶段结束写一份验收记录

关键是把目标、原始资料、规则、已确认结果、进度存在项目里,而不是存在某一段对话里。

局限

  • 尺寸是原图标注的约值;层高、墙厚、窗台高、楼梯踏步是建模假设,逐项写在 assumptions 里。
  • 家具是外观相近的 CC0 模型,不是原物;傍晚的阳光是氛围设定,不代表真实朝向。
  • 效果图和三维画面不能逐像素重合,渐显时有一瞬间叠影。
  • 只做了电脑端;帧率只在 M4 上测过。
  • 未解决:首次经过某些位置卡 0.07--0.40 s;墙脚外侧多出一条细线;一条原因未明的 three.js 报错。

前后不到一天;录下来的操作过程约 6 小时,最后两轮调整没录。大部分时间在等 Claude Code 把漫游一段段做出来,人在每段做完后看结果、提修改。代码一行没有手写,但每个阶段的画面都是人对着照片和原图看过的。

你做三维或可视化项目时,最难让 AI 一次做对的是哪一块?

相关推荐
瓶 盖2 小时前
你的 IDE 插件改了 authorized_keys:Claude Code 扩展投毒链路拆解
ide·ssh·vs code·安全分析·供应链安全·claude code
limuyang210 小时前
我在 2核2G 云主机上跑 OpenViking:一次关于云主机、模型选型与长期成本的真实复盘
人工智能·开源·ai编程
金銀銅鐵12 小时前
[Java] 用 CLI 展示 EnumMap 增删键值对时的内部变化
java·源码·ai编程
李剑一16 小时前
没收用户的钱冲史上最大IPO?2 万亿美元的估值有多少是罚没来的
aigc·openai·ai编程
张益达Yida16 小时前
我把 0.6B 大模型塞进安卓手机:596 秒到 10 秒
ai编程
Aman198417 小时前
Claude Code做Unity项目移植(一):从零散工具到团队式
unity·游戏开发·vibe coding·claude code·项目迁移
小虎AI生活17 小时前
腾讯 Buddy One 架构解读:跨应用 Agent 底座怎么打通办公协同
aigc·ai编程
ZhengEnCi19 小时前
Agent 如何进行工具调用:从一次 Function Calling 到生产级工具编排的完整链路
llm·agent·ai编程
熊猫钓鱼>_>20 小时前
四种书签,一条时间线:一个读书 App 的记忆颗粒与它的边界案例【鸿蒙心迹】
开发语言·华为·ai编程·harmonyos·鸿蒙·openharmony·鸿蒙pc