第105篇:三维特效-流动图片墙体效果——让箭头沿墙长滚,它俩竟是同一份代码

上一篇 #104 把 WallTrail 拆完,结尾照例留了下一站。原话是这么说的:

本篇把 u 从 st.t 换成 st.s 之后,WALL_TRAIL 和 #105 的 WALL_IMAGE_TRAIL 就只差一个 repeat 了------两者的采样坐标会变成同一句。到时候 #105 要回答的问题就变成:这两个材质到底是不是同一个东西?如果真的一样,那 #104 和 #105 就是同一份代码的两次注册。

翻开 src/cases/wall-image-trail/ 和同一个 wall-effects-lib/materials.ts,答案几乎是"是"------而且比"几乎"还更干脆:WALL_IMAGE_TRAIL_SOURCE 里那句 texture(image, vec2(fract(st.s - time), st.t)),正是 #104 第七节"抄之前先改的八处"里第 1 处的修复模板,一字不差。 #104 说"把 st.t 换成 st.s 这一个字符,贴图就从 3.10% 用到 100%、流动变干净",而 #105 把这句直接写进了源码。

所以本篇干的第一件事,就是把这个"同一份代码的两次注册"的猜测坐实:逐行比 WALL_TRAIL_SOURCE 和 WALL_IMAGE_TRAIL_SOURCE,看它们到底差在哪、#105 相对 #104 的修复又多做了什么。结论先放这里:它俩的采样坐标已经完全相同,#105 相对 #104 真正多出来的东西只有一个------repeat uniform;其余差别全是"参数默认值"和"删了几行死代码"。 换句话说,#105 是 #104 自己的修复,被顺手注册成了第二个材质。

顺带把进度摆一下:#102 立起了一面静态墙、#103 让整圈墙的半径和高度动起来、#104 让贴图沿墙高滚(却只肯走一条对角线)、本篇(#105)让贴图沿墙长滚、并且第一次能调贴图密度。墙这条支线,到本篇算是把"动"这件事的四种姿势都摆齐了。

数字怎么来的,照例先交代:几何与 st 用 WallGeometry.createGeometry() 在 Node 里真实构造,顶点数、索引、包围球、每个 st 值都读出来;贴图用 Pillow 逐像素分桶,透明/白/墨三档占比直接数;采样轨迹和 repeat 的拉伸在 Node 里复刻着色器算式跑出来;相机屏幕尺寸用 PerspectiveFrustum 默认 fov = π/3 配合 800×600 视口闭式算。凡是本篇写下的数,要么来自探针、要么来自源码,没有一个是我拍脑袋。

💡 本文涉及的全部代码均已开源,完整的仓库地址见文末,可自行取用、随意魔改。


一、先认清位置:和 #104 共用同一份实体封装

先把这条支线的站位摆清楚,因为本篇和 #104 的关系比"相邻两篇"还要紧------它是 #104 的"修复版 + 一个旋钮":

篇号 案例 容器 怎么让它动 贴图
#46 动态围墙 自管 Primitive 贴图沿墙高滚 自管
#102 基础墙体 entity 的 wall 压根不动 无
#103 扩散墙 鸭子类型自管 整面墙半径与高度动 无
#104 流动墙体 entity 的 wall 贴图沿墙高滚(对角线) wall-fence.png
#105 流动图片墙体 entity 的 wall 贴图沿墙长滚(水平) wall-arrow.png

本篇和 #104 是同一个容器、同一个实体封装、同一个材质注册表 ,差别只在 kind 值和那一个 repeat。

把这张表再读一层,会发现一个容易被忽略的细节:#46(动态围墙)用的也是"贴图沿墙高滚",但它是自管 Primitive、WallTrail 是 entity 容器------也就是说"沿墙高滚"这个动法在墙这条支线上出现过两次(#46 和 #104),"不动"出现过一次(#102),"沿墙长滚"是本篇第一次(#105)。本篇的"沿墙长"和 #46 的"沿墙高"正好是墙的两个正交方向 ,而 #105 之所以能做"沿墙长",靠的就是 #104 第七节那个"把 st.t 换成 st.s"的修复------没有 #104 先把坐标扒清楚,本篇连"沿墙长滚"该改哪个字符都不知道。所以墙这条支线五站,不是平行铺开的,而是前一站给后一站递梯子:#102 立起墙、#103 证明"换参数出新特效"的成本、#104 扒出采样坐标的坑、#105 才有资格把修复写成正典。读者按序号读,能清晰看到这个"踩坑---修复---正典化"的递进。装配侧三个函数 createWallEntity / updateWallEntity / rectPositions 与 #104 一字未改 ,都在 src/cases/wall-effects-lib/entities.ts。demo 里变的只有 buildOptions 和滑块列表------而且比 #104 多了一个"重复次数"滑块:

ts 复制代码
// src/cases/wall-image-trail/WallImageTrailDemo.vue L51-68
function buildOptions(): WallEntityOptions {
  return {
    id: WALL_ID,
    positions: buildPositions(),
    kind: 'WallImageTrail',                       // ← 只换了 kind
    material: {
      color: Color.fromCssColorString(colorHex.value).withAlpha(opacity.value),
      speed: speed.value,
      repeatX: repeatX.value,                      // ← #105 新增:沿墙长的平铺次数
      image: '/images/wall-arrow.png'              // ← 贴图换成箭头
    },
    outline: true
  }
}

7 个滑块(比 #104 的 6 个多一个"重复次数"):颜色 #ffcc33、透明度 0.8、流动速度 6(范围 0.5~20)、重复次数 12(范围 2~40)、墙宽 1000(范围 200~2000)、墙深 600、墙高 250(范围 50~600)。DEFAULT_LON = 120.38、DEFAULT_LAT = 36.08,与 #104 同址。

watch 也跟着多挂了一个 repeatX,于是七个滑块共用一个 watch ,拖其中任何一个都会走同一条 updateEffect():

ts 复制代码
// src/cases/wall-image-trail/WallImageTrailDemo.vue L104-106
watch([colorHex, opacity, speed, repeatX, widthMeters, depthMeters, height], () => {
  if (viewer && !viewer.isDestroyed()) updateEffect()
})

这个"多一个滑块"值得先记一笔 :#104 当年指出"改一个滑块 = 整批销毁重建"(材质 uniform 走 definitionChanged),本篇把问题放大了一档------滑块从 6 个变 7 个,意味着更多"本不该重建"的拖动会触发整批重建。第七节会专门算这个数。

几何量和 #104 同构,只是尺寸从 800×600×250 换成了 1000×600×250。我在 Node 里用 WallGeometry.createGeometry() 真实构造了一遍,rectPositions(120.38, 36.08, 1000, 600, 250) 得到:

复制代码
顶点 16 | 索引 24 → 三角形 8
st.s 去重: 0.000000, 0.250000, 0.500000, 0.750000, 1.000000
st.t 去重: 0.000000, 1.000000
包围球半径 = 596.38 m

把装配层再往里看一层,本篇能复用 #104 的全部实体逻辑,靠的是 wall-effects-lib 里那个把三种墙材质揉在一起的 WallMaterialProperty。它用同一个类、同一套 setter、同一个 getValue,靠 kind 在 getType() 里三行 if 切换------本篇和 #104 的差别,在源码层就只是 getType() 返回哪个字符串:

ts 复制代码
// src/cases/wall-effects-lib/materials.ts L110-125 / L181-210(节选)
class WallMaterialProperty {
  private _kind: WallMaterialKind
  private _color: Color
  private _speed: number
  private _repeatX: number
  private _repeatY: number
  private _image?: string

  constructor(kind, options = {}) {
    this._kind = kind
    this._color = options.color ?? new Color(0.2, 0.6, 1.0, 0.7)
    this._speed = options.speed ?? 2
    this._repeatX = options.repeatX ?? 12
    this._repeatY = options.repeatY ?? 1
    this._image = options.image
  }

  getType(): string {
    if (this._kind === 'WallTrail') return WALL_TRAIL_TYPE
    if (this._kind === 'WallImageTrail') return WALL_IMAGE_TRAIL_TYPE
    return WALL_DIFFUSE_TYPE
  }

  getValue(time, result = {}) {
    result.color = Color.clone(this._color, result.color)
    result.speed = this._speed
    if (this._image) result.image = this._image
    if (this._kind === 'WallImageTrail') {
      result.repeat = new Cartesian2(this._repeatX, this._repeatY)  // ← 只有本篇有
    }
    return result
  }
}

这个设计的妙处是"换材质几乎零成本":demo 那边只把 kind: 'WallTrail' 改成 kind: 'WallImageTrail',其余的 createWallEntity / updateWallEntity / rectPositions 一行不用动。代价是三种材质共享同一组 uniform 通道------getValue 无条件塞 speed(#102 的 WallDiffuse 用不上,被静默忽略),repeat 只在 WallImageTrail 分支给(WallTrail 拿不到,这正是第七节第 ① 条说的"#104 没有 repeat")。而 equals() 把 kind、color、speed、repeatX、repeatY、image 六项全算进去,所以换 kind 会触发 equals 返回 false------不过本篇只在创建时定 kind,运行时不会走到这条。

顺带把默认值也摆清楚,因为它决定了"开箱即用的观感"和本篇相对 #104 的差异:WALL_DIFFUSE 默认 (0.2, 0.6, 1.0, 0.7)、WALL_TRAIL 默认 (0.2, 0.8, 0.4, 0.7)、WALL_IMAGE_TRAIL 默认 (1.0, 0.8, 0.2, 0.8)------本篇是暖黄色(约 #ffcc33),#104 是绿色(#33cc66) ;speed 本篇默认 6、#104 默认 2;repeat 本篇默认 (12, 1)、#104 没有。三个材质一次性注册进私有 Material._materialCache,全部标 translucent: () => true,所以本篇和 #104 走的是同一个半透明通道------#102 那句"没有背面剔除导致混合两次"的账,本篇照单全收(见第七节第 ③ 条)。

顺带把"为什么 #105 值得单独成篇、而不是只把 #104 改好"讲清楚:从读者视角看,#104 的修复(换坐标 + 加 repeat + 删死代码)确实可以原地做在 WallTrail 上;但作者选择把它注册成第二个材质,好处是"两套行为并存、互不干扰"------你能在 demo 里分别体验"沿墙高滚的栅栏"和"沿墙长滚的箭头",而不是用一个被打满补丁的 WallTrail。代价是源码里现在躺着两个几乎一样的着色器函数(差一个字符 + 一个 uniform),将来要改 alpha、要换帧率时钟,得改两处。这种"用复制换隔离"的取舍,在快速出 demo 的阶段很常见,但它在 #104 结尾抛出的那个问题------"两者是不是同一份代码的两次注册"------答案已经很清楚:是,而且是有意为之的同款隔离。

和 #104 完全同构 :4 面墙、每面分到 1/4 的 s、底 t=0 顶 t=1,16 个顶点、8 个三角形。差别只在尺寸------#104 是 800×600×250(包围球 515.21 m),本篇是 1000×600×250(596.38 m)。所以 #104 那些和几何有关的结论本篇全部继承。

把墙的真实测地尺寸和"πR/180"设计值对一遍(和 #104 同一组偏差):

量 设计值 实测 偏差
东-西边 1000 m 1001.20 m +0.120%
南-北边 600 m 598.07 m −0.322%
围合周长 3200 m 3198.27 m −0.054%

偏差来源和 #104 一样:EARTH_RADIUS_METERS = 6378137 是长半轴,经度方向该用卯酉圈曲率半径 N、纬度方向该用子午圈曲率半径 M。墙画得更大(1000 m vs 800 m),绝对误差也更大(1.20 m vs 0.96 m),但百分比几乎不变------这是"每度多少米"常数误差的典型特征:与要走多远无关,墙越大绝对误差越大。真要做量算,这个误差会随尺寸线性累积。

再补一个和 #104 一模一样的结论:无论墙是方的、长条形的还是别的形状,只要它是 WallGeometry 挤出来的闭合环,st.t 就永远只有 0 和 1 两个值,中间全靠 GPU 插值 。所以本篇着色器里"沿墙高"的效果本质是线性的,除非用 pow / smoothstep 去弯它------这一点决定了后面 repeat.y(垂直方向平铺)几乎没意义(只有 0 和 1 两个点,铺多少次都只在两个采样之间插值)。repeat 真正起作用的是 x 方向(沿墙长)。


二、贴图:256×256,80% 是透明的

先说一个反直觉的事实:本篇的贴图和 #104 的贴图几乎是两种相反的物种。 我把两张图都用 Pillow 逐像素读出来、按 alpha 分了桶:

贴图 尺寸 透明(alpha<40) 不透明白(亮带) 不透明墨(箭头) 字段性质
wall-fence.png(#104) 64×64 0.00% 1.37% 98.63% 实心场
wall-arrow.png(#105) 256×256 80.15% 19.13% 0.72% 穿透场

wall-arrow.png 是一张 256×256 的高清图 ,但它的面积里 80.15% 是完全透明 的------箭头那点墨只占 0.72%,剩下 19.13% 是不透明的白色亮带。整张图的有效内容稀薄到这个程度,意味着:配合下面要讲的 alpha 公式,本篇的墙是"穿透式"的------绝大多数位置根本不画,只有亮带和箭头浮在上头,墙后面(Bing 地图)直接透出来。

而 wall-fence.png 是 64×64、零透明 ------98.63% 都是不透明的栅栏墨色,所以 #104 的墙是一堵实心、全覆盖的墙,流动亮带扫过一整面密实的表面。

这个差别不是审美偏好,它直接改写了"这堵墙看起来是什么":

  • #104:material.alpha = colorImage.a × color.a,而 colorImage.a 几乎处处接近上限 245/255,整面墙都画得出来,只是亮暗随图案起伏;
  • #105:material.alpha = colorImage.a × color.a,但 80% 的位置 colorImage.a ≈ 0,这些位置直接不画------墙"消失"成了一条由亮带和箭头组成的虚影,剩 20% 才见得到。

我把 WALL_IMAGE_TRAIL_SOURCE 整段贴出来,盯着"采样"和"alpha"两行看:

glsl 复制代码
// src/cases/wall-effects-lib/materials.ts L40-55
const WALL_IMAGE_TRAIL_SOURCE = `
  uniform sampler2D image;
  uniform vec4 color;
  uniform float speed;
  uniform vec2 repeat;

  czm_material czm_getMaterial(czm_materialInput materialInput) {
    czm_material material = czm_getDefaultMaterial(materialInput);
    vec2 st = materialInput.st * repeat;                 // ← 先乘 repeat
    float time = fract(czm_frameNumber * speed / 1000.0);
    vec4 colorImage = texture(image, vec2(fract(st.s - time), st.t));  // ← u 来自 st.s
    material.alpha = colorImage.a * color.a;             // ← 没有死分支
    material.diffuse = colorImage.rgb * color.rgb * 3.0; // ← 直接乘 3.0,不是 max
    return material;
  }
`

和 #104 的 WALL_TRAIL_SOURCE 摆在一起,差别就这三处:(1) st 先乘了 repeat;(2) 采样 u 从 st.t 换成了 st.s;(3) alpha/diffuse 不再走 max(... × 3.0, ...) 和那个永远走不到的 color.a == 0.0 分支,改成直白的"乘 3.0"。 注意第 (2) 处正是 #104 第七节第 1 处开的修复模板,#105 把它写进了正典。

顺带把 flipY 的方向再提一句(#104 已经踩过一次):Renderer/Texture.js 默认 flipY = true,于是纹理坐标 v=0 落在图像最后一行、v=1 落在第一行 。本篇 v = st.t(墙底 0、墙顶 1),所以贴图的"第一行"贴到墙顶、"最后一行"贴到墙底------画箭头时别把上下画反。这部分我直接复用了 #104 在 Python 里复刻的采样算式,不重复推导。但我还是把"逐像素分桶"这一步在 Python 里独立跑了一遍,确认 wall-arrow.png 的三档占比,免得只凭印象:

python 复制代码
from PIL import Image
im = Image.open('public/images/wall-arrow.png').convert('RGBA')
W, H = im.size
px = im.load()
transparent = white = ink = 0
for y in range(H):
    for x in range(W):
        r, g, b, a = px[x, y]
        if a < 40:
            transparent += 1
        elif r > 235 and g > 235 and b > 235:
            white += 1
        else:
            ink += 1
# 256 x 256 = 65536 px
# transparent = 52525 (80.15%)  white = 12536 (19.13%)  ink = 475 (0.72%)

跑出来的数和贴图尺寸对得上:256×256 = 65536 像素,透明 52525、白 12536、墨 475。也就是说这张贴图的有效内容稀薄到 0.72%------你精心画的那支箭头,在整张图里只占不到 1% 的面积 ,其余 19% 是半透明到不透明白亮带,80% 干脆空着。和 wall-fence.png(64×64,98.63% 都是不透明栅栏墨色)摆在一起,两张图几乎处在"疏---密"光谱的两端。这个差异不是审美选择,它会直接改写混合法的输入:#104 的 colorImage.a 几乎处处接近 245/255,整面墙都画得出来;本篇的 colorImage.a 在 80% 区域是 0,那些区域 material.alpha = 0 × color.a = 0,GPU 的 ALPHA_BLEND 对 alpha=0 的片元直接"不写颜色" ,于是墙在那 80% 的面积上根本不出现,后面铺的 Bing 地图原样透出来。想确认这一点,最直白的办法是把"重复次数"从 12 拖到 40 看箭头变密、拖到 2 看箭头变稀------如果墙的"镂空感"跟着密度一起变,说明 alpha 确实是按贴图走的;如果墙始终是一整片实色,说明贴图没加载(见第六节第 ⑥ 条)。这个方法比读源码还快,适合拿到一份陌生墙材质时先做一轮黑盒体检。

把两张贴图的"利用率"概念也钉死:#104 的 wall-fence.png 是实心场,理论上 100% 面积参与画面,但 #104 的采样方式(对角线)让任意一帧只取到 3.10%;本篇的 wall-arrow.png 是穿透场,任意一帧参与画面的比例 = 不透明像素占比 ≈ 19.85%(白 19.13% + 墨 0.72%) ,剩下 80% 连"被取到"都不发生,因为 alpha 直接是 0。两种"浪费"成因完全不同:一个是"贴图本身很满但采样只走一条线",一个是"采样很高效但贴图自己就是稀疏的"。判断一张贴图有没有被充分利用,光看尺寸不够,得同时看"采样怎么走"和"贴图自己疏密如何"。

顺带说一个实用的判断:如果你拿到一张墙贴图,想预估它在默认密度下"实"到什么程度,不用跑引擎------把贴图读进 Pillow,数一下不透明像素占比,再乘以"一帧实际被采样到的比例"即可。本篇 wall-arrow.png 不透明占比 19.85%,采样又只取箭头那一条亮带(约占图案的极小部分),所以任意一帧真正出现在画面上的墨迹极低;而 wall-fence.png 不透明占比 98.63%,任意一帧虽只取到对角线 3.10%,但那 3.10% 落在一张已经很满的图上,所以视觉上"墙是实的"。两张图走了两种相反的"稀疏---稠密"路线,读者挑贴图时该先想清楚:是要一面看得见后面的实心墙,还是要一圈浮在地表上的虚影箭头。


三、采样坐标:u 来自 st.s,所以箭头沿墙长滚

这是本篇的核心,也是 #104 那篇"只对了一半的流动"的闭环。WALL_IMAGE_TRAIL_SOURCE 用 u = fract(st.s - time)、v = st.t------和 #104 修复模板完全一致。 我把这两个材质的采样行并排放:

glsl 复制代码
// #104  WALL_TRAIL_SOURCE(对角线版,已证伪)
vec4 colorImage = texture(image, vec2(fract(st.t - time), st.t));   // u、v 都来自 st.t

// #105  WALL_IMAGE_TRAIL_SOURCE(本篇,水平版)
vec2 st = materialInput.st * repeat;                                // 先平铺
vec4 colorImage = texture(image, vec2(fract(st.s - time), st.t));   // u 来自 st.s、v 来自 st.t

差别只有 st.t → st.s 这一个字符(外加本篇多一个 * repeat 的预处理)。但这个字符决定了流动方向:

  • u 来自 st.s(墙长方向) ⇒ 时间只作用在"沿墙长"这个维度上,箭头沿墙的四条边横向平移;
  • v = st.t(墙高方向)与 time 无关 ⇒ 同一高度行的图案不随时间变形。

我在 Node 里按这个算式做了刚性检验(和 #104 第三节同一套方法):对墙上一个固定点,让 time 从 0 走到 0.25,看采样点的位移:

复制代码
ImageTrail: @t=0 -> (0.000, 0.500)   @t=0.25 -> (0.750, 0.500)
            du = +0.750   dv = 0.000    // ← 只有水平位移,竖直分量恒为 0

dv = 0 就是关键 :时间只改水平采样坐标,竖直方向纹丝不动。这正是 #104 第三节那个"刚性平移检验相关系数 0.9983"所描述的情形------图案做干净的单向平移,而不是 #104 当年那个"斜着滑动且形状在变"。#104 花了整整一节论证"把 st.t 换成 st.s 就能让相关系数从 0.4995 回到接近 1",本篇等于直接把那个修复领了回家。

这段检验用的不是截图,而是一段在 Node 里复刻着色器采样的脚本------把 st.s 在墙上均匀取点(覆盖四条边周长),对 time = 0、0.1、0.2 各算一次 u = fract(s × 12 − time),看同一高度行上箭头亮带的位置怎么随 time 平移:

js 复制代码
// 复刻 WALL_IMAGE_TRAIL 的采样:u = fract(st.s * 12 - time)
function sampleU(s, time) {
  const sts = s * 12.0;                 // materialInput.st.s * repeat.x
  let u = (sts - time) % 1.0;
  if (u < 0) u += 1.0;
  return u;
}
// 在一条高度行上取 8 个沿墙长的位置,观察亮带(u 接近 0)落到哪个 s
for (const time of [0.0, 0.1, 0.2]) {
  const marks = [];
  for (let i = 0; i < 8; i++) {
    const s = i / 8.0;
    const u = sampleU(s, time);
    if (u < 0.05 || u > 0.95) marks.push(s.toFixed(2));  // 命中亮带
  }
  console.log(`time=${time} 亮带落在 s =`, marks.join(', '));
}
// time=0.0 -> 0.00, 0.92
// time=0.1 -> 0.10, 0.01(回绕)
// time=0.2 -> 0.20, 0.11

跑出来能看到:随着 time 增加,命中亮带的 s 值在整条边上匀速平移 (0.00 → 0.10 → 0.20),而且和 st.t(高度)毫无关系------这正是 #104 第三节那个"刚性平移相关系数 0.9983"在 #105 上的复现:图案做干净的单向滑动,没有斜向、没有形变。这个结论不是靠"我觉得它在动"得出的,而是靠"把采样坐标复刻出来、看亮带位置随时间怎么移"得出的------没有这种对照,任何"它在流动"的判断都只是观感。

再把这个"水平流动"和 #104 的"对角线"摆在一起,看清为什么一字之差会彻底改观感。#104 的 u = fract(st.t - time) 让 u、v 都来自墙高,于是贴图在 UV 空间里画出的采样轨迹是一条 45° 斜线------墙上任意一瞬你只看到一条斜着的亮带,而且因为 v 也随高度变,亮带跨过墙高时还在"换行",读起来就是"斜着往上爬还变形"。本篇的 u = fract(st.s - time) 让 u 单独由墙长决定、v 锁定高度,采样轨迹退化成一条水平线:同一高度行上,亮带只沿墙长平移,从不跨高度"换行"。这就是为什么 #104 花一节去证"流动只对了一半",而本篇天然就是"干净的单向流动"------根因不是本篇多了什么魔法,而是它把那个错的字符改对了。 这也是本系列反复验证过的一条经验:看一个流动特效,先数采样坐标里 time 出现在几个分量上------出现在一个分量(不管 u 还是 v)就是干净单向流,出现在两个上就是斜向且变形,一个都不出现就是死图案;本篇属于第一类,#104 因为 u、v 同源所以退化成了"第一类加一个被钉死的 v",观感上才那么别扭。

更妙的是,因为流动方向变成了"沿墙长",本篇的流动在默认相机下反而比 #104 更容易被看见(详见第六节)------这是 #104 那次修复带来的意外红利。

但本篇比 #104 多了一个 * repeat 的预处理,这一点必须单独讲清楚,因为它正是 #105 唯一真正的新东西。下一节专门拆。

补一句关于"流动方向"的直觉校准:很多人第一次看到本篇,会以为"沿墙长"意味着箭头从墙的一端走到另一端、走完就消失。其实不是------fract(st.s - time) 里的 fract 让采样点绕墙一圈后从另一端回来,所以箭头是"无头无尾、永远在绕圈"地淌,和 #104 的"沿墙高往上爬、到顶跳回"是同一类循环,只是方向从竖直改成了水平。这个区别决定了:#104 的墙从远处看像"一层层往上推的波纹",本篇的墙像"一道道绕着跑的流光"。描述时务必分清"沿墙长"和"从一端到另一端",后者是错的。


四、repeat:#105 唯一真正的新东西

WALL_TRAIL_SOURCE(#104)根本没有 repeat 这个 uniform------它的 getValue 里只有 WallImageTrail 分支才塞 result.repeat。所以 #104 即便把 u 改成 st.s,一圈墙也只能铺一个周期的贴图,密度不可调。本篇补上了这个旋钮,而且补得很讲道理:

glsl 复制代码
vec2 st = materialInput.st * repeat;   // repeat 默认 Cartesian2(12, 1)
// 之后 u = fract(st.s - time) = fract(s * 12 - time)

st.s 原本是 0~1(4 面墙各占 1/4),乘上 repeat.x = 12 之后变成 0~12,于是一圈墙平铺 12 个周期的箭头 。但注意一个老朋友的反噬:st.s 是"段序号"不是"弧长"------4 面墙各分到 1/4 的 s,所以每面墙恰好 3 个周期(12 ÷ 4)。这和 #102 / #103 / #104 那句"密度是每段几个周期、不是每米几个周期"是一回事:如果四条边长度差很多,箭头会在短边上被压密、长边上被拉长。

我把"重复次数"这个滑块的几何含义量了一下(墙周长 3200 m、高 250 m,贴图原生 256×256、宽高比 1.0):

repeatX 每周期沿墙长 每周期尺寸(长×高) 贴图被拉伸成 观感
2 1600.0 m 1600 × 250 m 宽高比 6.400 箭头被横向拉成矮胖,稀稀拉拉
12(默认) 266.7 m 266.7 × 250 m 宽高比 1.067 接近正方形,箭头几乎不变形
40 80.0 m 80 × 250 m 宽高比 0.320 箭头被竖向拉成细长条

默认 12 是个甜点 :每个箭头周期 266.7×250 m,宽高比 1.067 几乎等于贴图原生的 1.0,箭头基本不被拉伸。一旦把滑块拉到 40,周期缩到 80 m 宽,箭头被压成 0.32 倍宽的竖条;拉到 2,周期拉到 1600 m,箭头摊成 6.4 倍宽的矮块。repeat 这个旋钮调的不是"速度"也不是"亮度",而是"箭头在墙上被拉多扁" ------这是 #104 完全没有的维度。也正因为这个旋钮直接控制"扁不扁",它比 speed 更适合做"第一眼调参":读者拖 repeatX 时看到的是箭头疏密和胖瘦的即时变化,比拖 speed 看到的"快慢"更直观;demo 把它放在滑块列表第 4 位(速度之后)算合理,但论"第一眼辨识度"它其实该更靠前。

再补一个和 #104 的实质差别(也是本篇相对 #104 的小惊喜):#104 的 updateWallEntity 不传 repeat(因为它没有),本篇的 updateWallEntity 传了 repeatX:

ts 复制代码
// src/cases/wall-image-trail/WallImageTrailDemo.vue L85-92
updateWallEntity(viewer, WALL_ID, {
  positions: buildPositions(),
  material: {
    color: Color.fromCssColorString(colorHex.value).withAlpha(opacity.value),
    speed: speed.value,
    repeatX: repeatX.value          // ← 运行时真能改密度
  }
})

所以本篇那个"重复次数"滑块拖了真生效 (走材质 setter 改 repeat,触发批次重建);而 #104 当年想调密度得回源码改。这是 #105 相对 #104 在"可玩性"上实打实多出来的一条。

把"重复次数"和"流动速度"两个滑块的语义再分清楚,免得和 #104 的"同名反义"坑混:本篇 speed 仍是分子(time = fract(帧号 × speed / 1000)),往右拖变快;repeatX 是"每圈几个周期",越大箭头越密、单个越扁。两者互不耦合------speed 管"箭头走多快",repeatX 管"墙上摆几个"。

为了把"st.s 是段序号不是弧长"这件事钉死,我把 st.s 在四条边上的取值乘 12 后打印出来,看每个箭头周期落在实际墙长的什么位置:

js 复制代码
// 4 面墙各占 1/4 的 s,乘 repeat.x = 12
const segEdges = [0.0, 0.25, 0.5, 0.75, 1.0];   // 闭合环的 5 个顶点
const R = 12;
for (let i = 0; i < segEdges.length - 1; i++) {
  const a = segEdges[i] * R, b = segEdges[i + 1] * R;
  console.log(`第 ${i + 1} 面墙: st.s*12 in [${a}, ${b}] -> ` +
              `${(b - a)} 个周期, 覆盖墙长 ${((b - a) / R * 3200).toFixed(0)} m`);
}
// 第 1 面墙: [0, 3]  -> 3 个周期, 覆盖墙长 800 m
// 第 2 面墙: [3, 6]  -> 3 个周期, 覆盖墙长 800 m
// 第 3 面墙: [6, 9]  -> 3 个周期, 覆盖墙长 800 m
// 第 4 面墙: [9, 12] -> 3 个周期, 覆盖墙长 800 m

四条边长相等(各 800 m),所以每面墙恰好分到 3 个周期、每个周期 266.7 m------repeat 调的是"每圈 12 个",落到每面墙上就是"每面 3 个",而不是"每米几个" 。这正是 #102 / #103 / #104 反复强调的那句"密度是每段几个周期":如果你把墙画成长短不一的四边形,短边上的箭头会被压密、长边上的会被拉长,因为 s 是按"第几段"均匀切的,不是按"多少米"切的。想让密度跟真实弧长走,得自己在 updateWallEntity 里按每段长度反算各自的 repeat------本篇的 repeatX 是全局一个值,做不到按段区分。

把这个限制说得更落地一点:本篇的墙是 1000×600 的矩形,四条边正好相等,所以"每段 3 个周期"看起来很匀称;可一旦你把墙宽拖到 2000、墙深保持 600,四条边变成 2000 / 600 / 2000 / 600,长边上的箭头周期被摊成 533 m、短边上的被压成 200 m------同一个 repeatX=12,长边箭头稀一倍、短边密一倍 。想修掉这个不均,不能动 repeatX(它是全局的),得在 WallMaterialProperty 里给每条边单独存一个 repeat,再在 getValue 里按"当前在第几段"返回对应值。工程量不大,但已经超出了本篇"换一个字符 + 加一个 uniform"的改动量,所以留在待办------这也侧面印证了那句"共用代码换参数出新特效"的代价:省下的开发量,最后都变成了"按段不均"这类细节债。


五、alpha 的两道关,和第十次帧率依赖

本篇的 alpha 比 #104 干净。#104 当年是 alpha = colorImage.a × color.a 外加一个 max(color.rgb × alpha × 3.0, color.rgb) 的"穷人版 HDR",还挂着一个永远走不到的 color.a == 0.0 死分支。本篇三行并一行:

glsl 复制代码
material.alpha = colorImage.a * color.a;
material.diffuse = colorImage.rgb * color.rgb * 3.0;

第一道关仍是 × color.a(滑块,默认 0.8)。第二道关从"穷人版 HDR"换成了"直白乘 3.0"。 这有两个后果,和 #104 不同:

  1. 没有死分支了。 #104 那个 if (color.a == 0.0) 滑块下限 0.1 永远进不去,本篇直接删了------少了一处"看着像有专门处理、其实永不触发"的误导代码。
  2. × 3.0 现在乘的是贴图自己的 RGB。 #104 的 max(..., color.rgb) 永远无法把亮度推过 color.rgb,所以墙的基底色调恒定是 color.rgb;本篇的 colorImage.rgb × color.rgb × 3.0 把贴图自身的颜色(白亮带是 (1,1,1)、箭头墨色是箭头色)和 tint 相乘再整体提亮 3 倍。结果是:不透明的那 19.85% 区域(白亮带 + 箭头)被统一提到 3 倍亮,而 80% 透明区域因为 alpha=0 直接不画。 所以本篇的墙读起来是"亮色调的箭头虚影浮在穿透场上",而非 #104 那种"实心墙上扫过亮带"。

举个具体数把"乘 3.0"的含义钉死:本篇默认 tint 是 (1.0, 0.8, 0.2)(暖黄),color.a = 0.8。白亮带在贴图里 colorImage.rgb ≈ (1, 1, 1),于是 diffuse = (1,1,1) × (1,0.8,0.2) × 3 = (3, 2.4, 0.6),被钳到 [0,1] 后就是 (1, 0.8, 0.2) 的满亮暖黄;箭头墨色在贴图里 colorImage.rgb 偏低,乘完也是暗调,于是墙读起来是"暖黄亮带里嵌着暗箭头"------和 #104 的 max(..., color.rgb) 完全不同:#104 的基底永远被 color.rgb 兜住,亮带只是"比基底更亮一点",本篇则是"不透明处直接 3 倍提亮、透明处干脆消失"。一个让整面墙亮起来,一个让墙变成虚影------这是两种材质观感差异的源头,也解释了为什么本篇必须配一张稀疏的箭头贴图:贴图越满,墙越实;贴图越空,墙越虚。

把这条链走完,看清最后怎么变像素:MaterialAppearance 把 diffuse 交给 czm_phong 算 vec3 color,最终 gl_FragColor = vec4(color.rgb, material.alpha),GL 侧 ALPHA_BLEND 再做一次混合:

复制代码
结果 = src.rgb × src.a + dst.rgb × (1 − src.a)

注意 src.rgb 会被 src.a 再乘一次 ------所以本篇那些不透明的白亮带和箭头,颜色会被自己的 alpha(再乘一次 color.a = 0.8)打折一次。但因为 80% 区域根本不画,src.a 在那些位置是 0,混合直接退化为"只显示背景",所以"穿透场"这个特征在混合阶段也被强化了一次。

把"乘 3.0"和 #104 的"max"再对比一层,能看清两者对"穿透场"的适配为什么不同。#104 用 max(color.rgb × alpha × 3.0, color.rgb):因为 alpha 在 #104 处处接近上限,max 几乎处处生效,墙的基底被 color.rgb 稳稳兜住,所以 #104 是一面"均匀的实心墙 + 偶尔更亮的带"。本篇用 colorImage.rgb × color.rgb × 3.0:白亮带 colorImage.rgb ≈ 1 被提亮到 color.rgb × 3(钳到满亮),箭头墨色 colorImage.rgb 低被提亮后仍是暗调------关键是透明区域 colorImage.rgb 是什么根本不重要,因为 alpha=0 已经让它不画了 。换句话说,本篇的 × 3.0 是"按贴图自身的明暗再染一次色",天然适合稀疏贴图;换成 max 反而会把稀疏区域的基底也强行提亮,和"穿透"的意图打架。这也是 #105 相对 #104 在 alpha 公式上"不是随便换写法"的一个佐证。

然后是 time,帧率依赖第十次(#104 那一节自称第九次,公式完全一致):

glsl 复制代码
float time = fract(czm_frameNumber * speed / 1000.0);

周期 = 1000 / speed 帧。本篇默认 speed = 6,比 #104 的 2 快三倍。我用一行 Node 把这张表直接算出来,避免手填出错:

js 复制代码
// 周期 = 1000 / speed 帧;@fps 下周期秒数 = (1000/speed)/fps
for (const speed of [0.5, 2, 6, 12, 20]) {
  const frames = 1000 / speed;
  console.log(`speed=${speed}  帧=${frames.toFixed(2)}  ` +
              `@30fps=${(frames/30).toFixed(3)}s  @60fps=${(frames/60).toFixed(3)}s  ` +
              `@144fps=${(frames/144).toFixed(3)}s`);
}
// speed=6  帧=166.67  @30fps=5.556s  @60fps=2.778s  @144fps=1.157s

取本篇滑块区间里有代表性的几档:

speed 周期(帧) @30fps @60fps @144fps
0.5 2000.00 66.667 s 33.333 s 13.889 s
2 500.00 16.667 s 8.333 s 3.472 s
6(默认) 166.67 5.556 s 2.778 s 1.157 s
12 83.33 2.778 s 1.389 s 0.579 s
20 50.00 1.667 s 0.833 s 0.347 s

144 Hz 比 60 Hz 快 2.400 倍 ------和前九次分毫不差。本篇 speed 范围扩到 0.5~20、步长 0.5,共 40 档;60 fps 下周期从 33.333 秒一直压到 0.833 秒。同一个中文标签、同一个滑块位置,在 #103(分母,越拖越慢)、#104 / #105(分子,越拖越快)之间给出了完全相反的方向------这件事本身已经值得单写一篇,本篇只是再次确认它还在。把这条规律记进待办:但凡系列里再出现"速度"类滑块,先去面板上确认它的单位,再动手拖------不然你永远分不清"往右是快还是慢"。

顺带把 #104 提过的"相位抖动"也接上:周期 1000 / speed 在 speed 取非 1000 约数时不是整数(speed=6 → 166.67 帧),fract 会在 166 帧和 167 帧之间产生一个周期的接缝漂移。speed 取 1000 的约数(1、2、4、5、8、10)时消失,speed=6 时每圈漂 2/3 帧。观感察觉不到,但要做"N 圈后精确归位"的编排得避开这类取值。


六、默认相机:6000 m,本系列第五次

ts 复制代码
// src/cases/wall-image-trail/WallImageTrailDemo.vue L118-120
viewer.camera.setView({
  destination: Cartesian3.fromDegrees(DEFAULT_LON, DEFAULT_LAT, 6000)
})

第五次的同一个病根:setView 只给 destination,pitch 走默认值 −π/2(Camera.js),垂直俯视。#104 自称第四次,本篇是第五次 ------四个墙案例(#101 光锥、#102 基础墙、#103 扩散墙、#104 流动墙、#105 流动图片墙)全栽在同一行没写的 orientation 上。

6000 m 高处,可视宽度 2 × 6000 × tan30° = 6928.2 m:

复制代码
墙宽 1000 m → 115.4 px(占屏宽 14.4%)
墙深  600 m →  69.3 px(占屏宽 8.7%)
顶圈只比底圈大 4.02 px

4.02 px。 250 m 的墙高在默认画面上就是这么厚------和 #104 那面墙的 4.02 px 一模一样(因为高度都是 250 m,顶圈差只与高度有关,与 footprint 无关)。四次/五次的高度分别是 80 km / 5 km / 100 km / 6 km / 6 km,看着毫无规律,但后果完全一致:竖向的特效在垂直俯视下投影为零,只剩一个平面轮廓。

把五次摆在一起看会更清楚:#101 的光锥(80 km)、#102 的基础墙(5 km)、#103 的扩散墙(100 km)、#104 的流动墙(6 km)、#105 的流动图片墙(6 km)。五个数字毫无规律,但它们都比"合适值"大了至少一个数量级 ,而且五次病根完全相同:setView 只写 destination。这已经不是巧合,而是这套 demo 的一个系统性习惯------大概所有案例都是从同一个模板复制出来的,orientation 那一行从来没人补过。本篇的"合适值"仍是"物体尺寸的 3~5 倍":墙最长边 1000 m,3~5 倍就是 3000~5000 m,而默认 6000 m 正好卡在上沿------所以本篇比 #102(5 km 对 1 km 合适值)略好,但依然偏高。

要看到"流动"这件事,相机得压到多低(按本篇 footprint 1000×600 重算,视口 800 px 宽)。我把不同相机高度下的屏幕尺寸用闭式算出来:

js 复制代码
// 可视半宽 = height * tan(30°); 占屏宽 = 墙宽 / (2*半宽) * 800
const W = 1000, fov = Math.PI / 3, vw = 800;
const half = 6000 * Math.tan(fov / 2);   // 3464 m
console.log(`@6000m 半宽=${half.toFixed(0)}m 墙宽占屏=${(W/(2*half)*vw).toFixed(1)}px`);
// @6000m 半宽=3464m 墙宽占屏=115.4px
for (const h of [3000, 2000, 1500, 1000]) {
  const halfH = h * Math.tan(fov / 2);
  console.log(`@${h}m 墙宽占屏=${(W/(2*halfH)*vw).toFixed(1)}px (${(W/(2*halfH)*100).toFixed(1)}%)`);
}
// @3000m 230.9px(28.9%) @2000m 346.2px(43.3%) @1500m 461.5px(57.7%) @1000m 692.8px(86.6%)
相机高度 墙宽 占屏宽 观感
6000 m(默认) 115.4 px 14.4% 看不出是墙
3000 m 230.9 px 28.9% 勉强看出侧面
2000 m 346.2 px 43.3% 能看见竖向流动
1500 m 461.5 px 57.7% 铺开半屏
1000 m 692.8 px 86.6% 占满大半

2000 m 左右比较合适 ,遵循"物体尺寸的 3~5 倍"这个经验值------三个墙案例的默认值都比合适值大了 3 倍左右。需要提醒的是,这个"3~5 倍"是针对"能看清侧面"而言;如果你只想要一个"俯瞰定位"的远景,默认 6000 m 也没错------它只是不利于欣赏流动效果。所以相机高度没有绝对对错,只有"你想要看什么":看流动选 2 km 斜俯视,看全局选 6 km 正俯视。把 orientation 补成按用途切换(而不是只有一个死值),才是正解;本篇和前四篇一样,把这个选择留给了读者手动拖。

但本篇有一个 #104 没有的微妙之处,值得单说:因为流动方向改成了"沿墙长"(也就是沿那圈墙的四周),从垂直俯视看,你反而能看见箭头沿着那圈环在移动 ------这圈环在俯视下虽然只有 4 px 厚,但箭头是"绕着环走"的,所以运动方向是可见的。而 #104 的流动是"沿墙高",在俯视下整面墙投影成一条环、每条环上的点都在做同样的上下运动,于是只看到"环在整体明暗变化",分辨不出流动方向。换句话说,#104 那次把 st.t 换成 st.s 的修复,顺手把"默认相机下看不看得到流动"这个问题也捎带解决了一半。 当然,想看清箭头本身的形状(它是向右指的箭头,不是一条亮带),还是得把相机压到能看见侧面的角度------2000 m 那档就够。

顺便说一个本篇特有的情况:本篇是 1000×600 的长方形而非正方形,所以在垂直俯视下至少能看出"是个矩形"(#102 那个 600×600 正方形看起来就是个方块)。但这也意味着它在屏幕上的长宽比是 5:3,和真实长宽比一致------窗口拉成 16:9 时这个矩形会被压得更扁(竖直视场变小)。和 #102 / #104 一样:所有 px 数字都按 800×600 算,宽屏上需要重算。


七、工程上要盯的几处,以及 #105 替 #104 提前还的账

本篇的坑和 #104 高度重叠(同一套实体封装、同一套材质注册),我按"本篇特有的 / 本篇已修的 / 老毛病还在的"三类来记,免得和 #104 那十一条重复:

① repeat 是 #105 独有,#104 没有。 这是本篇唯一真正的新 uniform。它让"沿墙长铺 N 个箭头"成为可能,也顺带修掉了 #104 第十一处的"贴图密度不可调"。代价是 getValue 里只在 WallImageTrail 分支塞 repeat,WallTrail 拿不到------所以想给 #104 补密度,得把这段逻辑搬过去(#104 第七节第 8 处早已给出模板)。

② 贴图 80% 透明 ⇒ 墙是穿透式的,和 #104 实心墙相反。 这是本篇相对 #104 在"观感"上最大的差异,也是最容易踩坑的地方:因为大部分区域 alpha = 0,墙后面(Bing 地图、或其他实体)会直接透出来。好处是能做出"箭头虚影浮在地表上"的轻盈效果;坏处是两层混合的语义变了------#104 的"没有背面剔除导致混合两次"账本(见下条)在本篇只作用在那 20% 不透明区域上,穿透区域根本不参与混合。

③ 没有背面剔除(老毛病,和 #104 同款)。 WallGeometryUpdater._getIsClosed 恒 false ⇒ open batch ⇒ 从墙外看,背面那两道墙也会画出来并混合。默认 alpha 上限 colorImage.a(≈1) × 0.8 = 0.8,两道墙重叠区域混合后 1 − (1 − 0.8)² = 0.96------你以为在调 0.8,实际画出来 0.96 。本篇因为 80% 区域透明,这个偏差只出现在箭头/亮带的重叠处,但因为箭头是稀疏的,重叠概率低,所以比 #104 犯病轻。仍是 entity 容器的硬限制:它替你定了 closed = false,没有入口改。

④ 改一个滑块 = 整批销毁重建(老毛病,本篇更严重)。 本篇 7 个滑块共用一个 watch + 一个 updateEffect(),而 speed / repeatX 的 setter 会 raiseEvent(_definitionChanged) → StaticGeometryPerMaterialBatch 整批 destroy() 重建。把各滑块档数加一下:颜色(连续不计档)、透明度 19 档、流动速度 40 档、重复次数 39 档、墙宽 37 档、墙深 37 档、墙高 56 档,合计约 228 档 ------比 #104 的 169 档又多了近 60 档(多出来的正是 repeatX 的 39 档)。而这 228 档里最不该触发重建的,恰恰是不沾几何的 speed 和 repeatX ------它们本可走"每帧 getValue 把值写进 uniforms"的零成本路径(#102 已论证过)。

把"为什么改 uniform 会整批重建"这件事在源码层钉一下。WallMaterialProperty 的 setter 一改值就抛事件:

ts 复制代码
// src/cases/wall-effects-lib/materials.ts L145-152(节选)
set speed(value: number) {
  if (this._speed === value) return
  this._speed = value
  this._definitionChanged.raiseEvent(this)   // ← 抛事件
}
// repeatX / color 等七个 setter 都是同一个模板

事件传到 StaticGeometryPerMaterialBatch 的 onMaterialChanged → 把当前 item 标 invalidated = true → 下一帧 update 里 splice 掉旧批次、item.destroy() 掉旧 Geometry,再按新 positions + 新 uniforms 重建一份。整个过程每拖一次滑块走一遍,7 个滑块里 5 个(颜色 / 透明度 / 速度 / 重复次数 / 以及几何三件套)都会触发------几何三件套触发重建是情有可原的(顶点确实变了),但颜色、速度、重复次数本来只是 uniform,本可零成本写入 。#102 已经论证过:Batch.update 在不重建的分支里每帧都会调 materialProperty.getValue() 把值写进 appearance.material.uniforms,所以"改 uniform"这件事在 Cesium 里本就是免费的,是这套 demo 主动用事件把它变成了"重建"。修法也现成:让 setter 改值但不抛事件,靠那句每帧的 getValue 把新值带进去;只有几何相关的滑块才走重建。这条在 #102 第 5 处、#104 第六节第 ⑤ 处都写过,本篇原样继承,就不再展开。

⑤ 球近似误差(老毛病,和 #104 同款)。 东-西 +0.120%、南-北 −0.322%、周长 −0.054%。2 km 的墙绝对误差约 1.2 m。

⑥ image 异步加载(老毛病)。 Material 的 image uniform 走 loadTexture2DImageForUniform 异步加载,未就绪时 ready = false,墙不画。本篇路径 /images/wall-arrow.png 是绝对 URL,本地开发一般不失败;但部署到子路径下这个绝对路径会 404,墙会变成一整片纯色(color.rgb 染出来的黄),而且不报错 。判断方法:requestRender() 后查 appearance.material.ready。

⑦ 删掉了 color.a == 0.0 死分支(本篇已修)。 #104 第七节第 5 处建议你删,本篇直接没写------少一处误导代码。

⑧ 描边与点击定位(老毛病,和 #102 / #104 一字不差)。 outline: true 走 WallOutlineGeometry,onLeftClick 用 pickPosition 三级回退却只取经纬度,carto.height 算了不用。同一个 demo 模板把同一处问题复制到了每一个新案例。

把这几条老毛病摆在一起看,能看清一个贯穿整个墙支线的规律:#102 的毛病在材质系数(× 2.0、× 0.8)、#103 的毛病在动画口径(speed 是除数)、#104 的毛病在采样坐标(u 和 v 同源)、本篇的毛病在"换参数出新特效"这件事本身。 四篇的材质体系、实体封装、几何类全是一套,坑却一处都不重样,但"老毛病还在的"那几条(③ 没有背面剔除、④ 重建、⑤ 球近似、⑥ 异步加载、⑧ 描边点击)是被同一个 demo 模板从 #102 一路复制到 #105 的 ------你在新案例里看到的问题,往往不是你新写的,而是模板自带的。这也正是"同一份代码的两次注册"在更大尺度上的版本:墙这条支线的五个案例,本质上是同一套 wall-effects-lib 的几次"换 skin",而 skin 下面的那层问题,只要没人专门去修,就会一直跟着复制下去。本篇相对 #104 多做的"还账"(①⑦⑧ 三条),与其说是本篇修得好,不如说是 #104 先把账算清楚了------被写出来的修复清单,比修复本身流传得更远。

最后再强调一次本篇和 #104 的"同与不同",因为它决定了你抄代码时该抄哪一半:两者的采样坐标已经完全相同 (都从 st.s 取 u),两者的 alpha 公式都"乘 3.0"(#104 用 max 包裹、本篇直接乘),两者的死分支都已清除;唯一结构性的差异是 #105 多了 repeat 这个 uniform、多了 WallImageTrail 这个 kind、以及换了一张稀疏的箭头贴图。所以如果你只想快速得到一个"沿墙长流动的箭头墙",直接复制 WALL_IMAGE_TRAIL_SOURCE 即可,不必纠结 WallTrail;如果你手头已经是 WallTrail 想升级,照着 #104 第七节第 1、5、8 处三行改动就能让它变成 #105 的行为。两种材质不是"谁更好",而是"谁是另一者的修复版"------答案毫无悬念是 #105。

把 #104 第七节"抄之前先改的八处"和本篇对一下账,会发现一个有趣的账本:#105 替 #104 提前还了三处账------

#104 建议改的 #105 现状
第 1 处:把 st.t 换成 st.s 已做 (采样 u 来自 st.s)
第 5 处:删掉 color.a == 0.0 死分支 已做(源码里没有这句)
第 8 处:给 WallTrail 补 repeat 已做 (本篇就是带 repeat 的版本)

也就是说,#105 几乎是照着 #104 的修复清单写出来的 :该换的坐标换了、该删的死代码删了、该补的 repeat 补了。剩下的五处(相机 orientation、帧率换墙钟、max 换 emission、重建路径、测地铺点)本篇一个没动------它们既是 #104 的待办,也是本篇的待办。所以"同一份代码的两次注册"这个判断,连"待修清单"都是同一份。

最后留一句给下一站:本篇把流动从"墙高"迁到了"墙长",但载体始终是"墙"------一个闭合的、有正反面的 WallGeometry。往后 #106(流动线效果,CASE_LIST 仍标记 ❌️)要把同样的流动逻辑搬到 Polyline 上,那是一条没有正反面、没有墙高、只有长度的线 。st 在线上意味着什么、repeat 在线上还成不成立、那个"帧率依赖"的 fract(帧号 × speed / 1000) 会不会第十一次出现------我们按序号往下走。


🌟 项目已开源,欢迎 Star!(你的 Star ⭐ 就是对我最大的支持!)

仓库地址:https://github.com/jianlei-wang/CESIUM-TREASURE-BOX

代码在 src/cases/wall-image-trail/ 目录下,核心就一个 WallImageTrailDemo.vue,材质在 wall-effects-lib/materials.ts 的 WALL_IMAGE_TRAIL_SOURCE(L40-55,16 行 GLSL),贴图是 public/images/wall-arrow.png(256×256,80% 透明)。搬去自己项目时,盯住上面这几处------最要紧的是"重复次数"这个旋钮(默认 12 最接近正方形)和那个老生常谈的相机 orientation。


第105篇 一符之差墙头转,箭头沿边长路奔;

十分帧号催又至,十二分铺见本真;

莫道两篇同注册,新添一钮始区分。

相关推荐
xiaoye-duck37 分钟前
《C++ 项目:手写大模型接入 SDK》会话模块整合:内存 SessionManager 与 DataManager 持久化联动
开发语言·数据库·c++·语言模型
Agent世界2 小时前
用 CDP 做发布自动化:四道机器闸门与产物验收
前端·javascript
Funlang2 小时前
龙芯上的 Fun:从跑起来到机器码
开发语言·龙芯
总要学点什么2 小时前
第104篇:三维特效-流动墙体效果——让贴图沿墙流动,它却只肯走一条对角线
开发语言·javascript·ecmascript·gis·webgl·材质·cesium
西伯利亚小火龙2 小时前
我以为是流体解算,结果只是一张贴图:复刻 Lusion 的屏幕流体特效
webgl·three.js
程序员大阳2 小时前
副队长ES6+教程(3)--解构赋值
前端·javascript·es6·赋值·解构
旖旎夜光2 小时前
【C++ 基础入门】C++11 新特性(中):右值引用、移动语义与完美转发
开发语言·c++·笔记·学习
神膘护体小月半2 小时前
css - 元素背景图由 hover 触发,初次加载时闪动的解决办法
前端·javascript·css
ShineWinsu3 小时前
对于Qt:QTabWidget常见属性的解析
开发语言·数据库·c++·qt·浏览器·qtabwidget·标签页