
I-remember 复现:图片节点如何融入粒子地表并完成消融
图片节点怎样真正属于粒子地表?从 CPU/GPU 地形函数对齐开始,拆解节点池滞回、Billboard、白点到照片的显现、透明层级与四层消融。
图片节点如何融入粒子地表:Billboard、显现与消融
引言
把一张图片放进 Three.js 场景并不困难,困难的是让它看起来原本就属于粒子地表:远处是一个暖白光点,靠近后才显露照片;相机拖动时它留在正确的世界位置;多个半透明节点重叠时不发黑;离开视野后又能自然融回环境。
如果把这些要求交给一个单独的卡片组件,结果通常会像悬浮在镜头前的 UI。更稳妥的做法,是让节点从选址开始就遵守地表的世界坐标规则,再用 Billboard、连续状态量和分层透明度完成显现与消融。
这篇文章将从“节点如何落到地表”开始,逐步讲到节点池滞回、始终面向相机、照片显现、四层消融以及透明渲染中的层级问题。
先区分四个容易混淆的概念
| 概念 | 它改变什么 | 它不改变什么 |
|---|---|---|
| 地表贴合 | 节点的世界位置 (x,y,z) |
不负责图片显现 |
| Billboard | 节点的旋转,使正面朝向相机 | 不把节点固定在屏幕上 |
| 照片显现 | 白色纸面与照片颜色的混合比例 | 不移动相机或节点 |
| 消融与回收 | 片元边界及整体透明度 | 不销毁 Mesh 或纹理 |
这四层共同工作,才会得到“节点像从地表中长出来、悬停时成为照片、离开后又融回地表”的观感。
术语与符号
| 名称 | 符号 | 含义 |
|---|---|---|
| 世界平面坐标 | (x,z) |
地表上的水平位置 |
| 地表高度函数 | H(x,z) |
给出该位置的 Y 高度 |
| 地表密度函数 | D(x,z) |
判断该位置是粒子聚落还是空洞 |
| UV 坐标 | (u,v) |
图片平面内部从 0 到 1 的二维坐标 |
| 径向距离 | r |
片元到图片中心的归一化距离 |
| 悬停量 | h |
uHover ∈ [0,1] |
| 生命周期透明度 | o |
uOpacity ∈ [0,1] |
| 观察缩放 | zₘ |
场景 zoom,范围 0 到 1 |
下图是项目近景状态下的实际画面。暖白大点是尚未悬停的图片节点;它们混在红橙色地表粒子之间,而不是排列在固定屏幕层中。

实际近景:白色节点有不同世界深度和投影尺寸,位置随地表聚落分布。
先明确要解决的问题
图片节点需要同时满足以下视觉和交互目标:
- 未交互时像地表上的暖白色记忆光点,而不是提前显示图片;
- 节点必须位于粒子聚落表面,不能漂浮在空中;
- 相机拖动后节点应留在原世界坐标,并随地表一起移动;
- 悬停时节点逐渐放大并显露照片;
- 离开悬停、关闭详情或离开可见区域后,应平滑恢复或消融;
- 多节点重叠时不能出现透明黑色交叠;
- 前景节点必须高于其他节点,且不受残影造成的下层闪烁影响;
- 节点回收不能在视野边缘成批闪现或消失。
系统结构
完整链路可以按一次节点生成过程理解:
- 把观察中心量化到 10 单位的地形网格;
- 在附近网格上计算与粒子地表相同的密度函数
D(x,z); - 只保留
D(x,z) > 0.75的聚落核心; - 用相同高度函数
H(x,z)和相同 X/Z 扰动得到节点世界位置; - 从节点池分配一个平面 Mesh,并让它每帧朝向相机;
- 未悬停时 Shader 输出暖白纸面,悬停时混入照片;
- 节点越过空间回收边界时,整体透明度归零并回到池中。
节点并不修改粒子地表;它们是独立的 THREE.Mesh,但使用与地表完全一致的空间函数定位,再通过颜色、雾化、尺寸和后期处理融入同一视觉系统。
先区分地表区块复用与节点池复用
两套系统都在复用 GPU 对象,也都使用绝对世界坐标,但“复用”的时机和含义不同:
| 系统 | 复用的载体 | 载体何时被重新指派 | 重新指派后计算什么 |
|---|---|---|---|
| 粒子地表 | 3×3 个 Points 区块槽位 |
某一行或列落到观察中心后方,其循环位移增加或减少 6000 | 新世界坐标上的高度、密度、扰动、颜色和闪烁 |
| 图片节点 | 有限个 Plane Mesh 节点槽位 | 原节点淡出且越过外圈保留半径,或进入另一种布局模式 | 新候选网格上的位置、地表高度、图片归属和生命周期状态 |
一个仍在保留范围内的图片节点不会随着观察中心每移动一格就重新定位。它保持原来的世界坐标,让相机拖动自然产生视差;只有归还节点池并再次分配后,该 Mesh 才代表另一个世界位置或另一张图片。
这与地表槽位的共同原则是:GPU 对象身份不等于世界内容身份。判断视觉对象在哪里、长什么样,必须以它当前绑定的世界坐标和业务数据为准,而不能以 Mesh 或 Buffer 在内存中的身份为准。
节点几何与材质
几何
所有节点共享一张 512×512 的 PlaneGeometry:
new THREE.PlaneGeometry(512, 512, 1, 1)
512 是 Shader 运算前的局部几何尺寸,不是最终屏幕尺寸。实际大小由每帧计算的世界缩放决定,因此未悬停节点仍然是小型光点。
材质设置
节点使用自定义 ShaderMaterial:
transparent: true;depthWrite: false;depthTest: false;DoubleSide;NoToneMapping;- 普通透明混合。
关闭深度测试可以避免半透明平面互相切割;实际前后层级由 renderOrder 管理。
纹理与降级
图片使用 sRGB 色彩空间和线性过滤。如果加载失败,则生成一张暖色径向渐变 CanvasTexture,保证节点仍可显示和交互。
节点如何贴合粒子地表
同一地形函数的 CPU 版本
粒子地表在 GPU 顶点 Shader 中计算高度和空洞,而节点选址需要在 CPU 侧完成。因此 MemoryCloud.js 实现了与 Shader 对齐的 simplexNoise2D() 和 getTerrainNoise()。
节点不是根据屏幕位置猜测地表,而是直接在同一世界坐标上重复相同噪声计算:
相同 (worldX, worldZ)
→ 相同 terrainNoise
→ 相同地表高度
→ 相同 X/Z 颗粒扰动
这是节点与地表不分离的根本保证。

白板图读法:右下角只接受密度大于 0.75 的网格;该网格通过同一 (x,z) 输入高度函数,得到地表峰顶的 Y;节点平面位于这个世界点,但旋转始终朝向相机。
CPU 与 GPU 为什么必须逐项一致
粒子地表的高度在 GPU 顶点 Shader 中计算,节点位置在 CPU 中计算。如果二者只使用“相似”噪声而不是同一算法,会产生三种典型误差:
- 噪声梯度表不同:节点会落在另一座虚构山丘上;
- 频率或常量不同:近处看似贴合,远处逐渐漂离;
- 只复制 Y 高度、不复制 X/Z 扰动:节点落在规则网格中心,粒子簇却偏在旁边。
因此需要把下列内容视为一份跨 CPU/GPU 的数据协议:
Simplex skew/unskew 常量
289 周期的 permutation 算法
每层噪声的输入频率
每层噪声的常量偏移
每层噪声的权重和正负号
X、Y、Z 三个方向的最终组合方式
维护时不应分别“优化”两份函数。更安全的做法是给一组固定 (x,z) 测试样本,在 CPU 侧保存期望值,并用调试 Shader 把 GPU 结果编码成颜色或浮点纹理进行比对。
网格选址
漫游模式以 10 世界单位为一个逻辑网格:
centerGridX = trunc(cameraTarget.x / 10)
centerGridZ = trunc(cameraTarget.z / 10)
候选范围随 zoom 改变:
radius = round(lerp(24, 12, smoothstep(0.7, 1.0, zoom)))
远景覆盖更大的世界区域,近景覆盖更紧凑的区域。节点只选择 terrainNoise > 0.75 的网格点,从而避开地表空洞、低密度区和视觉上不适合承载图片的位置。
稳定分布
符合地形阈值的候选点会根据 (gridX, gridZ) 的确定性哈希值排序。相同世界网格始终得到相同分数,因此节点分布不会因帧率或遍历顺序随机抖动。
漫游状态的目标可见数量为 12。这个数量维持原版的稀疏感,避免白点重新变成大量漂浮物。
位置公式
候选网格转换为节点世界坐标时,复用粒子地表的高度与横向扰动:
x = gridX × 10
z = gridZ × 10
node.x = x + (noise(xz × 0.3) - 0.5) × 8
node.y = noise(xz × 0.0013 + 4) × -40
+ noise(xz × 0.0006 + 32) × -90
+ noise(xz × 200 + 12) × 10
node.z = z + (noise(xz × 0.4) - 0.5) × 8
由于粒子和节点使用同一公式,节点会落在对应粒子聚落的表面,不会形成固定高度的悬浮层。
一个数值化的理解方式
假设候选网格为 (gridX, gridZ) = (18, -7),首先得到未扰动坐标:
x = 18 × 10 = 180
z = -7 × 10 = -70
之后才把 (180,-70) 输入三组高度噪声和两组横向噪声。注意所有噪声都使用原始网格世界坐标作为输入;不能先加入 X/Z 扰动再计算高度,否则 CPU 与 GPU 的求值顺序会不同。
最终节点位置可以抽象为:
Pnode(x,z) = (
x + Jx(x,z),
H(x,z),
z + Jz(x,z)
)
其中 Jx/Jz 是横向颗粒扰动,H 是低频山势叠加高频颗粒后的高度。粒子地表对同一网格点使用完全相同的 Pnode 结构。
拖动时的节点池、缓冲和滞回
为什么不能每格重新分配
观察中心每移动 10 单位就会进入新网格。如果此时把全部节点重新分配到新候选点,会产生明显的“整批消失—整批出现”。如果只把回收距离放大,但节点池仍只有当前 12 个节点,旧节点会全部留在视野外,新区域又没有节点可用。
当前实现把“画面密度”和“后台容量”分离:
- 每段记忆创建 3 个漫游实例;
- 正常视野目标仍为 12 个节点;
- 额外实例作为视野外预加载和拖动尾部保留池;
内圈与外圈
候选分配发生在内圈 radius 中,已有节点则保留到更大的外圈:
retentionRadius = max(14, floor(radius × 1.65))
cardRetentionRadius = retentionRadius + seededMargin
seededMargin ∈ [0, 4]
节点进入外圈后不会立即消失。只有超过自己独立的回收半径,才会归还节点池。不同节点的额外余量不同,所以它们不会在同一条边界上同时回收。
这是一种空间滞回:出现阈值和消失阈值不同。它消除了边界附近反复横移时的闪烁,也允许节点继续固定在原世界位置。
为什么叫“滞回”
普通单阈值系统只有一条边界:
distance <= R → 显示
distance > R → 隐藏
当相机在 R 两侧轻微往返时,节点会逐帧显示、隐藏。滞回系统使用两条不同边界:
进入内圈 R_in → 允许分配新节点
离开外圈 R_out → 才允许回收旧节点
并且 R_out > R_in
位于 R_in < distance <= R_out 的节点保持上一次状态。这段区域就是缓冲带。
当前参数对应的实际范围
网格间距为 10,使用 Chebyshev 距离:
distanceGrid = max(abs(nodeGridX - centerGridX),
abs(nodeGridZ - centerGridZ))
这等价于以观察中心为中心的正方形范围,计算量比欧氏距离小,并与候选网格遍历方式一致。
| zoom | 内圈半径(格) | 内圈世界半径 | 基础外圈(格) | 基础外圈世界半径 |
|---|---|---|---|---|
| 0.70 | 24 | 240 | 39 | 390 |
| 0.85 | 18 | 180 | 29 | 290 |
| 1.00 | 12 | 120 | 19 | 190 |
每个节点还增加 0–4 格的确定性余量,所以同一方向上最早和最晚回收的节点可相差最多 40 世界单位。内圈负责保持约 12 个候选,外圈只保留已经存在的节点,不会把所有缓冲实例同时显示在中心。
为什么需要额外实例
假设画面中正好只有 12 个 Mesh,并把 R_out 放大到 300。相机移动后,旧 Mesh 会正确留在后方,但新内圈没有空闲 Mesh 可以生成前方节点,于是画面暂时变空。
当前每段记忆创建 3 个漫游实例,相当于把节点池扩大到可见目标的数倍:
- 第一部分维持当前内圈密度;
- 第二部分留在拖动尾部,避免突然消失;
- 第三部分可以在拖动前方淡入。
这是“延迟回收”与“预先补位”必须同时存在的原因。
节点复用
节点被分配到新位置时会:
- 写入新的
gridX/gridZ和terrainNoise; - 计算新的
targetPosition; - 直接把 Mesh 放到新世界位置;
- 把当前
uOpacity设为 0; - 把
opacityTarget设为 1,让它从不可见状态淡入。
回收时只把 opacityTarget 设为 0 并清除网格占用,不销毁 Mesh、材质或纹理。
为什么节点始终面向相机
每帧执行:
card.lookAt(camera.position);
节点是 Billboard:它保留自己的世界位置,但局部正面永远朝向相机。这解决了两个问题:
- 地表有坡度和相机有旋转时,图片不会侧成一条薄片;
- 点击图片不再需要调度相机去“校正图片角度”,打开详情时镜头也不会发生瞬时复位。
Billboard 不代表节点固定在屏幕上。节点仍然处于世界坐标中,拖动场景时会产生正确的视差和位移。
从矩阵角度看,节点的位置向量不变,只重建旋转部分:
forward = normalize(cameraPosition - nodePosition)
right = normalize(cross(worldUp, forward))
up = cross(forward, right)
right/up/forward 构成节点的朝向基。Three.js 的 lookAt() 完成等价操作。节点材质使用 DoubleSide,可以容忍不同坐标系中平面正面的符号差异。
节点尺寸如何融入地表
漫游模式的缩放公式为:
scale = 3 / distanceToCamera
× lerp(0.4, 1.0, terrainNoise)
× devicePixelRatio
512 大小的平面先乘以该 world scale,再经过透视投影。近似忽略相机倾角时,屏幕尺寸满足:
screenSize ∝ planeSize × worldScale / distance
∝ 512 × 3 × noiseScale / distance²
所以它不是严格的恒定屏幕尺寸,而是随靠近快速增大。这是有意设计:远处只留下很小的记忆信号,真正靠近后才成为可操作节点。
它产生以下效果:
- 距离越远,世界缩放越小,屏幕尺寸相对稳定;
- 地形噪声较高的聚落节点稍大,低密度边缘节点稍小;
- 高 DPI 屏幕得到适当补偿;
- 不会因为 PlaneGeometry 是 512 而显示成巨大图片平面。
白色节点到照片的显现
状态量
每个节点有三类互相独立的连续状态:
| uniform | 来源 | 作用 |
|---|---|---|
uHover |
hoverTarget |
白色纸面与照片纹理的混合、悬停放大 |
uAppear |
appearTarget |
新增节点的尺度入场 |
uOpacity |
opacityTarget × reveal × integrity |
节点整体显隐和记忆完整度 |
它们都用帧率无关的指数阻尼逼近目标值,不做瞬间切换。
顶点阶段的悬停放大
顶点 Shader 先处理入场尺度,再处理悬停尺度:
positionLocal.xyz *= clamp(pow(uAppear, 10.0), 0.08, 1.0);
positionLocal.xyz *= mix(1.0, uPopScale, uHover);
这里使用 uPopScale = 3。uHover 从 0 平滑走向 1,因此节点不会突然放大。
片元阶段的照片混合
未悬停时,节点显示为由白色到暖米色渐变的 paper:
vec3 paper = mix(
vec3(1.0),
vec3(252.0, 222.0, 184.0) / 255.0,
radial
);
vec3 color = mix(paper, photo.rgb, vHover);
当 uHover 增大,颜色逐步从纸面过渡到照片纹理。白点和照片不是两个 DOM/3D 对象切换,而是同一 Mesh、同一 Shader 中的连续混合,因此不会发生位置或层级跳变。

实际悬停:中央偏右节点的外圈扩大,内部已经从暖白纸面混入照片。照片仍位于原世界位置,镜头没有进行角度校正。
阻尼速度的可解释性
状态更新使用统一形式:
value += (target - value) × (1 - base^delta)
把它展开后,误差随时间按 base^time 衰减,与帧率无关。当前几个状态的近似半衰期为:
| 状态 | base | 误差减半时间 |
|---|---|---|
悬停 uHover |
0.0005 | 约 0.091 秒 |
新增节点 uAppear |
0.006 | 约 0.136 秒 |
整体透明度 uOpacity |
0.012 | 约 0.157 秒 |
悬停最快,保证响应;整体透明度稍慢,避免节点回收显得像开关。
消融是如何实现的
当前节点的“消融”由四层机制共同完成,不是单一透明度动画。

白板图读法:先求片元半径 r;0–0.8 是完整核心,0.8–1.0 是噪声侵蚀环;最后所有片元再乘生命周期透明度。下方曲线显示 alpha 在核心保持 1,在外圈带噪下降。
第一层:径向边界
片元先把 UV 转换为以节点中心为原点的极坐标:
centered = (uv - 0.5) × 2
radial = length(centered)
angle = atan(centered.y, centered.x)
半径小于 0.8 的核心区域保持完整,0.8 到 1.0 之间进入消融边缘。
第二层:颗粒噪声
边缘区域使用基于 UV 与时间的哈希噪声 grain。越靠近外缘,噪声对 alpha 的影响越强,所以轮廓不是规则圆形,而是颗粒状破碎。
第三层:方向性侵蚀
Shader 用两个余弦项组合出 directionalNoise:
directionalNoise = average(
cos((angle + π/4) × 2),
cos(angle + π/4)
)
它让消融边界在不同角度具有不同强度,避免形成均匀的圆形羽化。
完整公式可以写成:
r = length((uv - 0.5) × 2)
theta = atan(centered.y, centered.x)
g = hash(uv × 10 + time × 0.0001)
d = 0.5 × (
0.5 × (cos((theta + π/4) × 2) + 1)
+ 0.5 × (cos(theta + π/4) + 1)
)
f = 1 - smoothstep(0.8, 1.0, r)
core = 1 - step(0.8, r)
edgeStrength = (1 - f) × (0.75 + 0.25 × d)
edge = mix(1, g, edgeStrength) × f
shapeOpacity = core + edge
逐项解释:
r把方形 UV 映射成以中心为原点的圆形距离;g是每个片元不同的颗粒值,乘time × 0.0001只产生极慢变化,避免边缘像电视雪花一样抖动;d随角度变化,让某些方向更容易暴露颗粒噪声;f在 0.8 以内为 1,接近 1.0 时平滑降到 0;core保护内部圆盘;edge只在外圈把规则羽化替换成噪声侵蚀。
在核心区域,core + edge 可能达到 2。这不是最终 alpha;后续 clamp 会把它饱和为 1,从而保证中心完全不透明。
第四层:生命周期透明度
径向消融结果还会乘以 uOpacity:
alpha = clamp(opacity * uOpacity, 0.0, 1.0);
if (alpha < 0.012) discard;
uOpacity 的目标值为:
opacityTarget × reveal × integrity
其中:
opacityTarget控制节点池分配和回收;reveal = smoothstep(0.7, 0.74, zoom)控制靠近地表时的整体显现;integrity表示记忆完整度,统一影响场景的衰退程度。
因此用户靠近时,节点会在很窄但平滑的 zoom 区间中出现;节点离开保留外圈后,则由 uOpacity 阻尼淡出。
zoom 显现曲线
smoothstep(0.7, 0.74, zoom) 不是线性插值,而是三次 Hermite 曲线:
x = clamp((zoom - 0.7) / 0.04, 0, 1)
reveal = x² × (3 - 2x)
几个采样点:
| zoom | x | reveal |
|---|---|---|
| 0.700 | 0.00 | 0.000 |
| 0.710 | 0.25 | 0.156 |
| 0.720 | 0.50 | 0.500 |
| 0.730 | 0.75 | 0.844 |
| 0.740 | 1.00 | 1.000 |
曲线在首尾的斜率为 0,所以节点不会在阈值处突然开始或停止变化。
为什么要 clamp alpha
原始边缘公式的核心 alpha 可能大于 1。如果把超范围 alpha 直接交给普通透明混合,重叠节点会产生异常的暗色月牙。当前实现先执行:
clamp(opacity * uOpacity, 0.0, 1.0)
这样多个白色节点重叠时只会正常叠亮或遮盖,不会出现透明黑色区域。
雾化与地表统一
节点和粒子地表使用相同的 FOG_DENSITY = 0.0006,并都按视空间深度计算 FogExp2。节点片元的 RGB 会乘以雾可见度,但 alpha 仍由节点消融控制。
这保证远处节点随地表一起沉入黑色,而不是在远景中保留一个不受环境影响的白色 UI 圆点。
交互、层级与闪烁控制
射线拾取
每帧使用 THREE.Raycaster 检测当前可交互节点。只有 uOpacity > 0.16 的节点可以被拾取,避免用户点击到尚未显现或正在消融的节点。
渲染层级
| 状态 | renderOrder |
|---|---|
| 普通节点 | 2 |
| 悬停节点 | 100 |
| 已选择节点 | 200 |
悬停或选择后的节点始终在其他透明节点之后绘制,避免底层节点透过前景照片闪烁。
残影控制
当存在悬停或已选择节点时,MemoryPostFX 会把 AfterimagePass 的 damp 设为 0。背景地表仍正常渲染,但前景图片不会留下多帧残影,也不会把旧节点轮廓拖在照片下方。
关闭详情后的恢复
关闭详情时,MemoryExperience.restoreFromMemory() 会清除 selected 状态并恢复漫游交互。节点继续由当前 hover、opacity 和地表布局状态驱动,因此会回到暖白节点形态,而不是停留在照片状态。
节点状态流
| 当前状态 | 触发条件 | 下一状态 | 连续变量变化 |
|---|---|---|---|
| 未分配 | 获得地表候选点 | 淡入 | opacityTarget: 0 → 1 |
| 淡入 | uOpacity 接近目标 |
白色节点 | 保持纸面颜色 |
| 白色节点 | 指针悬停 | 照片显现 | hoverTarget: 0 → 1 |
| 照片显现 | 点击 | 已选择 | renderOrder: 100 → 200 |
| 已选择 | 关闭详情 | 白色节点或悬停 | 清除 selected,恢复交互 |
| 照片显现 | 指针离开 | 白色节点 | hoverTarget: 1 → 0 |
| 白色节点 | 离开内圈但未出外圈 | 保留 | 位置和 opacity 不变 |
| 保留 | 观察中心返回 | 白色节点 | 继续复用原世界位置 |
| 保留 | 超过独立外圈 | 未分配 | opacityTarget: 1 → 0 |
可移植伪代码
updateLayout(cameraTarget, zoom):
centerCell = trunc(cameraTarget.xz / 10)
innerRadius = round(lerp(24, 12, smoothstep(0.7, 1, zoom)))
outerRadius = max(14, floor(innerRadius * 1.65))
for each assigned node:
distance = chebyshev(node.cell, centerCell)
if distance <= outerRadius + node.margin:
retain node
else:
return node to pool
candidates = all cells inside innerRadius
where terrainDensity(cell * 10) > 0.75
and cell is not occupied
deterministically sort candidates
assign pooled nodes until inner area contains about 12 nodes
updateNode(node, delta):
node.position -> terrainPosition(node.cell)
node.rotation -> face camera
node.hover -> damp toward hoverTarget
node.opacity -> damp toward opacityTarget * reveal(zoom) * integrity
这段伪代码不依赖 Three.js;只需要目标引擎支持程序噪声、透明材质、相机朝向和射线拾取。
性能与资源模型
- 所有节点共享一份 PlaneGeometry;
- 每个实例使用独立 ShaderMaterial,以保存独立动画 uniform;
- 同一记忆的漫游实例共享图片纹理;
- 节点移动、缩放、billboard 和透明度在固定数组上逐帧更新;
- 拖动时只复用 Mesh,不重复加载图片;
- 候选布局只在观察中心跨越 10 单位网格或 radius 变化时刷新,不是每帧重新搜索。
CPU 的主要成本是布局刷新时的候选噪声计算;GPU 的主要成本是透明平面覆盖面积。当前 12 个目标可见节点和有限缓冲池能把两者控制在稳定范围内。
常见错误:为什么节点会脱离地表
不要给节点使用固定 Y 高度
错误结果:节点形成悬浮平面,拖动和旋转后与粒子地形明显分离。
不要只复制地表高度而忽略 X/Z 扰动
错误结果:节点会落在规则网格中心,与实际粒子簇有轻微但可见的错位。
不要让节点跟随相机目标重新居中
错误结果:拖动场景后节点仍停留在原屏幕区域,看起来像固定 UI。节点只能通过世界坐标和相机投影移动。
不要每跨一个网格就重排全部节点
错误结果:白点成批闪现。必须使用内外双阈值、保留区和额外节点池。
不要重新启用透明平面的深度写入
错误结果:半透明边缘会互相切割。前景优先级应由 renderOrder 和明确的交互状态控制。
不要通过调度镜头校正图片角度
图片应每帧 billboard 面向相机。调度镜头会在详情打开时产生角度突变和复位跳动。
结语
图片节点能够融入粒子地表,并不是因为最后叠加了一层相似的颜色或雾,而是因为它从位置、朝向、尺寸到生命周期都遵循同一个三维世界。
地表函数决定节点在哪里,Billboard 只修正朝向而不改变世界位置,连续状态量负责从白点过渡到照片,径向与颗粒噪声塑造消融边缘,节点池和滞回则保证拖动过程中不会整批闪现。把这些职责拆开后,每一层都很简单,组合起来却能让一张普通图片获得“从地表生长出来”的空间感。