
I-remember 复现:用 9 个循环区块实现无限粒子地表
如何用固定的 3×3 GPU 粒子区块构造可无限拖动的连续地表?从几何槽位复用、世界坐标重采样出发,逐步解释接缝连续、曲面光滑、Shader 表现与相机调度。
无限粒子地表:用 9 个循环区块实现无缝拖动漫游
引言
在浏览器里表现一片可以持续拖动的地表,直觉上似乎需要不断创建新网格。但真正决定“无限感”的,并不是场景里有多少几何,而是有限的几何能否稳定覆盖观察窗口,以及任意世界坐标能否得到连续一致的地貌。
这里采用的是一个固定的 3×3 粒子区块池。区块离开观察范围后会被搬到前方,同一批 GPU 资源由此反复使用;区块抵达新位置时,高度、空洞、颜色和闪烁则根据新的绝对世界坐标重新计算。
这篇文章将沿着“槽位复用—世界坐标重采样—接缝连续—曲面光滑”的顺序展开。理解这条主线之后,相机控制、Shader 表现和性能优化都会自然归位。
无限的不是几何数量,而是世界函数可以被求值的范围。
先约定几个符号
| 名称 | 符号 | 本文含义 |
|---|---|---|
| 区块尺寸 | S |
单块地砖的边长,当前为 2000 |
| 循环场尺寸 | F |
3 块地砖的总宽度,F = 3S = 6000 |
| 粒子局部坐标 | p |
粒子在一块地砖内部的坐标 |
| 区块基础偏移 | b |
地砖在初始 3×3 排列中的位置 |
| 循环位移 | r |
地砖被回收到其他周期时增加的位移,必为 F 的整数倍 |
| 绝对世界坐标 | w |
用于噪声求值的坐标,w = p + b + r |
| 观察中心 | t |
相机当前看向的地表中心,而不是相机自身坐标 |
| 噪声函数 | N(w) |
从世界坐标确定性地产生地形值的连续函数 |
下图是项目在远景状态下的实际画面。此时看到的是密集点阵形成的山脊、谷地和空洞,而不是一张平面图片。

实际远景:画面中的地貌由固定世界坐标噪声产生。相机之外仍只有 9 块几何区块,而不是一张无限网格。
先建立正确的心智模型
这套实现最重要的一句话是:
循环使用的是 9 个 GPU 几何槽位;地形内容并不随槽位循环,而是根据槽位当前覆盖的世界坐标重新计算。
因此,3×3 只描述“此刻拿多少块有限几何覆盖相机周围”,并不表示世界由同一张 3×3 地图周期平铺而成。
哪些东西复用,哪些东西重新计算
| 对象 | 区块回收前后是否复用 | 区块搬到新位置后发生什么 |
|---|---|---|
9 个 THREE.Points |
复用 | 改变区块偏移,不创建第 10 个区块 |
BufferGeometry 中的局部粒子坐标 |
复用 | 局部坐标 p 不变 |
| GPU Buffer、材质和 Draw Call | 复用 | 继续由同一批资源渲染 |
粒子的绝对世界坐标 w |
不复用旧值 | 由新偏移重新计算 |
高度 H(w) |
不复用旧值 | 在新世界坐标重新采样 |
| 空洞、抖动、颜色和闪烁 | 不复用旧值 | 同样由新世界坐标重新计算 |
所谓“同一个粒子被搬到前面”,准确地说是“同一个 GPU 槽位被重新用于前方的一段世界”。它不是一颗带着固定高度和颜色飞过去的物理粒子。
用一颗粒子的生命周期理解回收
假设一个粒子在区块内的局部横坐标为 p = 0,该区块的基础偏移为 b = -3000。
回收前:
r = 0
w = p + b + r = -3000
height = H(-3000)
区块越过循环边界后,回收偏移增加 6000:
r = 6000
w = p + b + r = 3000
height = H(3000)
几何槽位还是同一个,局部坐标 p 也没有变化;变化的是它代表的世界坐标,所以高度会从 H(-3000) 重新计算为 H(3000)。一般既不要求、也不希望这两个高度相等。
这不是周期地形
以下等式不是实现条件:
H(x + 6000, z) = H(x, z)
如果强制它成立,看到的反而会是每隔 6000 单位重复一次的地貌。项目需要的是一个可在任意世界坐标求值的、确定且连续的函数。用一层噪声作概念化简写:
H(x, z) = noise(x / 600, z / 600) × 300
只要给定相同 (x, z),它总返回相同结果;相邻坐标又会得到相近结果。它可以在整个坐标域上连续,而不需要具有 6000 单位的周期。
“无限且无缝”由三个独立条件共同成立
- 覆盖不断档:9 个区块始终覆盖观察中心周围;落到后方的槽位会被放到前方。
- 坐标不断档:回收后的相邻区块继续采样连续的世界坐标段,例如
2990, 3000, 3010。 - 函数不断档:所有区块都调用同一个世界函数
H(x, z),公式中没有“区块边界”分支。
第一项解决“有没有几何”,第二项解决“采样位置是否缺失或重叠”,第三项解决“高度和外观是否连续”。三者不要混为“贴图首尾相接”。
一帧画面的因果顺序是:输入改变相机目标,相机阻尼得到当前观察中心,区块系统围绕该中心回收几何槽位,顶点 Shader 在新的绝对世界坐标上重新计算地貌,片元 Shader 生成光点,最后进行后期合成。后文将按这个顺序展开。
几何层:固定粒子池
区块参数
| 参数 | 桌面端 | 触摸设备 | 作用 |
|---|---|---|---|
tileSize |
2000 | 2000 | 单个区块的世界尺寸 |
gridSegments |
3 | 3 | X、Z 方向区块数量 |
fieldSize |
6000 | 6000 | 一个完整循环周期 |
segmentSize |
200 | 142 | 单区块每轴粒子数 |
| 单区块粒子数 | 40,000 | 20,164 | segmentSize² |
| 总粒子数 | 360,000 | 181,476 | 单区块粒子数 × 9 |
9 个区块共享同一份 BufferGeometry,但各自持有独立材质和区块偏移 uniform。这样可以避免复制 9 份粒子坐标,同时允许每个区块单独循环搬移。
局部网格
单个区块只保存规则的局部坐标:
spacing = tileSize / segmentSize
position = (x × spacing, 0, z × spacing)
桌面端的间距是 10 个世界单位。CPU 只负责一次性生成规则网格;高度、横向扰动、可见性、点尺寸和颜色全部在 GPU 中逐帧计算。
初始 3×3 排列
区块初始偏移为:
start = -fieldSize / 2 = -3000
offsetX = start + tileX × 2000
offsetZ = start + tileZ × 2000
因此 9 个区块在 X/Z 平面上形成连续覆盖。THREE.Points.frustumCulled 被关闭,因为区块会通过对象变换循环移动,固定几何的原始包围盒不能可靠代表它的实际位置。
无限循环:区块回收而不是重建
回收公式
每帧根据观察中心 target 计算区块应该位于哪个 6000 单位周期:
positionX = -floor(
(-target.x + tile.offsetX + fieldSize / 2 + tileSize / 2)
/ fieldSize
) × fieldSize
Z 轴使用相同公式。计算结果只会以 6000 为步长跳变,所以区块对象会从观察中心的一侧循环到另一侧。
这里要区分两个容易混淆的距离:观察中心继续前进时,大约每跨过一个区块宽度 S = 2000,就会轮到一列槽位回收;但被选中的那一列每次实际跳过的是整个循环场宽度 F = 6000。因此是“回收事件依次发生,单次位移跨过三块”,不是相机必须先拖满 6000 才有任何区块变化。

白板图读法:A 是移动前,蓝框表示相机可见窗口;B 中观察中心向右移动,离开后方的整列地砖增加一个 6000 单位周期,成为新的前方列。图中被画在中间的旧列只是为了展示搬运过程,实际运行时它在单帧内改变对象位移。
这个过程具有三个重要特征:
-
不创建或销毁几何;
-
不修改粒子局部坐标;
-
每次仅更新 9 个对象的位置和少量 uniform。
因此,漫游距离不会增加内存占用或 draw call 数量。
两套偏移的职责
Shader 同时接收两套概念不同的偏移:
| 数据 | 含义 | 用途 |
|---|---|---|
uTileOffset |
区块在 3×3 网格中的基础偏移 | 生成当前区块的可渲染局部位置 |
points.position |
区块本轮循环产生的对象级位移 | 把整个区块搬到观察中心附近 |
uFieldOffset |
基础偏移 + 循环位移 | 生成稳定的绝对世界采样坐标 |
顶点 Shader 中:
vec3 referencePosition = position + uFieldOffset;
vec3 particlePosition = position + uTileOffset;
particlePosition 再经过 Points 对象的模型矩阵变换,得到实际渲染位置;referencePosition 则直接代表当前粒子所在的绝对世界位置。
更完整地写,粒子最终出现的位置与噪声采样位置共享同一个循环周期:
渲染世界坐标 = p + b + r
噪声采样坐标 = p + b + r
这里 p 是 BufferGeometry 中不变的局部坐标,b 是 uTileOffset,r 同时进入对象变换和 uFieldOffset。两条路径只是在 Three.js 的不同阶段完成加法,数学结果相同。

上方橙色等高线跨越虚线边界连续;黑色局部坐标在新地砖重新从 0 开始,蓝色世界坐标则继续增长。地形只能使用蓝色坐标求值。
无接缝的关键不变量
无限地表必须始终满足:
同一绝对世界坐标 → 同一噪声结果
如果噪声只用粒子的局部坐标采样,区块回收后会重复显示同一块纹理,边界处也会发生明显跳变。当前实现把循环位移写入 uFieldOffset,所有噪声都使用 referencePosition.xz,所以:
-
邻接区块在边界两侧连续采样;
-
区块搬移后会生成新世界区域对应的噪声;
-
旧位置与新位置之间不会出现噪声图案滑动;
-
用户可以持续拖动,而不会察觉 6000 单位的循环周期。
“无缝”其实包含两个不同问题
讨论无缝时,必须把下面两件事分开:
- 采样位置是否连续:边界两边不能重叠一列、漏掉一列或突然回到局部坐标 0;
- 曲面值和斜率是否连续:即使采样位置正确,高度函数本身也不能在边界换一套规则。
第一件事由区块坐标和采样间距保证;第二件事由绝对世界坐标上的连续噪声函数保证。3×3 只解决“哪里需要有几何载体”,它本身不会自动让曲面光滑。

颜色代表可复用的几何区块身份,黑线代表绝对世界高度函数 H(x)。浅蓝区块从左侧增加 6000 后,显示的不再是原来的 H([-3000,-1000)),而是重新计算的 H([3000,5000))。放大圈强调最终接缝仍按 10 单位连续采样;具体采样集合以正文公式为准。
先证明采样位置没有重叠或缺口
设区块宽度为 S,每轴粒子数为 n,采样间距为:
Δ = S / n = 2000 / 200 = 10
一个区块采用半开区间。它有 n 个样本:
p_k = kΔ, k = 0, 1, ..., n-1
所以局部样本是:
0, 10, 20, ..., 1990
对于世界基础偏移为 b_i 的区块,其世界采样集合为:
W_i = { b_i + kΔ | k = 0, 1, ..., n-1 }
相邻区块满足 b_(i+1) = b_i + S。前一区块的最后一个样本和后一区块的第一个样本分别为:
x_last = b_i + S - Δ
x_first = b_i + S
x_first - x_last = Δ
这个差值与区块内部任意两个相邻样本的间距完全相同。因此边界处:
-
没有两个粒子占据同一列;
-
没有多出 20 单位的裂口;
-
边界只是无限规则网格中的又一组普通相邻列。
当前系统是点云,不存在需要焊接的三角形边。这里所谓“完全连接”,是采样序列在世界坐标中无重叠、无缺项。
如果换成三角网格,通常会让左右区块都包含边界 x = b_i + S。因为两边都计算同一个 H(b_i + S,z),共享边上的顶点位置会完全相同;随后可以焊接顶点,或让两个 Mesh 各自保留一份位置相同的边界顶点。
相邻高度不需要相等
先只看 X 轴。设区块宽度 S = 2000,左区块基础偏移为 b₀ = 0,右区块基础偏移为 b₁ = S。
规则网格的采样间隔为 10,所以左区块最后一个粒子位于:
p_left = 1990
w_left = p_left + b₀ = 1990
右区块第一个粒子位于:
p_right = 0
w_right = p_right + b₁ = 2000
两个采样点的世界间距仍为 10,与区块内部任意相邻粒子的间距完全相同。边界没有重复点,也没有额外空隙。
现在考虑连续地形函数 H(w)。边界两侧得到的是:
左侧:H(1990)
右侧:H(2000)
这里不要求 H(1990) = H(2000),正如区块内部也不要求 H(1980) = H(1990)。要求只是 1990 → 2000 仍是一次普通的 10 单位相邻采样,其高度差由同一个连续函数决定,而不是突然切换到另一套地形。
连续函数的含义也不是“任意两个点的值相等”。更准确地说,当输入距离趋近于 0 时,输出差也趋近于 0:
当 ε → 0 时,H(x + ε) - H(x) → 0
当前点云的采样间距固定为 10,所以视觉上看到的是该连续曲面的离散采样。如果改成共享边界顶点的三角网格,左右两块在同一个 x = 2000 处都会求 H(2000),边界顶点才会严格重合。
如果错误地只使用局部坐标,则边界两侧会变成:
左侧:H(1990)
右侧:H(0)
两次采样相距 1990 个坐标单位,视觉上就会出现高度断层、颜色突变或重复纹样。这是最常见的接缝来源。
为什么曲面不仅不断开,而且是光滑的
采样位置连续只能排除裂缝,还不能保证曲线没有尖角。曲面光滑依赖高度函数 H(x,z) 的连续性和梯度连续性。
当前高度不是每个区块随机生成一组顶点,而是一个定义在整个二维世界上的函数:
H(x,z) = -40 × N(0.0013x + 4, 0.0013z + 4)
-90 × N(0.0006x + 32, 0.0006z + 32)
+10 × N(200x + 12, 200z + 12)
这里 N 是同一套 Simplex Noise。改变频率、平移输入、乘常数和把多层噪声相加,都不会凭空产生一条区块边界;它们仍是全局函数。
Simplex Noise 的每个角点贡献会乘一个紧支撑衰减核。当前实现的核心形式是:
m = max(0, 0.5 - distance²)
contribution = m⁴ × gradientDotOffset
当一个角点离开作用范围时,m 逐渐趋近 0;m⁴ 让贡献值及其低阶导数也平滑趋近 0,而不是突然关闭。因此相邻 Simplex 单元之间不会产生高度跳变或明显折角。
如果把 X 方向斜率写出来,多层高度的导数只是各层导数的加权和:
∂H/∂x = -40 × 0.0013 × N₁'(x,z)
-90 × 0.0006 × N₂'(x,z)
+10 × 200 × N₃'(x,z)
只要每层噪声在该处平滑,和值和斜率也会平滑。区块边界没有进入这个公式,所以 x = 2000、x = 3000 与 x = 1730 没有任何数学区别。
需要注意,高频 200× 噪声会故意制造细小颗粒起伏,因此“光滑”指没有由区块拼接造成的跳跃或折线,并不表示地表是低频的光滑瓷面。
同一原则还必须应用到高度以外的属性:
-
X/Z 位置扰动按绝对世界坐标采样,否则边界粒子会突然改变疏密;
-
空洞判断
D(x,z) < 0使用全局密度函数。它虽然通过step()形成锐利聚落边缘,但这条边缘是世界中的自然等值线,可以跨越区块边界,不会沿区块边界生成直线裂缝; -
颜色纹理按世界坐标以 200 单位重复,区块宽度 2000 恰好是其整数倍,回收前后的采样相位一致;
-
闪烁也以世界坐标为种子,避免同一粒子簇在跨区块后改变时间相位。
只让高度连续而让密度、颜色或扰动回到局部坐标,几何上可能没有裂缝,视觉上仍会暴露一条接缝。
用当前三个区块走一遍真实回收
只看 X 轴,初始三个区块的基础偏移为 -3000、-1000、1000。移动前的世界采样范围是:
| 区块 | 循环位移 | 世界半开区间 | 实际首末采样 |
|---|---|---|---|
| T0 | 0 | [-3000,-1000) |
-3000 ... -1010 |
| T1 | 0 | [-1000,1000) |
-1000 ... 990 |
| T2 | 0 | [1000,3000) |
1000 ... 2990 |
当观察中心向正 X 方向越过本轮边界后,T0 的循环位移变成 +6000:
| 区块 | 循环位移 | 新世界半开区间 | 实际首末采样 |
|---|---|---|---|
| T1 | 0 | [-1000,1000) |
-1000 ... 990 |
| T2 | 0 | [1000,3000) |
1000 ... 2990 |
| T0 | 6000 | [3000,5000) |
3000 ... 4990 |
最终新接缝是:
T2 最后一列:x = 2990,height = H(2990,z)
T0 回收后首列:x = 3000,height = H(3000,z)
间距:3000 - 2990 = 10
它与任何区块内部的 1980 → 1990 或 2310 → 2320 完全同构。更重要的是,T0 回收后把 3000 传给噪声函数,而不是把自己的局部 0 传进去。
在二维 3×3 情况中,X 和 Z 分别执行同样的周期计算。跨过一个方向时移动一列或一行;同时跨过两个方向时,角落区块会对角移动。绝对坐标仍是 (p.x + b.x + r.x, p.z + b.z + r.z),所以二维接缝和角点都服从同一个证明。
同一个槽位回收后发生了什么
继续使用 T0。它的基础偏移是 b = -3000,第一个局部粒子为 p = 0:
回收前:r = 0 → w_old = 0 - 3000 + 0 = -3000
回收后:r = 6000 → w_new = 0 - 3000 + 6000 = 3000
T0 的 Points、Geometry、GPU Buffer 和局部粒子 p 都没有变化,但这个槽位已经从“负责世界 [-3000,-1000)”改为“负责世界 [3000,5000)”。顶点 Shader 在新坐标上重新计算:
height = H(w_new)
density = D(w_new)
jitter = J(w_new)
color = C(w_new)
所以不能把它理解为“旧粒子的高度在移动途中改变”,而应理解为“旧的几何槽位已经被指派去绘制另一段世界”。发生回收的同一帧,渲染位置与上述世界采样偏移一起更新,不存在槽位已经移到前方、却仍暂时显示旧地貌的中间状态。
回收瞬间为什么看不到跳变
仅有坐标连续还不够,区块不能在相机正在注视它时搬走。回收公式中的 F / 2 和 S / 2 把判断基准放在整个 6000 场的半宽与区块中心上,使当前观察中心始终被 3×3 区块包围。
可以把一维情况写成目标约束:
让区块中心 c = b + r + S/2
始终落在观察中心 t 附近的一个 F 周期内
因为 r 只能取 ..., -6000, 0, 6000, ...,floor() 相当于求“当前应该使用哪个周期编号”。只有当观察中心跨过周期分界时,编号才改变。此时被搬运的区块位于整个 3 区块带的远端,主要可见区域仍由中间和前方区块覆盖。
透明粒子本身还有视空间雾化和暗背景。远端区块在到达回收边界前已经接近黑色,但这只是额外保险;无缝的必要条件仍是区块覆盖和世界坐标连续。
这个条件并非自动成立。如果相机可见地表宽度大于循环场的安全覆盖范围,用户可能同时看到区块从后方消失、从前方出现。可选修复有三种:
- 把 3×3 扩大为 5×5;
- 增大单区块尺寸或整个循环周期;
- 缩小相机远裁剪面,或用雾确保循环边界进入不可辨识区域。
当前相机距离、FOV、6000 单位场宽和深度雾共同保证主要可见区域落在安全覆盖内。
一个可移植的伪代码实现
下面的伪代码不依赖 Three.js,可用于任何支持对象位移和程序噪声的 3D 引擎:
S = 2000
GRID = 3
F = S * GRID
for each tile with baseOffset b:
cycleX = -floor((-target.x + b.x + F/2 + S/2) / F)
cycleZ = -floor((-target.z + b.z + F/2 + S/2) / F)
recycle = (cycleX * F, 0, cycleZ * F)
tile.objectPosition = recycle
tile.worldSampleOffset = b + recycle
vertex(localPosition p):
worldSample = p + tile.worldSampleOffset
visibleLocal = p + b
height = terrainNoise(worldSample.xz)
outputPosition = objectTransform(visibleLocal + height)
实现时必须检查 worldSample 与最终世界位置是否落在同一坐标域。任何一条路径少加一次 b 或 recycle,都会产生稳定但错误的接缝。
极远距离与浮点精度
这种方案允许观察中心持续增长,但 GPU 顶点 Shader 通常使用 32 位浮点数。当世界坐标达到非常大的数量级时,相邻 10 单位采样可能逐渐失去精度。当前体验的漫游尺度远未达到该限制。
若用于持续数小时或超大世界的产品,可以加入“浮动原点”:当观察中心超过阈值时,把相机、对象和逻辑原点整体减去同一大整数偏移,同时把累计世界偏移单独保存在高精度 CPU 变量中。噪声求值仍使用累计世界坐标,渲染坐标保持在原点附近。
地表形态:多频 Simplex Noise
空洞与聚落
地表不是均匀点阵。顶点 Shader 用多组不同频率的 Simplex Noise 组合出 fixedPositionNoise:
-
高频噪声形成粒子簇边缘和局部破碎;
-
中频噪声形成岛状聚落;
-
低频噪声形成大面积疏密变化;
-
余弦重映射强化正负区域之间的分界。
当 fixedPositionNoise < 0 时,粒子 Y 坐标增加 99999:
particlePosition.y +=
(1.0 - step(0.0, fixedPositionNoise)) * 99999.0;
它不会真的删除粒子,而是把不应显示的粒子移出相机范围。这比 CPU 每帧重写索引或几何更稳定,也保留了固定粒子池。
连绵起伏
可见粒子的高度由两层低频噪声和一层高频颗粒组成:
height = noise(world × 0.0013 + 4) × -40
+ noise(world × 0.0006 + 32) × -90
+ noise(world × 200 + 12) × 10
前两项形成缓慢起伏的地貌,第三项打破过度平滑的表面。X/Z 还会分别叠加约 8 单位的局部扰动,使规则网格不易被肉眼识别。
拖拽与相机运动
输入映射
拖拽由 MemoryExperience 接收 Pointer Events,并把相邻事件的位移传给 CameraRig.drag(deltaX, deltaY)。
水平拖动改变观察朝向:
targetYaw += deltaX × 0.0032 × (1 - targetZoom × 0.32)
垂直拖动沿当前水平视线方向推进观察中心:
forward = (-sin(yaw), 0, -cos(yaw))
movement = deltaY × 1.36 × (1 - targetZoom × 0.68)
targetLookAt += forward × movement
因此用户的体验不是“把一张地表图片拖走”,而是在固定世界坐标中旋转并移动观察中心。
缩放不是对象缩放
滚轮更新归一化的 targetZoom ∈ [0, 1]。CameraRig 根据 zoom 同时修改:
-
相机到观察中心的水平距离:
(1 - zoom) × 745 + 5; -
相机高度:由两个幂函数组合,在极近处略微压低;
-
透视相机 FOV:从 100° 插值到 25°;
-
拖动速度和指针轻微视差;
-
粒子点尺寸和后期景深强度。
地表本身没有整体缩放。靠近时看到更大的粒子,是相机距离、FOV 和 Shader 点尺寸共同作用的结果。
帧率无关阻尼
朝向、zoom 和观察中心都不会瞬间跳到目标值,而是使用指数阻尼:
damping = 1 - 0.0008^delta
current += (target - current) × damping
这种写法按真实帧间隔计算收敛比例,相比固定的 current += diff × 0.1,在 30 Hz、60 Hz、120 Hz 下具有更一致的手感。
粒子视觉合成
点尺寸
每个粒子的屏幕尺寸为:
pointSize = mix(500, 5000, zoom^2.1)
/ distanceToCamera
× 6
× noiseScale
× pixelRatio
× blinkScale
这使远景呈现为细密地表,近景逐渐解构为独立光点。像素比在桌面端限制为 1.65、移动端限制为 1.2,避免高 DPI 设备产生过大的 GPU 压力和光点尺寸。
颜色与叠加
颜色来自内置的 colorMap.png,以 200 世界单位循环采样。纹理保持 NoColorSpace,复现旧式渲染管线中红、橙、黄、白快速加法饱和的观感。
材质使用:
-
AdditiveBlending; -
transparent: true; -
depthWrite: false; -
depthTest: false。
因此粒子更像发光记忆痕迹,而不是互相遮挡的实体点云。
径向轮廓、闪烁和雾化
片元 Shader 用四段径向 alpha 构造“亮核心 + 柔和外圈”,并用固定世界坐标噪声控制少量闪烁。雾化使用视空间深度:
fogVisibility = exp2(
-density² × fogDepth² × log2(e)
);
雾只把 RGB 压向黑色,不直接裁掉 alpha。远处粒子因此会融入背景,而不是在某一距离突然消失。
每帧执行顺序
MemoryExperience.animate() 的顺序是视觉稳定的重要组成部分:
- 读取经过上限保护的
delta; CameraRig.update()得到本帧相机和观察中心;ParticleTerrain.update()计算 9 个区块的循环位置与 uniform;- 更新临时搜索粒子和图片节点;
MemoryPostFX.update()更新景深、色偏、完整度和残影参数;- 通过
EffectComposer渲染最终画面。
先更新相机再循环区块,可以保证本帧所有系统使用同一观察中心,避免地表、节点和后期效果产生一帧错位。
后期处理对“连续地表”的贡献
MemoryPostFX 不负责生成地形,但会把离散点阵融合成连续视觉:
-
AfterimagePass为运动保留短暂残影,弱化粒子位移的机械感; -
RGB Shift 产生轻微色散;
-
Horizontal/Vertical Tilt Shift 让非焦点区域更像景深中的地表;
-
Memory Shader 叠加暗角、冷暖偏移和细颗粒噪点;
-
zoom 越远,模糊越强;图片节点进入前景时关闭残影,避免节点下方闪烁。
后期只能柔化运动,不能掩盖错误的区块坐标。真正的连续性仍来自稳定的世界坐标噪声和正确的循环公式。
从远景到近景:实际效果
从连续地表到独立粒子
| 中距离 | 近距离 |
|---|---|
![]() |
![]() |
中距离时仍能读出连续山脊,同时开始看见单个红橙色粒子。近距离时点尺寸显著增大,暖白图片节点只在 zoom > 0.7 后进入画面。地表几何没有缩放;变化来自相机距离、FOV 和 Shader 点尺寸。
拖动前后
| 拖动前 | 沿当前视线方向拖动后 |
|---|---|
![]() |
![]() |
两张图的粒子聚落不同,说明相机进入了新的世界坐标;但颜色、密度、雾和节点尺度保持一致。区块回收发生在内部,画面中没有 2000 或 6000 单位的直线边界。
性能模型
稳定成本
漫游距离不会改变以下指标:
-
粒子数量;
-
BufferGeometry 数量;
-
draw call 数量;
-
纹理数量;
-
CPU 端场景节点数量。
CPU 每帧只更新 9 个区块位置和 uniform。主要开销位于 GPU 顶点计算、透明粒子填充率与后期处理。
移动端降级
移动端将单轴采样从 200 降为 142,总粒子数约减少一半;同时降低像素比、关闭抗锯齿和残影 Pass。区块尺寸、循环周期和世界噪声保持不变,所以视觉结构不会因降级而改变。
常见错误:哪些实现会暴露接缝
不要把区块偏移从噪声坐标中移除
错误结果:每个区块重复同一噪声图案,回收时发生跳变。
不要用相机坐标采样地形噪声
错误结果:移动相机时噪声会贴着屏幕滑动,世界失去空间稳定性。
不要在跨区块时重建 Geometry
错误结果:产生 GC、上传缓冲区和帧时间尖峰。正确做法是只移动现有 9 个 Points。
不要用欧氏距离直接裁掉远景 alpha
这会形成肉眼可见的圆形消失边界。当前实现按视空间深度雾化 RGB,让远景自然沉入黑色。
结语
这套无限地表真正值得复用的,并不是某一条取模或回收公式,而是把资源身份和世界内容分开:GPU 槽位可以循环使用,世界坐标却持续向前;每次槽位被重新指派,都在新的坐标上重新求值。
因此,无缝来自三件事同时成立:几何覆盖没有空档、采样坐标没有跳回局部原点、所有视觉属性共享同一个连续世界函数。只要守住这三个条件,3×3 区块就不再是一张反复出现的地图,而会成为观察无限坐标域的一扇有限窗口。


