Skip to content

在浏览器里造开放世界,第 18 部分:一个感觉像 AI 摆的撒布笔刷

作者:Oleg Sidorkin,Cinevva CTO 和联合创始人

刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。

第 17 部分给了玩家一套战斗级动画集,和一种把任何 CC0 模型拉进世界的办法。这一部分回到创作者那一侧。Spike 34 的调色板每次点击放一个道具,这对摆一个主角物体没问题,对一片森林就没用了。Spike 37 就是那个笔刷:在地形上拖动,树就在该长树的地方填进来。

没有 AI 的"AI 摆放"

在新标签页打开 Spike 37 ↗ · 看源码

这个 spike 回答的问题是:一个纯启发式的笔刷感觉够不够智能,能跳过 LLM?"AI 摆放"的检验很具体:树离开悬崖、岩石往坡里倾、海滩卵石停在水线上,全部在第一笔里。我们靠坡度和海拔谓词、加权抽取、按家族的间距做到了,一次模型调用都没有。

笔刷在一张 257×257 的 CPU heightmap 上工作,地形特征是手调的,所以每个预设都有地方落脚:北部的山供混合坡度的选择、东边一条悬崖带供碎石、南部一片沿海平原供海滩和草甸、西南一个湖盆。地形从一个 (altitude, slope) 生物群分类器烘出顶点颜色,所以在你刷下任何一棵树之前,你就能看到一个预设会在哪里触发。五个预设以扁平数据形式发布,每个是一列像 { category, weight, slopeMin, slopeMax, altMin, altMax, minSpacing, alignToSlope } 这样的选择。Cliff 和 Scree 设 slopeMin: 0.3,所以岩石只落在真正的坡上,并设 alignToSlope: true,所以每块巨石的 up 向量跟着表面法线。

每一笔,撒布引擎在笔刷圆盘内采样 densityPerM2 × area 个候选点,逐个候选读高度和坡度,把预设的选择过滤到谓词通过的那些,加权抽一个,然后对一个半径内的空间哈希做间距检查。整件事是确定性的:一个可设种子的 Mulberry32 RNG 拥有每一次抽取,所以 (seed, brush events) 能精确重现任何一次会话。在启动地形上,一笔 Mixed Forest 在平坦草甸上把 158 个候选里的 139 个放下,用了 5 毫秒,而同一个预设在悬崖上只放了 226 个里的 106 个,HUD 报告其中 81 个被坡度拒绝。那份拒绝明细就是整个 UX:你能看到悬崖为什么只接受了几棵树,而不用猜。

把预设保持成扁平数据的意义在于:当 LLM 版本落地时,它是一次 JSON 替换而不是重写。paint({ preset }) 不在乎 preset.picks 来自一个手调配方还是一个把"长着苔藓巨石的落叶林"展开成权重的 worker。引擎也从不硬编码道具 id,所以换上一个不同的目录无需引擎改动。

从 300 次绘制调用到 49 次

第一版把每个放置渲成一个多 mesh 组的 clone(true),这在几百个道具时没问题,到 2500 上限时是堵墙,绘制调用爬到上千。在撞上那个之前我们换成了 InstancedMesh,每个 (propId, partIndex) 一个桶。每个桶靠翻倍增长:分配一个更大的 InstancedMesh、复制活的矩阵、换场景父节点、释放旧 attribute。擦除是一次 swap-remove,所以移除一个实例是 O(1),与桶大小无关。确定性、间距和拒绝 HUD 全都原样传下去,因为这次替换完全活在放置记录之下。

一个对 MegaKit 包的诊断定下了一个真正的架构问题。一个多图元 glTF mesh(树干加树叶)到达 three.js 时可能是一个带数组材质和 geometry.groups 的 mesh,也可能是各带一个材质的独立兄弟 mesh。加载器对这个包走第二条路:每个部件是一个空 group 的单材质 mesh。那对撒布是更好的形状,因为每个图元一个桶让树干桶能在数量分歧时独立于树叶桶增长。两种方式绘制调用数一样,拆开后内存形状更好。实测的胜利站住了:一笔约 300 次绘制调用的森林描边变成了 49 次,而一整次多笔会话达到 3221 个实例、75 FPS、51 次绘制调用,这是 clone 路径在帧预算崩溃之前永远到不了的上限。

距离 LOD,和藏在里面的四个 bug

实例化砍了绘制调用,但每个实例还是画它的全三角形数,连 90 米外只贡献两像素树叶细节的树也是。所以我们用 meshoptimizer 给每个道具部件烘了三档 LOD(满、50%、15%),把桶键扩展成 (propId, partIndex, lod),加了一个 move() 在兄弟桶之间转移一个放置而不分配。距离带是 0 到 30 米、30 到 90 米、再往外,每个边界周围有 ±4 米的滞回,这样一个悬在带边缘附近的镜头不会把一个放置来回抖、每帧重新上传它的矩阵。重新评估封在 4 Hz 并以镜头确实移动过为门槛,所以静止的镜头每帧只花一次平方距离比较。

那条 LOD 路径就是那些有教益的 bug 住的地方。第一个表现为放置随镜头环绕时消失或重复,场景越满越糟。原因是一个共享的暂存矩阵:move() 把一个放置的变换读进模块作用域的 _tmpMat,但来源桶的 swap-remove 用同一个 _tmpMat 做它自己的内部挪动,在目的地写入之前就把携带的矩阵覆盖了。这个 bug 只放过被移动的槽已经是桶里最后一个的情况,大约 1/count 的概率,这正是 playtest 看到的"罕见闪烁、场景越大越糟"。修法是给 move() 单独留一个专用的 _carryMat。压力测试 1274 次累积移动,那一簇保持像素一致。

第二个 bug 更微妙:每个 LOD 过渡都感觉平滑,除了第一个。穿进 LOD1 的树明显移了着色,尽管轮廓几乎没变,而阶梯后面更大的三角形下降看起来没事。带 LockBorder 的简化器从不移动或发明顶点,所以存活的顶点精确保留它们的法线,但我们却在每次简化后都调了 computeVertexNormals()。LOD0 原样返回原始作者标注的法线,LOD1 及以上拿到的是 three.js 通用的面平均重算。0 到 1 的边界是阶梯里唯一法线机制变化的地方,所以爆点就住在那。删掉那一行防御性代码修好了着色,作为附带好处,把每道具烘焙时间大约砍了一半,因为我们不再对每个部件的四个 LOD 重算法线。

审查简化器产出的东西浮出第三个胜利。每个 LOD 都是一个带新索引的 original.clone(),而 BufferGeometry.clone() 深拷贝每一个 attribute,所以五个 LOD 握着五份独立的 position、normal、UV、color buffer 拷贝,而它们的值在全部五个之间比特相同。我们重构成共享 attribute 引用,每个 LOD 只拥有一个私有索引 buffer,把一个典型树部件从 20 个不同的 attribute 身份降到 9 个,每个顶点 buffer 只往 GPU 上传一次。别名存储带来两条约定:别通过任何单个 LOD 改 attribute 数据,别 dispose() 单个 LOD 几何体,因为两者都会波及共享那个 buffer 的每个兄弟。

第四个 bug 跟绘制毫无关系。光是在地形上挥光标就让帧率掉,一个按钮都没按。pointermove 处理器对地形 mesh 做射线投射,那是一个 131072 三角形、没有空间结构的平面,所以 three.js 每个事件都走一遍整个索引 buffer,每秒最多 1000 个事件。我们那次查找根本不需要那个 mesh,因为地形是一个参数化 heightmap。一个对 sampleHeight 的自适应光线步进(高出表面时大步、靠近时 0.4 米的下限、然后在符号翻转处 12 次二分)每条射线花大约 8 到 30 次采样,而不是 131072 次三角形测试,便宜大约三个数量级,悬停又稳住了帧上限。

开销只是挪走了;确保它挪离点击

把这个 spike 换到 three r184(生产目标)上的 WebGPURenderer 之后,一份 DevTools 性能分析显示第一次绘制阻塞了 265 毫秒,其中 79% 在 meshoptimizer WASM 里面。那个烘焙是真活,一个冷预设大约 180 次 simplify 调用,但它在点击处理器里跑,因为 preloadProps 只拉取并解析场景,从不触发 LOD 烘焙。修法是让预设选择在后台做完整的烘焙:preloadProps 现在调用部件解析路径,缓存正在进行的 promise,这样一次快点击加入它而不是 fork 一份重复,并把简化器每个部件重做四次的逐几何体预处理记忆化。HUD 里首次绘制从 209 毫秒降到 4 毫秒。WASM 时间没消失,它只是离开了用户的关键路径,在用户看着地形决定往哪刷的时候跑。

这是这个 spike 反复出现的教训。这些修复几乎没有一个改变了笔刷做什么。它们改变的是开销什么时候落地:离开点击、离开悬停、离开镜头正悬在附近的那个边界。一个感觉即时的撒布工具不是在做更少的活,它是在用户没等着它的地方做活。

本章涉及的技术

启发式适宜性撒布。 一个笔刷在圆盘内采样候选点,逐点从一张 CPU heightmap 读 (height, slope),按坡度和海拔谓词过滤一个预设的选择,加权抽一个,如果它违反一个空间哈希里追踪的按家族最小间距就拒绝它。坡度对齐的选择把它们的 up 向量旋转到表面法线。这产生读起来像有意为之的放置(树离开悬崖、岩石往坡里倾、卵石停在水线上),没有学习出来的权重,并把预设保持成扁平数据,所以一个 LLM 生成的选择列表是个即插即用的替换。

异步加载下的确定性放置。 一个可设种子的 Mulberry32 RNG 拥有每一次抽取,所以 (seed, brush events) 精确重现一次会话。RNG 抽取发生在任何 await 之前,间距预留在 glTF 克隆 resolve 之前就插进空间索引,所以并发候选互相尊重,而异步资源加载不能扰动序列。

带 O(1) 编辑的分桶 InstancedMesh。 每个 (propId, partIndex, lod) 一个 InstancedMesh,按需把活的矩阵复制进一个更大的 buffer 来翻倍容量。擦除和 FIFO 逐出是 swap-remove,配一个反向引用数组给被移动实例的索引打补丁,所以一次移除是 O(1),与桶大小无关。一个诊断确认了 glTF 部件以单材质 mesh 到达,让每图元一桶成为生效路径,并给每个图元一个可独立增长的桶。

带滞回和共享 attribute buffer 的距离 LOD。 每个部件三档 meshopt 简化的层级,按距离带选择,带 ±4 米滞回,这样镜头靠近边界时不抖,以一个封顶的频率重新评估、并以真实镜头运动为门槛。因为 LockBorder 简化从不移动顶点,所有 LOD 共享一套 position/normal/UV/color buffer,只在各自私有的索引 buffer 上不同,把不同的 GPU 顶点 buffer 砍了大约一半。跳过一个防御性的 computeVertexNormals 让作者法线在各 LOD 间保持一致,去掉了阶梯里唯一的着色不连续。看 LOD 和 meshoptimizer

给高频查找用的解析 heightmap 射线投射。 一个 pointermove 频率的光标查找,对一个 131k 三角形的平面 mesh 每个事件都走一遍整个索引 buffer。把它换成对解析高度函数的自适应光线步进(离表面远时大步、近时一个小下限、在 (rayyterrainy) 的符号翻转处二分)花几十次采样而不是几万次三角形测试,便宜大约三个数量级,而一个预分配的输出向量让热路径无分配。

把活挪离交互的关键路径。 昂贵的一次性工作(meshopt LOD 烘焙、WGSL 管线编译)应该在空闲间隙跑,而不是在点击处理器里。在预设选择时预加载生效预设的完整烘焙、缓存正在进行的 promise 让快点击加入而不是 fork 它、把逐几何体预处理记忆化,把首次绘制延迟从 209 毫秒降到 4 毫秒,而没有少做任何总活。


第 18 部分,共 29 部分。 上一篇:第 17 部分 - 不用重定向的动画,和一个实时资源搜索 下一篇:第 19 部分 - 必须在森林里活下来的冒名顶替者 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide