继收藏星图之后的第二个 3D 个人网站:一座挪威峡湾边的小木屋,日落后二十分钟的那种蓝。屋里藏着我的经历、写作和项目——每找到一件宝物,就亮起一盏灯。这篇文章记录从灵感到上线的完整过程。

在线体验 →(项目页 →)(建议电脑端打开,戴耳机)

最终效果:蓝调时刻的海边木屋

起点:从”看星图”到”进屋子”

上一个项目收藏星图回答的问题是”我被什么塑造”,但星图是俯视的、一览无余的。我一直想再做一个第一人称的、需要走进去逐个发现的空间——那才适合放真正属于我自己的东西:经历、写作、项目。

灵感源头还是 jesse-zhou.com 的拉面店。雨夜、霓虹、推门进去别有洞天——信息藏在物件里,而不是摆在菜单上。

但这次我不想用自己的房子做原型。我想要一个有情绪的场景,像 zhou 的雨夜霓虹一样,访客还没点任何东西,光站在那儿就有感觉。

选场景:蓝调时刻、木屋、还是古墓

候选有三个:蓝调时刻(一种光线)、北欧海边小木屋(一种场所)、古墓(一套玩法)。

古墓最先出局——它的叙事是”挖掘别人的过去”,而我要讲的是活着的自己,两层皮。真正定下来是在 B 站刷到 Linksphotograph 的视频《和李现,攀登人生第一座雪山》:32 分钟左右,他们住进峡湾边的小木屋,深蓝夜色里只有门廊一串暖黄灯串,屋里有句话一下击中我——进屋时主人说:“先不打开所有的灯。”

就是它了。孤独中的温暖。后来我让 AI 开着浏览器把这一段逐帧截下来分析,这句话成了整个寻宝机制的原型(后面讲)。

最重要的决定:把摊子收小

第一版野心很大:海滩、门廊、阳光房、起居室、隐藏房间……做了两天毛胚,怎么看怎么像教学 demo。

然后我重新读了一遍 Ramen-Shop 的开源代码,意识到一个事实:zhou 的获奖网站其实只有一个外立面。没有内部漫游,没有解谜,就是一个外立面 + 几块屏幕,但每一寸都打磨到极致。

于是砍:先做外立面,做到能拿出台面为止。内部以后再说。这个决定救了项目。

在 Blender 里盖房子:19 版迭代

木屋不是手工拖出来的,是 AI 驱动 Blender 程序化建模——我写意图,AI 写 Python,Blender 执行。全过程渲染了 19 版风格帧,挑几个关键的坑:

灯串是灵魂。 参考图之所以动人,90% 的冷蓝里那 10% 的暖黄。灯串做了三条悬链线弧线,每颗灯泡是自发光小球 + 吊线座,靠辉光后期出光晕。它是画面的第一焦点。

天空连着翻车四次。 前几次渲染天空总是脏橙色,怎么调参数都没用。最后排查发现:不是天空的错,是辉光后期把地平线的亮带涂满了整个天空。关掉辉光定位问题后,干脆弃用世界纹理,改用一个发光渐变的天穹球——这个思路后来原样搬进了 three.js。

海面怎么调都是黑的。 查了一轮,结论是物理问题:山太高太近,水面掠射角的反射光路上只有黑色的山体,镜面当然只能反射出黑。把山推远压低,镜面立刻活了。渲染器没骗我,是几何骗了我。

白桦树皮像银环蛇。 程序化的等距环纹太规则,真实白桦的斑纹是随机的”眯眼”状黑斑。换成噪声驱动 + 横向拉伸的斑块才解决。

最终定稿:深蓝夜色小屋、暖窗、灯串、白桦、镜面海、三排雪山剪影。模型按 collection 组织,Draco 压缩导出 GLB,只有 0.38MB。

技术:抄骨架,换血肉

架构整套参考了 Ramen-Shop(它是 Bruno Simon 三门课那套 Experience 单例模式):Vite + 原生 ES modules + three.js + GSAP,没有框架没有 TypeScript。

抄的部分:单 GLB 按节点名解析、GSAP 镜头飞行、点击/拖动的位移阈值判断、事件驱动的资源加载。

换的部分:

  • 内容不用贴图。zhou 的”关于我”是烘焙在屏幕贴图里的,中文长文这么做是死路。我的宝物内容全是 HTML 覆盖层 + Markdown 渲染——可读、可链接、可选中
  • 宝物是注册表,不是 switch 语句。一个 treasures.json 管所有宝物:{id, meshName, cameraNode, lightId, content}。加宝物不用改代码
  • 命名约定打通 Blender 和 web。HIT_ 前缀是隐形碰撞盒、CAM_ 是机位、LGT_ 是灯位。Blender 里摆好,web 端自动注册——改场景不用碰 JS
  • 点灯系统是他没有的。每盏灯一个点光源(初始强度 0)+ 自发光材质,找到宝物时 GSAP 把光 ramp 上来,bloom 带出光晕。寻宝进度 = 点亮房子的过程——就是视频里那句”先不打开所有的灯”

从固定机位到自由环绕

第一版学 zhou 做固定机位 + 点击飞行。上线自己玩了十分钟就放弃了:这是个 360° 都建了模型的外景,锁机位太浪费。改成 OrbitControls 自由环绕:拖拽转、滚轮缩放、带阻尼,限制距离和俯仰角(不穿墙、不进山、不钻水下)。

唯一的巧思:点开宝物时,环绕中心会跟着镜头一起飞到宝物上——你是”绕着柴堆看柴堆”,而不是贴一张死镜头。关掉面板,环绕中心再飞回小屋。

上线前的三个灵异事件

天上有个黑色碎片。 排查:那是一颗月亮。天穹是 shader 不吃雾,月亮是普通材质吃雾,被雾染成深蓝——比天空还暗,成了悬浮异物。参考图里蓝调时刻本来就没有月亮,删。

湖里有个光源闪瞎人眼。 某个角度看海面,一道白色光柱直冲镜头。逐层排查:关环境反射没用、海面调纯黑也没用、最后把平行光强度归零——光柱消失。根因:three.js 的平行光是全场灯光,layers 只对相机可见性生效,挡不住光照;而水面掠射角的 Fresnel 反射率趋近 1,无解。最终把海面换成不受光的 MeshBasic + 手动挂天空环境贴图,光柱消失,水面反而更平静。

页面莫名其妙”死机”。 截图永远是同一帧,一度以为代码坏了。真相:Chrome 对被完全遮挡的窗口和后台标签页会冻结 rAF——我截图用的窗口被自己最大化的浏览器压在下面。最后写了一个无头 Chrome + CDP 的截图脚本(scripts/headless-shot.mjs),真实时间驱动,从此调试截图不求人。

我学到的事

  1. 摊子要小。一个精修的外立面胜过十个毛胚房间。zhou 的网站就是证据,我读了他的源码才真正相信
  2. “先不打开所有的灯”。好的交互机制不是发明出来的,是从场景本身长出来的——多看参考素材,机制就在里面
  3. 渲染器不撒谎,撒谎的是几何。海面是黑的时候,先检查反射光路上有什么
  4. 这次的分工依然是:AI 建模、写码、排查,我负责说”这个不对”。审美判断依然是人的活——19 版风格帧,每一版都是我说”不对”才到下一版的

技术栈:Blender MCP(程序化建模)· three.js + GSAP(渲染与交互)· Vite(工程化)· WebAudio(程序化环境音:风/浪/柴火)· 无头 Chrome CDP(调试图)· 全部宝物内容本地 Markdown