在浏览器里造开放世界,第 12 部分:环、天空雾、和我们会再做一遍的事
作者:Oleg Sidorkin,Cinevva CTO 和联合创始人
刚看到?看系列导览。那里解释了 spike 是什么,并链接了所有部分。
Spike 24 本来应该是"给地形加 clipmap 环"。结果变成了一次完整的终章,一次性碰到了渲染、着色器、模块基础设施和视觉集成。
地形核心任务是在顶点着色器里生成以相机为中心的同心 clipmap 环。每个环是个以相机为中心的平面网格 mesh,顶点用 heightmap 采样顶起。内环用全分辨率。后续每环顶点间距翻倍,覆盖更大区域。难点在环和环之间的边界:高分辨率环遇到低分辨率环的地方,细 mesh 边上的顶点需要 snap 到粗 mesh 边的中点上。我们做了 2:1 边缘 morph,通过检测边界顶点(那些沿环边格子坐标是奇数的)把它的高度在相邻两个偶数顶点之间插值。这样得到水密接缝,不用过渡几何。
然后是雾和天空集成。我们要远处的地形淡入真正的天空色,而不是一个固定常数。这意味着雾着色器要知道每个片元方向上的天空色。我们加载了一张 equirectangular HDR 天空盒纹理,在片元着色器里用从相机到片元的视方向采样,通过 TSL 的 equirectUV 节点转成 equirectangular UV 坐标。雾因子基于距离,用 positionView.z.negate() 取相机空间深度,用 smoothstep 在近距和远距之间混合。
模块接线比任何几何都更烦人。我们升级到 Three.js 0.183.1,build 产物结构变了。three/tsl 这个 import 要解析到 three.tsl.js,TSL 内部又把 three/webgpu 作为 bare specifier 来 import。两个映射都得在 HTML 的 import map 里写明。少了任何一个,浏览器就吐"does not provide an export"或者"failed to resolve module specifier"那种看不出哪个映射错了的隐晦错误。两个都进 import map 之后,着色器图就正确加载了。
我们还有个天空盒朝向问题,纹理渲染上下颠倒。修法是给 equirectangular 纹理加 flipY = true,这是 Three.js 加载纹理时的默认值,但我们最初代码里是设 false 的。
最初的雾实现采样天空时方向几乎是常数,结果是一条薄薄的地平线色带,而不是自然渐变。修法是每像素算实际的相机到片元世界方向 positionWorld.sub(cameraPosition).normalize(),把它传进 equirectUV 来查雾色。这让地形片元淡入它们后面实际的天空色,从任何相机角度看都对。
在所有单点修复之下,核心成果立住了。我们现在有一套地形系统:近场体素编辑(marching cubes 加 Transvoxel 接缝)、中场 heightmap chunk、远场 clipmap 环,全部由一个策略层治理,决定模式、LOD 和过渡行为。
如果让我点名下个项目我会复用的模式,会是这些:
特性工作之前先做风险 spike。Spike 1 在我们投资内容管线之前就杀了"我们到底能不能渲染得够快"这个问题。
集成跳之前先冻已知良好的基线。Spike 13 和 14 给我们省下了好几天对回退做二分查找。
优化马拉松之前先强制策略和可观察性。Spike 23 把神秘 bug 变成了有名字、有触发规则的命名条件。
测运动,不测截图。Clipmap pop、接缝闪烁、流式 hitch 全藏在静帧里。
测每特性的帧时间,不测平均 FPS。平均值藏着用户实际感受到的尖峰。
并且把烂的部分发出来。走错的路、追过期 buffer 鬼影的工夫、draw 范围搞错时怪罪过渡逻辑两天。那些才是人能真正学到东西的部分。
外部对照:Vuntra City 开发日志
这个系列做完之后,我们看了 @VuntraCity 的开发日志,作为对我们自己开放世界假设的外部实现校验。它是个原生 UE5 项目,不是浏览器栈,但系统模式映射得够好,对比有用。
第一个信号是位移速度必须当成流式控制处理,不只是玩法。在 Vuntra City,高速交通故意被路由到大多数室内的上方,细节范围随移动速度伸缩,避免 spawn churn 和 stall(交通系统、性能技术)。这和我们的策略层方向一致:移动模式应该直接影响 chunk 半径、室内激活、每帧允许的工作量。
第二个信号是架构。他们的地图和地址系统要求把世界拓扑和渲染对象分开,这样未加载区域的全局查询能跑(地图和地址)。这就是我们在浏览器里做世界搜索、任务路由、moderation 扫描和 POI 索引时需要的那种分离,不强迫绑死在渲染数据路径上。
第三个信号是仿真分层。他们百万 NPC 的设计让粗粒度时间表状态保持便宜又全局,只在玩家附近花昂贵的行为预算(百万 NPC 概览、系统深度剖析)。这增强了我们 AOI 优先的仿真模型:近场保真和远场确定性是两个独立关切,两份独立预算。
第四个信号是设计质量,不是原始规模。他们最强的探索时刻来自加权分布、稀有 outlier 和叙事性导航线索,而不是不停的 UI overlay(程序化环境笔记、no minimap 循环)。对我们来说这是个提醒:技术系统该调到产出"可发现的变化",而不只是"最大吞吐"。
本章涉及的技术
Clipmap 环几何。 每个环是个以相机为中心的平面网格 mesh,顶点用 heightmap 采样顶起。内环用全分辨率。后续每环顶点间距翻倍,覆盖更大区域。难点在边界:高分辨率环遇到低分辨率环时,细 mesh 边上的顶点 snap 到粗 mesh 边的中点。这个技术源自 Losasso 和 Hoppe 的 SIGGRAPH 2004 论文(PDF),GPU Gems 2 第 2 章有详述。看我们的关于 geometry clipmap 的地形指南。
2:1 边缘 morphing。 两个 clipmap 环之间的边界上,细环有位置粗环不共享。沿环边格子坐标是奇数的边界顶点被检测出来,高度在相邻两个偶数顶点之间插值。这给出水密接缝,不用专门的过渡几何。插值在顶点着色器里跑:对边界顶点 morphedHeight = mix(heightLeft, heightRight, 0.5),用我们指南里描述的同一个 geomorphing 框架。
Equirectangular 天空盒贴图。 一张 2D 图,用经纬度投影把整个天空方向球面映射出去。水平轴覆盖 0-360 度,垂直轴覆盖 0-180 度。Three.js 里设 texture.mapping = EquirectangularReflectionMapping 配 SRGBColorSpace 就能当场景背景。TSL 里 equirectUV(direction) 把一个 3D 视方向转成 2D UV 坐标采样纹理。
从天空算每片元雾色。 标准雾把片元朝一个常数颜色混合。带细节天空盒的场景这样看起来不对,因为天空色按方向变化。修法是每像素算相机到片元世界方向(positionWorld.sub(cameraPosition).normalize()),按那个方向采天空盒拿雾色。每个片元朝它后面实际的天空色淡入,从任何相机角度看都对。雾因子用 smoothstep(nearDist, farDist, viewDepth),用 positionView.z.negate() 取相机空间深度。
ES module 的 import map。 浏览器原生机制(<script type="importmap">),把 bare module specifier(比如 three/tsl)映射成实际 URL。Three.js 0.183.1 重构 build 产物后,three/tsl 要解析到 three.tsl.js,TSL 内部又把 three/webgpu 当 bare specifier import。两个映射都得在 import map 里写明,不然浏览器就吐"does not provide an export"或者"failed to resolve module specifier"。
延伸阅读
关于这个系列里用到的技术更深入的覆盖,看我们的姐妹指南:
- 浏览器开放世界的动态 LOD 和流式地形生成覆盖 heightmap、SDF、marching cubes、Transvoxel、geometry clipmap、geomorphing、流式架构、地形材质、植被渲染。
- 面向多人创作者世界的浏览器 3D 开放世界技术覆盖渲染栈、WebGPU、物理、网络、多人架构,以及来自 Skyrim、The Witcher 3、Breath of the Wild、GTA V 的教训。
感谢你陪我们走完这十二部分。
第 1 部分:我们从尝试把它弄崩开始 第 2 部分:Worker 物理和输入延迟恐惧 第 3 部分:救了我们的那些不吸睛的 spike 第 4 部分:在花哨地形之前先做好流式加载 第 5 部分:给漂亮东西做预算 第 6 部分:Clipmap 改变了剧情 第 7 部分:Marching cubes 和第一批真正的洞穴 第 8 部分:不丢掉基线的情况下做集成 第 9 部分:Transvoxel 从脚手架开始 第 10 部分:接缝混乱和拐角 boss 战 第 11 部分:策略模式,不是硬编码模式
14 篇中的第 12 篇。 上一篇:第 11 部分 - 策略模式,不是硬编码模式 下一篇:第 13 部分 - 地形雕刻和数学函数之死 系列导览:/zh-CN/blog/2026-02-25-open-world-browser-series-guide