DEV Library 是我对 DEV.to 的第一人称 3D 重新构想。它不是一些文章卡片漂浮的主题场景,而是通过 Forem API 拉取的实际实时目录,渲染为可步行的档案。书架。房间。区域。真实书籍封面出现在真实书脊上。你使用 WASD 在其中移动,而不是滚动信息流。
这个想法听起来很有趣。构建它主要是一长串我未曾预料到的问题,随后我弄清楚了原因。
这一切始于 vibe coding,几乎完全是为了 Sanity x Dev.to 黑客马拉松。我描述了我想要的东西,然后它就出现了——电影般的灯光、空间音频,以及我自己未完全想通的整个系统。
这方法在项目规模不大时效果很好,但当项目变得足够大以至于出现无法通过重新生成响应来修复的问题时,情况就变了。当目录变成真实数据,场景需要承受数千篇实时文章的流入(而不是之前只有十几篇模拟数据)时,我发现仅靠描述已经无法解决问题。我不得不打开动画循环,追踪引用,并真正理解底层发生了什么——不是因为我想这么做,而是因为“再试一次”已经失效。这篇文章主要讲述这部分内容。
技术栈
该项目基于 Next.js 和 Three.js 构建——没有游戏引擎,没有可视化场景编辑器,也没有带视口的工具(你可以在其中拖动对象并观察其移动)。Three.js 是一个使用 WebGL 在浏览器中渲染 3D 图形的 JavaScript 库,其中的一切都是代码。没有带 X/Y/Z 字段的检查器面板可以通过方向键微调。如果一个书架漂浮了过高六个单位,你不会把它拖下来——你会找到设置其位置的那行代码并修改数字。
听起来只是个小麻烦,直到你需要在一条曲折的、程序生成的走廊里摆放上百个架子。在 Unity 或 Unreal 中,你会立刻看到不匹配并直接拖动物体就位。而在 Three.js 中,你只能盲目地坐标推演——改一个数字,等待场景重建,用 WASD 走过去看是否正确,若不对就重复这个循环。项目中的每个位置、旋转和偏移都只是某个函数里的一个数字,而不是你能直接看到并抓取的东西。
这就是下面这么多 bug 都围绕‘东西放在哪里’而非‘东西长什么样’的真正原因。坐标计算不会像视觉样式那样容忍‘感觉编程’——颜色稍有偏差仍然看起来不错,但架子位置稍有偏差要么会穿墙,要么会漂浮在虚空中,而你只有亲自走过去才能知道到底是哪种情况。
一切并非由此开始

这最初是 Oniria,简称 Oni —— 一个第一人称的 3D 梦境日志。记忆以发光的球体形式存在,它们之间的关系用你可以实际飞行经过的纽带表示。它发展迅速:电影级灯光、空间音频、递归梦境潜入、记忆衰减,以及一个可以拉远视角以一次性查看全部梦境历史的“观测台”模式。当我停下来环顾四周时,我已经领先主分支 197 次提交,代码达到了一万三千行,我不得不提出显而易见的问题:这对黑客马拉松来说是不是太多了?
答案并没有缩小问题,而是重新定义了它:
如果我们继续把每个想法都视同等重要,那么对于黑客马拉松来说,范围确实太大了。但这并不意味着 Oniria 本身就是‘太多’。现在的问题在于产品聚焦,而非雄心。
于是我挑选了一个 90 秒的体验,把其他所有内容都冻结在其后。在这过程中,我审视了自己实际构建的东西——在一个由关联事物组成的图中进行第一人称飞行——并提出了另一个问题:如果这根本与梦境无关呢?
代码库探索器的想法最先出现(将文件夹视为世界,导入视为走廊)。它从未发布,但它打开了一个缺口:引擎本身并不真正关于梦境,而是一种将任何可链接、可浏览的结构转化为场所的方法。于是我提出了一个更奇怪的需求——把整个 DEV.to 网站导入引擎中。也就是说,真正地在网页上冲浪。
问题 #1:最初的版本就像一个阴谋板
它实际上起作用了。文章、个人资料、标签、搜索结果,全部同时漂浮在空间里。技术上令人印象深刻。完全无法导航 — — 你会生成在一个无关节点的云中,完全不知道首先应该看哪里。
这其实更像是设计失误而非 bug,但它为之后的一切定下了基调:修复不是新功能,而是一个隐喻——图书馆。房间作为主题。书架作为信息流。书籍作为文章。每个人都有自己的房间。这一个想法对可用性的提升超过了我在此之前所做的所有渲染改进,因为架构能够告诉你该去哪里,而漂浮的标签永远做不到。
问题 #2:图书馆在我站在其中时自行重新排列

当真实的 DEV 文章开始流入,而不是仅仅几个模拟的文章时,图书馆需要数百,然后是数千个书架。这时导航悄然出问题了。
原因在于:书架一直相对彼此定位。这听起来合理,直到你意识到这意味着导航实际上是布局的涌现副产品,而不是世界所保证的东西。每当另一页文章流入时,书架的位置可能会在玩家脚下移动。在普通网站上,重新渲染列表很无聊。而在第一人称空间中,它可以传送站在其中的人。
这是整个项目最终所依赖的架构决策:
路径是权威的。其余部分只是装饰路径。
一个确定的堤道成为整个图书馆的坐标系统——一个名为 archivePathPoint(bay) 的函数,它将逻辑的 "bay" 索引转换为世界空间位置。书架、雾气、天际线、能量带、相机夹持——所有这些现在都从同一条固定路径采样,而不是即兴生成自己的位置。书架的放置本身也移植到了一个带种子的确定性函数,使用稳定的输入(书架 ID、近似的 bay、路径侧面、抖动),这样世界仍然可以看起来有机,而不会在每次 React 状态更新时书架跳到新位置。
这一条规则一次性修复了一整类错误。当书架不再是定义世界的事物时,流式传输更多的目录就不再危险。
问题 #3:zoneRef.current 不是一个函数
这是让我真的坐下来逐行追踪代码,而不仅仅是再要一遍审查的问题。这也是我之前提到的那个转变的最清晰例子——振动编码(vibe coding)单靠自身已经不再足够的点。
Runtime TypeError
zoneRef.current is not a function
900 | if (nearestSection !== currentSection) {
901 | currentSection = nearestSection
902 | zoneRef.current(nearestSection)
zoneRef.current 被当作函数调用,意味着它并没有持有任何值。我进入动画循环,追踪 zoneRef 实际上是在哪里被赋值的——它是在一个依赖 currentSection 的 useEffect 内部被设置的,但动画循环在那个 effect 第一次触发之前已经在运行了。这是一个闭包过时的时序问题:该 ref 确实存在,只是在渲染循环尝试访问它的时候还未准备好。
这是 AI 辅助构建过程中没人会提前告诉你的部分。它能够瞬间交付电影级渲染、空间音频以及一个可完全探索的 3D 库。但它无法交付那 fifteen 分钟的 “等等,这个 effect 实际上是在渲染循环的什么时候运行的” 所需的调试时间——那部分你仍然需要自己亲手解决。
目前进展

目录以分页方式流式加载,而不是一次性阻塞请求。书架的确定性种子确保在加载更多内容时不会重新排列。相机在场景重建过程中得以保留。区域根据真实的文章标签自行组织,而不是依赖固定的分类法。附近的书架会作出反应——变亮、唤醒封面——而远处的书架则变暗,这比我预期的更重要:在 2D 中,层次感来源于排版和布局;而在一个一次可见数十个书架的 3D 档案中,只有距离和亮度决定了层次。
工作尚未完成。我仍在评估目录能够保持加载多少内容,才会导致远处书架的帧时间消耗超过其价值。天际线和雾气系统已经调校,但在较弱的 GPU 上尚未完成性能验证。我相信那里仍然至少藏有一个类似 zoneRef 的 bug,我尚未遇到。
但这一次,它终于表现得像我最初想要构建的东西:不是一个带有 DEV.to 主题的场景,而是试图让整个活动目录变成你可以实际行走的地方——一次一个损坏的 ref、一次闪烁的封面,以及一次吞噬屏幕的书籍。
更多内容即将发布,包括当这篇作品成为我实际的 Sanity Challenge 提交时——此时 CMS 部分将成为故事的主角,而不再是配角。



