I-remember 复现:用 9 个循环区块实现无限粒子地表
图形与可视化
42 分钟阅读

I-remember 复现:用 9 个循环区块实现无限粒子地表

如何用固定的 3×3 GPU 粒子区块构造可无限拖动的连续地表?从几何槽位复用、世界坐标重采样出发,逐步解释接缝连续、曲面光滑、Shader 表现与相机调度。

2026年7月21日·

无限粒子地表:用 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) 从世界坐标确定性地产生地形值的连续函数

下图是项目在远景状态下的实际画面。此时看到的是密集点阵形成的山脊、谷地和空洞,而不是一张平面图片。

1.00

实际远景:画面中的地貌由固定世界坐标噪声产生。相机之外仍只有 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 单位的周期。

“无限且无缝”由三个独立条件共同成立

  1. 覆盖不断档:9 个区块始终覆盖观察中心周围;落到后方的槽位会被放到前方。
  2. 坐标不断档:回收后的相邻区块继续采样连续的世界坐标段,例如 2990, 3000, 3010
  3. 函数不断档:所有区块都调用同一个世界函数 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 才有任何区块变化。

1.00

白板图读法: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 中不变的局部坐标,buTileOffsetr 同时进入对象变换和 uFieldOffset。两条路径只是在 Three.js 的不同阶段完成加法,数学结果相同。

1.00

上方橙色等高线跨越虚线边界连续;黑色局部坐标在新地砖重新从 0 开始,蓝色世界坐标则继续增长。地形只能使用蓝色坐标求值。

无接缝的关键不变量

无限地表必须始终满足:

同一绝对世界坐标 → 同一噪声结果

如果噪声只用粒子的局部坐标采样,区块回收后会重复显示同一块纹理,边界处也会发生明显跳变。当前实现把循环位移写入 uFieldOffset,所有噪声都使用 referencePosition.xz,所以:

  • 邻接区块在边界两侧连续采样;

  • 区块搬移后会生成新世界区域对应的噪声;

  • 旧位置与新位置之间不会出现噪声图案滑动;

  • 用户可以持续拖动,而不会察觉 6000 单位的循环周期。

“无缝”其实包含两个不同问题

讨论无缝时,必须把下面两件事分开:

  1. 采样位置是否连续:边界两边不能重叠一列、漏掉一列或突然回到局部坐标 0;
  2. 曲面值和斜率是否连续:即使采样位置正确,高度函数本身也不能在边界换一套规则。

第一件事由区块坐标和采样间距保证;第二件事由绝对世界坐标上的连续噪声函数保证。3×3 只解决“哪里需要有几何载体”,它本身不会自动让曲面光滑。

1.00

颜色代表可复用的几何区块身份,黑线代表绝对世界高度函数 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 = 2000x = 3000x = 1730 没有任何数学区别。

需要注意,高频 200× 噪声会故意制造细小颗粒起伏,因此“光滑”指没有由区块拼接造成的跳跃或折线,并不表示地表是低频的光滑瓷面。

同一原则还必须应用到高度以外的属性:

  • X/Z 位置扰动按绝对世界坐标采样,否则边界粒子会突然改变疏密;

  • 空洞判断 D(x,z) < 0 使用全局密度函数。它虽然通过 step() 形成锐利聚落边缘,但这条边缘是世界中的自然等值线,可以跨越区块边界,不会沿区块边界生成直线裂缝;

  • 颜色纹理按世界坐标以 200 单位重复,区块宽度 2000 恰好是其整数倍,回收前后的采样相位一致;

  • 闪烁也以世界坐标为种子,避免同一粒子簇在跨区块后改变时间相位。

只让高度连续而让密度、颜色或扰动回到局部坐标,几何上可能没有裂缝,视觉上仍会暴露一条接缝。

用当前三个区块走一遍真实回收

只看 X 轴,初始三个区块的基础偏移为 -3000-10001000。移动前的世界采样范围是:

区块 循环位移 世界半开区间 实际首末采样
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 → 19902310 → 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 / 2S / 2 把判断基准放在整个 6000 场的半宽与区块中心上,使当前观察中心始终被 3×3 区块包围。

可以把一维情况写成目标约束:

让区块中心 c = b + r + S/2
始终落在观察中心 t 附近的一个 F 周期内

因为 r 只能取 ..., -6000, 0, 6000, ...floor() 相当于求“当前应该使用哪个周期编号”。只有当观察中心跨过周期分界时,编号才改变。此时被搬运的区块位于整个 3 区块带的远端,主要可见区域仍由中间和前方区块覆盖。

透明粒子本身还有视空间雾化和暗背景。远端区块在到达回收边界前已经接近黑色,但这只是额外保险;无缝的必要条件仍是区块覆盖和世界坐标连续。

这个条件并非自动成立。如果相机可见地表宽度大于循环场的安全覆盖范围,用户可能同时看到区块从后方消失、从前方出现。可选修复有三种:

  1. 把 3×3 扩大为 5×5;
  2. 增大单区块尺寸或整个循环周期;
  3. 缩小相机远裁剪面,或用雾确保循环边界进入不可辨识区域。

当前相机距离、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 与最终世界位置是否落在同一坐标域。任何一条路径少加一次 brecycle,都会产生稳定但错误的接缝。

极远距离与浮点精度

这种方案允许观察中心持续增长,但 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() 的顺序是视觉稳定的重要组成部分:

  1. 读取经过上限保护的 delta
  2. CameraRig.update() 得到本帧相机和观察中心;
  3. ParticleTerrain.update() 计算 9 个区块的循环位置与 uniform;
  4. 更新临时搜索粒子和图片节点;
  5. MemoryPostFX.update() 更新景深、色偏、完整度和残影参数;
  6. 通过 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 区块就不再是一张反复出现的地图,而会成为观察无限坐标域的一扇有限窗口。