第104篇:三维特效-流动墙体效果——让贴图沿墙流动,它却只肯走一条对角线

上一篇 #103,我们把一圈墙的半径和高度交给逐帧累加,拆完"每帧 destroy + new 一个 Primitive"和那个"是除数却叫速度"的滑块。结尾照例留了下一站:

往后 #104 流动墙体要接的 WallTrail / WallImageTrail 是这套材质里最复杂的两个,换算层数只会更多------st.s 是段序号这件事,到时候会变成"贴图在长段上被拉长、在短段上被压扁"的直接原因。

结果翻开 src/cases/wall-trail/ 一看:这句预告完全猜错了方向。 WallTrail 压根没碰 st.s------它连一个 s 字都没写。整段着色器里 st 只出现两次,两次都是 st.t:

glsl 复制代码
vec4 colorImage = texture(image, vec2(fract(st.t - time), st.t));

u 和 v 都来自 st.t。 这意味着贴图采样轨迹是一条 45° 的对角线,而"沿墙长"那个维度(st.s)从头到尾没被读过一次。我原以为会看到"贴图被拉长或压扁",实际看到的是更彻底的一件事:64×64 的贴图,每一帧只有 3.10% 的像素被取到,其余 96.9% 全是白给。

顺着这条线往下挖,还挖出两件更有意思的事。第二件:"栅栏纹理沿墙体向上流动"这个描述只对了一半。 我把沿墙高的 alpha 剖面在四个不同时刻各算了一遍,又做了刚性平移检验------真实贴图的相关系数只有 0.9381 / 0.8402 / 0.4995(δ 分别取 0.05 / 0.10 / 0.20),而作为对照的"只沿 u 变化"合成贴图是 0.9983 / 0.9931 / 0.9805。差出来的那部分,就是被钉死在墙高上、永远不随 time 移动的 v 分量。

第三件是老朋友:默认相机又一次把墙躺平了 (6000 m 垂直俯视),而且这个 speed 滑块和 #103 那个同名却反义------#103 的是除数(越拖越慢),本篇的是分子(越拖越快)。同一个中文标签、同一个滑块位置,两个相邻案例给出了完全相反的方向,这件事本身就值得单写一篇。

回过头看,#103 结尾那句预告猜错方向不是坏事------它错得很有代表性 。我当时按"st.s 是段序号"这条线索往下推,很自然地推出"贴图会按段被拉伸";但实际代码压根没有走到 st.s 那一步。读到一段着色器时,先确认它到底用了哪些输入,再去推后果 ,这个顺序不能反。本篇第一件事就是把 WALL_TRAIL_SOURCE 里 st 出现的每一次都数一遍:两次,全是 st.t。数完这个,后面所有结论就有了地基。

顺带把本篇的"家底"交代一下:demo 一共 6 个滑块(颜色、透明度、流动速度、墙宽、墙深、墙高),其中只有"流动速度"不影响几何 ------而它恰恰是唯一一个会让 Cesium 销毁重建整个批次的(因为是材质 uniform,走 definitionChanged)。墙宽 / 墙深 / 墙高影响几何,反倒是通过"整体替换 positions"来触发重建,路径不同。这两条路径我们在 #102 已经拆过一次,本篇再遇一次,正好可以验证那套分析是不是通用的------结论是完全通用 :WallGeometryUpdater._isDynamic 那六个条件本篇一个都不触发(都是 ConstantProperty),所以走静态批次;而材质侧的 setter 一抛事件,批次就整批重建。同一个库、同一套机制,换了个 kind 也一样。

再看一眼本篇和 #102 的"代码差异量",这个数字挺有意思:两个 demo 的 <script setup> 部分几乎逐行对应,差别只有 kind 值、buildOptions 里多两个字段、相机高度从 5000 改成 6000、以及滑块列表里"墙宽 800 / 墙深 600 / 墙高 250"这几个默认值。也就是说,从"静态渐变墙"到"流动栅栏墙",作者只写了不到二十行新代码 ------而这二十行里藏了本篇三个主要结论中的两个(采样坐标、speed 语义)。改动量小不等于风险小,这是这类"换参数出新特效"的开发方式最需要注意的地方。

本文目标

  • 认清 WallTrail 在墙家族里的位置:它和 #102 共用同一份实体封装,只换了一个 kind;
  • 量清楚那句 texture(image, vec2(fract(st.t - time), st.t)) 到底取到了贴图的多少像素;
  • 用刚性平移检验判定"向上流动"这个描述成立到什么程度,并说清为什么;
  • 拆开 alpha 的两道关(× 0.7 与 max(... × 3.0, ...)),以及那个永远走不到的 color.a == 0.0 分支;
  • 抓住帧率依赖第九次,并把它和 #103 那个"同名反义"的 speed 摆在一起看;
  • 算清 6000 m 默认相机下这面墙有多大------本系列第四次栽在同一个地方;
  • 最后拿到一份"抄之前先改哪八处"的清单。

其中第二、三两条是本篇的核心,而且它们是同一个根因的两副面孔 :采样坐标的 u 和 v 都取自 st.t,于是贴图只被取到一条对角线(第二条),同时时间只作用在其中一个分量上(第三条)。改一个字符,两件事一起好。 这也是本篇和 #102 / #103 的一个区别:那两篇的坑各自独立(材质系数是一处、动画口径是另一处),本篇的坑只有一个源头。

另外提前说一句方法论上的提醒:本篇有一处结论是**"某个描述成不成立"**,而不是"某个数是多少"。这类结论需要对照组------我造了一个"只沿 u 变化"的合成贴图来做对照,才敢说"差异全部来自 v 方向的变化"。没有对照组的"我觉得它在动"是没有说服力的,这也是本篇第三节那张相关系数表存在的理由。

数字怎么来的,照例先交代。贴图那一侧是纯读像素:我用 Pillow 把 public/images/wall-fence.png(64×64 RGBA)逐像素读出来,然后在 Python 里复刻着色器的采样算式 ------u = fract(t - time)、v = t,再按 Cesium 的 flipY 规则把 v 映射到图像行。flipY 这件事我查了源码:Renderer/Texture.js L60 写 flipY = true 是默认值,L500 / L528 又把它传给 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, texture.flipY):

js 复制代码
// Renderer/Texture.js L27 / L60
 * @property {boolean} [flipY=true] If true, the source values will be read
 *                                  as if the y-axis is inverted (y=0 at the top).
    flipY = true,
// Renderer/Texture.js L500 / L528
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, texture.flipY);

所以纹理坐标 v=0 落在图像的最后一行,v=1 落在第一行------这和"图像第一行在上、v 从下往上"的直觉正好相反,不查源码一定会写反。我在 #101 那篇核对粒子贴图时也踩过同一个方向问题,本篇是第二次。刚性平移检验用的是皮尔逊相关系数,采 256 个点。

几何与颜色那一侧和 #102 / #103 一样:WallGeometry.createGeometry() 在 Node 里真实构造,顶点数、st 的每一个值都读出来;边长用 EllipsoidGeodesic.surfaceDistance 量真实测地距离,与 πR/180 的设计值对比。屏幕上几个像素按 PerspectiveFrustum 的默认 fov = π/3(水平)与 800×600 视口算。凡是对不上的数,这篇文章里一个都没写。

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


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

先把这条支线的站位摆清楚,因为本篇和前两篇的关系比看上去紧密得多:

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

本篇和 #46 是同一个动法 (贴图沿墙高滚),只换了容器;而本篇和 #105 是同一个容器,只差一个采样坐标------这个差别就是第五节要说的那一字。

装配侧几乎不用介绍,因为 createWallEntity / updateWallEntity / rectPositions 三个函数和 #102 一字未改 (都在 src/cases/wall-effects-lib/entities.ts)。demo 里变的只有 buildOptions:

ts 复制代码
// src/cases/wall-trail/WallTrailDemo.vue L44-56
function buildOptions(): WallEntityOptions {
  return {
    id: WALL_ID,
    positions: buildPositions(),
    kind: 'WallTrail',
    material: {
      color: Color.fromCssColorString(colorHex.value).withAlpha(opacity.value),
      speed: speed.value,
      image: '/images/wall-fence.png'
    },
    outline: true
  }
}

比 #102 多了两个字段:speed 和 image。就这两个字段,把整面墙从"静态渐变"变成了"会流动"。

这个"只换一个 kind"的设计值得单独夸一句。 wall-effects-lib 里三种材质共用一个 WallMaterialProperty、共用同一套 setter、共用同一个 getValue,切换材质只是 getType() 里三行 if 的事:

ts 复制代码
// src/cases/wall-effects-lib/materials.ts L181-196
  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: unknown, result?: Record<string, unknown>): Record<string, unknown> {
    if (!result) result = {}
    result.color = Color.clone(this._color, result.color as Color | undefined)
    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
  }

这是本系列见过的分层做得最干净的一处------代价是三种材质共享同一套 uniform 通道:getValue 无条件塞 speed(WallDiffuse 用不上,被静默忽略),repeat 只在 WallImageTrail 分支给(WallTrail 拿不到,见第六节第 ⑪ 条)。而 equals() 里那六项比较把 kind、color、speed、repeatX、repeatY、image 全算进去,所以换 kind 会触发 equals 返回 false ------不过本篇只在创建时定 kind,运行时不会走到这条。

注册那段也顺手看一眼,它解释了为什么本篇能用 #102 那套材质:

ts 复制代码
// src/cases/wall-effects-lib/materials.ts L87-97
  for (const entry of entries) {
    if (cache.getMaterial(entry.type)) continue
    cache.addMaterial(entry.type, {
      fabric: {
        type: entry.type,
        uniforms: entry.uniforms,
        source: entry.source
      },
      translucent: () => true
    })
  }

三个材质一次性注册进私有 Material._materialCache,全部标 translucent: () => true。所以本篇和 #102 走的是同一个半透明通道 ,#102 那句"没有背面剔除导致混合两次"的账本篇照单全收(见第六节第 ⑦ 条)。WallDiffuse 的默认色是 new Color(0.2, 0.6, 1.0, 0.7),WallTrail 的默认色是 new Color(0.2, 0.8, 0.4, 0.7)、speed 默认 2、image 默认 Material.DefaultImageId------不过 demo 三个值全覆盖了。

再看更新侧。updateEffect() 和 #102 是同一套写法(连"改颜色也把 positions 传一遍"这个毛病都一样),watch 则多了一个 speed:

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

六个滑块共用一个 watch,所以拖其中任何一个都会走同一条 updateEffect()。 而 updateEffect() 内部同时改 positions、color、speed 三样------于是拖"流动速度"也会把 positions 整体换掉,触发几何重建。这是 #102 已经指出过、本篇原样继承的问题,也是第七节第 6 条要修的地方。

顺带把几何量一遍。rectPositions(120.38, 36.08, 800, 600, 250) 生成 800×600 的矩形闭合环,真实构造 WallGeometry 得到:

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

与 #102 完全同构 :4 面墙、每面分到 1/4 的 s、底 t=0 顶 t=1。差别只在尺寸------#102 是 600×600×200,本篇是 800×600×250。所以 #102 那些和几何有关的结论本篇全部继承,包括球近似那笔账:

量 设计值 实测 偏差
东-西边 800 m 800.958 m +0.120%
南-北边 600 m 598.065 m −0.323%
围合面积 480000 m² 479024.7 m² −0.203%

和 #102 的 600×600 是同一组偏差(+0.120% / −0.323%),原因也一样:EARTH_RADIUS_METERS = 6378137 是长半轴,而经度方向该用卯酉圈曲率半径 N、纬度方向该用子午圈曲率半径 M。这是这套 πR/180 换算第二次出现在同一条线上(#102 基础墙用过、#103 扩散墙改用 ENU 避开了、本篇又回来了),量级稳定在千分之几。

有趣的是本篇的尺寸比 #102 大(800×600 对 600×600),但偏差的百分比几乎一模一样 ------因为这套换算是"每度多少米"的常数误差,与要走多远无关。这恰恰是它的麻烦之处:墙画得越大,绝对误差越大。800 m 的墙差 0.96 m,2000 m(滑块上限)的墙就差 2.4 m。真要做量算,这个误差会随尺寸线性累积。

再补一个和 #102 的对照,说明"同构"到什么程度:#102 的包围球半径是 435.482 m(600×600×200),本篇是 515.21 m(800×600×250)。两处的 st.s 都只有 5 个取值(0 / 0.25 / 0.5 / 0.75 / 1),st.t 都只有 0 和 1 两个值------因为两者的顶点结构完全相同 :4 面墙,每面 4 个顶点,底边两个 t=0、顶边两个 t=1。也就是说,无论墙是方的、圆的还是长条形的,只要它是 WallGeometry 挤出来的闭合环,st.t 就永远只有两个值,中间全靠 GPU 插值 。这一点决定了本篇着色器里所有"沿墙高"的效果本质上都是线性的------除非用 pow / smoothstep 去弯它。


二、贴图:64×64,每帧只被取到 3.10%

WALL_TRAIL_SOURCE 一共 20 行,是三套墙材质里唯一用到贴图与帧号的:

glsl 复制代码
// src/cases/wall-effects-lib/materials.ts L19-38
  uniform sampler2D image;
  uniform float speed;
  uniform vec4 color;

  czm_material czm_getMaterial(czm_materialInput materialInput) {
    czm_material material = czm_getDefaultMaterial(materialInput);
    vec2 st = materialInput.st;
    float time = fract(czm_frameNumber * speed / 1000.0);
    vec4 colorImage = texture(image, vec2(fract(st.t - time), st.t));
    if (color.a == 0.0) {
      material.alpha = colorImage.a;
      material.diffuse = colorImage.rgb;
    } else {
      material.alpha = colorImage.a * color.a;
      material.diffuse = max(color.rgb * material.alpha * 3.0, color.rgb);
    }
    return material;
  }

盯住采样那一行:

glsl 复制代码
vec4 colorImage = texture(image, vec2(fract(st.t - time), st.t));

第一个参数 u = fract(st.t - time),第二个参数 v = st.t。两个都来自 st.t。

这有什么后果?把 st.t 从 0 连续走到 1,采样点在贴图 UV 空间里画出的轨迹是 u 与 v 同步增长的一条 45° 直线(在 u 方向环绕),也就是贴图的主对角线。我在 Python 里按这个算式采样了 4096 个点,命中:

复制代码
time=0.00 → 命中 127 个不同像素 / 4096 = 3.10%
time=0.25 → 命中 127 个不同像素 / 4096 = 3.10%
time=0.50 → 命中 127 个不同像素 / 4096 = 3.10%
跨全部 time 的并集:4096 / 4096 = 100.0%

复刻的那段代码不长,但每一行都得和着色器对上,尤其是 flipY 那个方向:

python 复制代码
def sample(u, v, ch=3):
    """按 Cesium 的规则采样:Texture 默认 flipY = true
       ⇒ 纹理坐标 v=0 落在图像【最后一行】,v=1 落在第一行"""
    x = int(min(W - 1, max(0, u * W)))
    yp = int(min(H - 1, max(0, (1.0 - v) * H)))   # flipY 后的行
    return px[x, yp][ch]

def fract(x):
    return x - int(x) if x >= 0 else x - (int(x) - 1)

def wall_profile(time, N=512):
    """沿墙高 t: 0→1 的 alpha 剖面,复刻 vec2(fract(st.t - time), st.t)"""
    return [sample(fract(i / float(N) - time), i / float(N)) for i in range(N)]

顺带说明一下"127 个像素"这个数字是怎么来的。采样轨迹在 UV 空间是一条斜率 1 的直线,长度是 √2 ≈ 1.414(以贴图边长为单位),换算成像素约 64 × 1.414 ≈ 90;但因为 fract 会让轨迹在 u 方向环绕一次(除非 time 恰好为 0),实际扫描到的格子会更多一些。实测 127 个,占 4096 的 3.10%------这个比例与 time 无关(三个时刻都是 127),因为平移不改变一条直线扫过的格子数。

一张 64×64 的贴图,任意一帧只有 127 个像素(3.10%)参与画面。 剩下的 3969 个像素要等 time 走完一整个周期才轮得到------fract 的环绕让这条对角线在 u 方向平移,理论上扫过全图,但任何一瞬间你只能看到一条线。

这和 #100 那篇的情况正好是两种极端:#100 的 lighting.png 是 512×152、全部 152 行在 x 方向完全一致,所以 s 方向是死维度 ,裁成 1×152 效果相同------那是"一维剖面被复制成二维"。本篇反过来:贴图本身是真正的二维图 (我逐行逐列统计过,64 行、64 列全部有起伏,行内 alpha 极差普遍在 130 以上),但采样方式把它压成了一维------一条对角线。一个是"贴图浪费了空间",一个是"着色器浪费了贴图"。

把这张贴图本身的性质也记一下,因为它决定了修复后的观感。wall-fence.png 是一张纯灰度图 ------我逐像素看过,R、G、B 三个通道的值恒等(比如 (69,69,69)、(235,235,235)),颜色信息一点都没有 ,只有 alpha 通道在编码形状。所以"栅栏"的形状完全由 alpha 决定,而颜色则由着色器里的 color.rgb 统一染上去(默认 #33cc66 绿)。这也解释了为什么 max(color.rgb * alpha * 3.0, color.rgb) 这种写法在本篇行得通:RGB 是纯的,只有亮度需要调。

另外整幅图的 alpha 范围是 41 ~ 245 ,非零(alpha > 8)像素占比 100% ------也就是说没有一个像素是完全透明的。这带来一个后果:discard 之类的裁剪手段在这张贴图上无效,墙的"镂空感"完全靠 alpha 混合做出来,边缘永远带着 41/255 ≈ 16% 的底噪。想做出干净的栅栏缝隙,得把贴图最暗的部分真正压到 0。

把三篇用过的贴图摆一起看,能看出这条线的一个规律:#100 的 lighting.png 是 512×152 的一维剖面(死维度占 512/512),#101 的 makeParticlesImage() 是 64×256 的四条带(只覆盖 58.6% 宽度),本篇的 wall-fence.png 是 64×64 但只被取到 3.10%。三次都是"贴图有一部分用不上",但成因各不相同 ------分别是"贴图本身冗余"、"贴图内容没铺满"、"采样方式把贴图压成一条线"。判断一张贴图有没有被充分利用,光看它的尺寸是不够的,得看采样坐标怎么走。 这也是本篇第二节那张 127/4096 的表存在的理由。

顺带把 flipY 的方向说清楚,因为它决定了"墙根亮还是墙顶亮"。Renderer/Texture.js L60 的默认值是 flipY = true,L500 / L528 把它喂给 gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, ...),于是纹理坐标 v=0 对应图像的最后一行(PIL 的 y=63),v=1 对应第一行。不查源码照直觉写,结论会整个反过来。

按这个方向,我把沿墙高的 alpha 剖面算了四个时刻(数值是 0~255):

st.t 0.00 0.25 0.50 0.75 1.00
time=0.00 227 189 121 106 63
time=0.25 235 222 113 69 103
time=0.50 230 203 122 48 61
time=0.75 229 191 151 147 48

(上表是 21 点剖面里抽的 5 点,完整 21 点在探针输出里。)

注意最后那一列:四个时刻在 t = 1.0 处的 alpha 分别是 63 / 103 / 61 / 48,而 t = 0.95 处是 152 / 91 / 51 / 45。fract(st.t - time) 在 st.t 接近 1 时会归零跳变 ,于是墙顶那一排像素的取值和它下面一行完全接不上。这是本系列第三次见到 fract 的这个副作用(#102 的 alpha 顶排跳回满值、#103 的扩散重置),而且这次更明显------从 152 直接掉到 63。

这个跳变在默认相机下其实看不见(墙顶那条边投影不到一个像素),但一旦把相机压到 2 km ------ 也就是第五节建议的那个高度 ------ 它就暴露了。所以这不是一个"理论瑕疵",而是"修好相机之后会立刻看见的问题"。两处要一起改。

再补一个观察:四个时刻的剖面形状各不相同 ,不只是整体平移。time=0.00 是"从 227 一路降到 106 再回升到 152",time=0.25 是"从 235 缓慢降到 69 再微微回升",time=0.50 是"从 230 缓降到 184 后陡降到 48",time=0.75 是"从 229 缓降到 147 平台后陡降到 44"。四条曲线的极值位置、下降陡缓全都不同------这就是第三节那个相关系数只有 0.5~0.94 的直观体现:你看到的不只是"图案在往上走",而是"图案在变形"。


三、"向上流动"只对了一半

官方描述写的是"栅栏纹理墙体向上流动"。这句话对不对,得看"流动"是怎么定义的。

把采样坐标拆成两个分量看:

  • u = fract(st.t - time) ------ 随 time 变化。 这是"流动"的来源。
  • v = st.t ------ 与 time 无关。 这个分量永远钉死在墙高上。

如果贴图是"只沿 u 变化"的纯竖条纹,那么 colorImage = f(u) = f(fract(st.t - time)),time 增加 δ 等价于把图案沿墙高平移 δ------那就是干净的向上流动,描述完全成立。

但 wall-fence.png 在 v 方向同样有起伏(逐行、逐列的 alpha 都有 30~200 的变化幅度)。于是墙上的图案是"平移的 u 分量"叠加"固定的 v 分量",两者不再同步。

我用刚性平移检验把它量化了。判据是:若图案整体向上平移 δ,应满足 D(t, τ+δ) == D(t−δ, τ)(现在在高度 t 处看到的值,等于 δ 时间之前在高度 t−δ 处看到的值)。取 256 个采样点算皮尔逊相关系数:

δ 真实贴图 只沿 u 的合成贴图 判读
0.05 0.9381 0.9983 差别还小
0.10 0.8402 0.9931 明显变形
0.20 0.4995 0.9805 几乎不认得

检验的代码也就十几行,核心是把"现在的剖面"和"δ 之前的剖面错开 δ"做相关:

python 复制代码
NS = 256

def disp(tau):
    """时间 tau 时,沿墙高的 alpha 剖面(256 点)"""
    return [sample(fract(i / float(NS) - tau), i / float(NS)) for i in range(NS)]

def corr(a, b):
    n = len(a); ma = sum(a) / n; mb = sum(b) / n
    num = sum((a[i] - ma) * (b[i] - mb) for i in range(n))
    da = sum((a[i] - ma) ** 2 for i in range(n)) ** 0.5
    db = sum((b[i] - mb) ** 2 for i in range(n)) ** 0.5
    return num / (da * db) if da * db else 0.0

for delta in (0.05, 0.10, 0.20):
    A = disp(delta)[: NS - int(delta * NS)]   # D(t, 0+δ)
    B = disp(0.0)[int(delta * NS):]           # D(t-δ, 0)
    print(delta, corr(A, B))

对照组是我自己造的:把贴图每一行取平均,压成"只沿 u 变化"的合成贴图(把上面 disp 里的 sample 换成 rowmean[yp] 即可)。同样条件下它的相关系数是 0.9983 / 0.9931 / 0.9805------几乎完美平移。

这个对照很关键,因为它把结论钉死了:差异不是"我的检验方法有问题",而是"贴图在 v 方向有变化"这一件事。 把 v 方向的变化抹掉,相关系数立刻回到 0.98 以上;保留它,δ=0.20 时只剩 0.4995。所以真实原因只有一个,不含第二种可能。

两组数字的差距全部来自 v 方向的变化。 δ=0.20(也就是时间走过五分之一个周期)时,真实贴图的相关系数只剩 0.4995,意味着图案有一半以上已经换掉了;而同样条件下合成贴图还有 0.9805。

所以准确的说法是:u 分量在干净地向上流动,v 分量把图案拽住不动,两者叠加的结果是"斜着滑动且形状在变"。 肉眼看上去仍然像"在流动",所以这个描述不算错------只是它掩盖了一个事实:你看到的流动里混着一个不动的分量,而这个分量占了图案变化的一半以上。

这件事有个很实际的后果,值得单独说:只要把 u 换成 st.s,它就自动变成干净的流动。 因为那时 u = fract(st.s - time)、v = st.t,两个分量分别来自墙长和墙高,time 只作用在 u 上------正是对照组那个"只沿 u 变化"的情形(相关系数 0.98 以上)。也就是说,第三节这个问题不是"贴图本身的问题",而是"采样坐标选错了"的问题。 修一个字符,相关系数从 0.4995 回到接近 1。

再补一个判断方法,以后遇到同类特效可以直接用:看采样坐标的两个分量里,time 出现在几个。 出现在一个分量上(不管 u 还是 v)⇒ 干净的单向流动;出现在两个上 ⇒ 斜向流动且形状会变;一个都不出现 ⇒ 静态图案(#100 的 PolylineLightingTrail 就是这种,"贴图是死的")。本篇是两个分量里有一个含 time、另一个不含,属于第二类和第三类的混合------这也是它的相关系数落在 0.5~0.94 之间(而不是接近 0 或接近 1)的原因。


四、alpha 的两道关,以及第九次帧率依赖

alpha 那一段有两道关。第一道是 × color.a(滑块,默认 0.7),第二道是这个:

glsl 复制代码
material.diffuse = max(color.rgb * material.alpha * 3.0, color.rgb);

这是 #100 那篇已经拆过一次的"穷人版 HDR"。ALPHA_BLEND 会把 gl_FragColor.a 钳到 0,1,超出 1 的部分没法从 alpha 出去 ,于是作者把它塞进 diffuse:alpha 越大,color.rgb × alpha × 3.0 越亮,超过 color.rgb 之后由 max 兜住。

关键在于那个"超过"的临界点。color.rgb × alpha × 3.0 > color.rgb 化简后就是 alpha > 1/3 (假设 rgb 非零)。而 alpha = colorImage.a × 0.7,所以:

复制代码
colorImage.a > 1/3 ÷ 0.7 = 0.4762 = 121/255

贴图 alpha 小于 121 的像素,max() 完全不起作用------那句 × 3.0 白写。 我统计了一下:

  • 整幅 64×64:2433 / 4096 = 59.4% 的像素落在生效区;
  • 单帧实际采样到的那条对角线:321 / 512 = 62.7% 落在生效区(范围 106~231)。

也就是说大约四成的采样点上,那句 max(... × 3.0, ...) 是纯摆设 。这和 #100 那次形成对照:#100 的 lighting.png 峰值 alpha 只有 0.9961×0.8×1.5 = 1.1953,只有 5.9% 的线宽 能让 max() 起作用;本篇生效区宽得多(62.7%),所以这个"穷人版 HDR"在本篇是真的在发光的。

把这条链再往下走一步,看清 diffuse 最后是怎么变成像素的。czm_material 出来之后,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 小的地方,颜色不只是"透",还会"暗"------这是第二次衰减。本篇贴图的 alpha 最低是 41/255 ≈ 0.161,乘 0.7 后只有 0.113,再乘一次 alpha 之后暗处几乎看不见。这解释了为什么栅栏的暗部看起来比数字更暗:它被打折了两次。

顺带把 color 也交代清楚。demo 默认色是 #33cc66,Color.fromCssColorString 给出 (0.2, 0.8, 0.4)。注意它没有经过 × 2.0 ------那是 WALL_DIFFUSE 独有的;WALL_TRAIL 走的是 max(... × 3.0, ...),作用在 alpha 上而不是直接推 rgb。所以本篇不会像 #102 / #103 那样出现色相漂移 ------这是本篇相对前两篇的一个(意外的)优点。想要提亮,正确做法是调那个 3.0,而不是给 color.rgb 乘系数。

顺带一个死分支:

glsl 复制代码
if (color.a == 0.0) { ... }

滑块下限是 0.1,color.a 永远不可能是 0.0,所以这个分支一次都不会走 。这和 #100 的 PolylineImageTrail 里那句是同一处抄过来的代码,本系列第二次见。

然后是 time:

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

帧率依赖第九次 ,公式和 #98 / #100 / #102 预告过的 WallTrail 完全一致。周期 = 1000/speed 帧:

speed 周期(帧) @30fps @60fps @144fps
0.5 2000.00 66.667 s 33.333 s 13.889 s
1 1000.00 33.333 s 16.667 s 6.944 s
2 500.00 16.667 s 8.333 s 3.472 s
3(默认) 333.33 11.111 s 5.556 s 2.315 s
5 200.00 6.667 s 3.333 s 1.389 s
7.5 133.33 4.444 s 2.222 s 0.926 s
10 100.00 3.333 s 1.667 s 0.694 s

滑块 0.5~10、步长 0.5,一共 20 档;60 fps 下周期从 33.333 秒到 1.667 秒。144 Hz 比 60 Hz 快 2.400 倍------和前八次分毫不差。

这里有个特别值得记的对照:同样是"速度"滑块,本篇和 #103 的语义正好相反。 #103 的 ΔR = radius/speed/20,speed 在分母上,往右拖变慢 ;本篇的 time = fract(帧号 × speed / 1000),speed 在分子上,往右拖变快。同一个中文标签、同一个滑块位置,两个案例给出了完全相反的方向。这不是谁写错了------是"速度"这个词在两个语境里本来就有两种换算口径(一个是"每帧走多少"的分母,一个是"单位时间转多少圈"的分子),而两边都没在面板上写清楚。

把本系列出现过的"速度类滑块"拉个清单,会发现这个坑比想象中常见:#46 的 czm_frameNumber * speed(分子,越拖越快)、#98 的 fract(帧号 * speed / 1000)(分子)、#99 的 frequency(同时改周期与概率,差 24.63 倍)、#100 的 speed(分子)、#101 的扫描角速度(分子)、#103 的 speed(分母 )、本篇(分子)。九处里八处是分子,只有 #103 是分母。 所以"往右拖变快"虽然是多数派,但用户并没有办法从标签上区分------唯一稳妥的做法是把单位写在标签里("周期 3.0 s" 或 "每帧 1.5 m")。

还有一个和 #103 的对照值得提:#103 的周期是整帧数 (181 帧),因为它是逐帧累加出来的;本篇的周期 1000/speed 帧在 speed 取某些值时不是整数(比如 speed=3 → 333.33 帧),而 fract 会在 333 帧和 334 帧之间产生一个周期的相位抖动------也就是"每转一圈,接缝的位置会漂一点"。speed 取 1000 的约数(1、2、4、5、8、10)时这个问题消失,speed=3 时每圈漂 1/3 帧。观感上察觉不到,但如果你要做"N 圈之后精确回到起点"的编排,得避开这类取值。


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

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

第四次的同一个病根:setView 只给 destination,pitch 走默认值 −π/2(Camera.js L1528-1530),垂直俯视。

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

复制代码
墙宽 800 m → 92.38 px(占屏宽 11.55%)
墙深 600 m → 69.28 px(占屏宽 8.66%)
顶圈只比底圈大 4.02 px

4.02 px。 250 m 的墙高在默认画面上就是这么厚------和 #102 那面墙的 3.46 px 是同一个量级,#103 那个 100 km 更极端(3.88 px)。四次的高度分别是 80 km / 5 km / 100 km / 6 km,看着毫无规律,但后果完全一致:竖向的特效在垂直俯视下投影为零,只剩一个平面轮廓。

要看到"流动"这件事,相机得压到多低:

相机高度 墙宽 占屏宽 观感
6000 m(默认) 92.4 px 11.5% 看不出是墙
3000 m 184.8 px 23.1% 勉强看出侧面
2000 m 277.1 px 34.6% 能看见竖向渐变
1500 m 369.5 px 46.2% 铺开半屏
1000 m 554.3 px 69.3% 占满大半

2000 m 左右比较合适。 和 #102(压到 1 km)、#103(压到 1.6 km)是同一个量级------这三个墙案例都遵循"物体尺寸的 3~5 倍"这个经验值,而它们的默认值都比这个值大了 3 到 60 倍。

把四次摆在一起看会更清楚:#101 的光锥(80 km)、#102 的基础墙(5 km)、#103 的扩散墙(100 km)、#104 的流动墙(6 km)。四个数毫无规律,但它们都比"合适值"大了至少一个数量级 ,而且四次的病根完全相同:setView 只写 destination。这已经不是巧合,而是这套 demo 的一个系统性习惯------大概所有案例都是从同一个模板复制出来的,orientation 那一行从来没人补过。

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

还有一处和 #102 的细微差别值得一提:#102 的墙高是 200 m、本篇是 250 m,而相机从 5000 m 提到 6000 m------两者一起放大了 1.2 倍,所以"墙厚占屏比"几乎没变 (#102 是 3.46 px、本篇是 4.02 px)。也就是说,作者调相机时是按比例调的,而不是按"能不能看见特效"调的。这恰好证明了那个判断:默认视角的标准是"看得见物体在哪儿",不是"看得见物体是什么"。

最后补一个本篇特有的麻烦:在垂直俯视下,你不但看不出墙高,也看不出"流动"这件事的方向。 因为 st.t 是墙高方向的参数,而墙高在俯视时投影为零------整面墙在屏幕上就一条 4 px 宽的环,环上的每一个点都在做同样的上下流动 ,于是你看到的只是"这条环在整体明暗变化",完全分辨不出"图案在往上走"。必须把相机压到能看见侧面的角度,"向上流动"才成立。 这是本篇和 #102 的一个关键区别:#102 的效果是纯竖向渐变,俯视下至少还能看到一圈晕;本篇的效果是"渐变 + 时间",俯视下连"时间在走"都看不出来。


六、工程上要盯的十一处

① 沿墙长零变化。 着色器一次都没读 st.s,所以四面 200~800 m 长短不一的墙长得一模一样 。这和 #46 那面"动态围墙"是同一个毛病------#46 当年是从观感上推断出来的,本篇从源码层面对上了:源码里 st.s 这个字符串在 WALL_TRAIL_SOURCE 中出现次数是 0 。想要"一圈墙上铺连续的图案",必须让 u 来自 st.s。

这一条有个验证方法,跑起来一眼就能确认:把"墙宽"从 200 拖到 2000,观察墙上的图案。如果图案跟着墙一起横向拉伸(栅栏变稀),说明 st.s 在起作用;如果栅栏密度纹丝不动、只是墙变长了,就说明 st.s 没被读。 本篇属于后者。这个方法比读源码还快,适合拿到一份陌生特效时先做一轮"黑盒体检"。

还有个连带的后果值得提:因为沿墙长零变化,四面墙的图案完全相同、且相位也相同 (因为 st.t 在四面上都是从 0 到 1)。所以绕着这圈墙走一圈,你会看到四面墙在完全同步 地流动------不像真实的流动特效那样有相位差。想要"沿墙长依次亮起"的波浪感,st.s 是唯一的入口。

② 贴图 96.9% 是白给的。 单帧只取 127/4096 个像素。这不是性能问题(纹理采样本来就是 O(1),取一个角和取一条线代价一样),而是你精心画的贴图有 96.9% 不会出现。如果这张图是找设计师要的,那 96.9% 的工时白付了;如果是自己画的,那 96.9% 的细节白画了。

反过来想,这其实也是一条"省钱"的信息:既然只有对角线会被用到,那这张贴图完全可以只画一条对角线的内容 (其余 96.9% 随便填什么都行,画面上一模一样)。极端情况下,把它换成一张 64×1 的条带、再让采样坐标固定取那一行,效果几乎等价------前提是你能改着色器。改不了着色器的时候,正确做法反而是按对角线来设计图案:把想让人看到的明暗变化画在 45° 那条线上。

③ image 是异步加载的。 Material 的 image uniform 走 loadTexture2DImageForUniform 异步加载,未就绪时 Material.ready = false,墙不会画。#100 那篇已经提过一次。本篇的贴图路径是 /images/wall-fence.png------一个绝对 URL,从 public/ 直接取,本地开发一般不会失败;但部署到子路径下时这个绝对路径会 404,墙会变成一整片纯色(color.rgb 染出来的绿),而且不报错 。判断方法:viewer.scene.requestRender() 之后检查 appearance.material.ready。

④ updateWallEntity 不传 image,所以运行时换不了贴图。 updateEffect() 只传 color 和 speed:

ts 复制代码
// src/cases/wall-trail/WallTrailDemo.vue L83-88
  updateWallEntity(viewer, WALL_ID, {
    positions: buildPositions(),
    material: {
      color: Color.fromCssColorString(colorHex.value).withAlpha(opacity.value),
      speed: speed.value
    }
  })

而 updateWallEntity 里 if (patch.material.image !== undefined) material.image = ...,undefined 时不赋值------这是对的 (不会把已有贴图冲掉),但也意味着贴图只能在 createWallEntity 时定死。想做"切换栅栏样式"的功能,得先补这条。

⑤ 改一个滑块 = 整批销毁重建。 和 #102 一模一样:speed 的 setter 会 raiseEvent(_definitionChanged):

ts 复制代码
// src/cases/wall-effects-lib/materials.ts L148-152
  set speed(value: number) {
    if (this._speed === value) return
    this._speed = value
    this._definitionChanged.raiseEvent(this)
  }

事件传到 StaticGeometryPerMaterialBatch L47-51 的 onMaterialChanged → invalidated = true → L445-467 splice + item.destroy()。速度滑块 20 档,从头拖到尾就是 20 次整批重建 ,而 speed 本来只是个 uniform------Batch.update 在不需要重建的分支里每帧都会调 materialProperty.getValue() 把值写进 appearance.material.uniforms(#102 第 ❻ 条已经论证过"改颜色本可零成本",speed 同理)。

加上其余滑块:颜色(连续)、透明度 19 档、墙宽 37 档、墙深 37 档、墙高 56 档,一共 169 档 。这比 #102 的 149 档还多------多出来的 20 档正是本篇新增的"流动速度"。而这一档恰恰是最不该触发重建的那一档,因为它连几何的边都不沾。

对照一下 #103:那篇是"每帧都在重建",所以改参数免费;本篇和 #102 都是"事件驱动",所以改参数贵。三种容器、三种代价:entity + 事件(#102 / #104,改参数贵、静止免费)、自管 + 每帧(#103,改参数免费、静止贵)、自管 + 事件(#101,改参数贵、静止免费)。选哪种取决于你的特效是"一直在动"还是"偶尔改一次"。

⑥ 球近似误差。 东-西 +0.120%、南-北 −0.323%、面积 −0.203%。#102 同款。

⑦ 没有背面剔除。 WallGeometryUpdater._getIsClosed 恒 false ⇒ open batch + closed=false ⇒ 从墙外看背面那两道墙也会画出来并混合。默认 alpha 上限 245/255 × 0.7 = 0.673,两次混合后 1 − (1 − 0.673)² = 0.893------你以为在调 0.67,实际画出来 0.89。

这个偏差的方向和 #102 / #103 一样:只会让墙更实,不会更透 。而且本篇的贴图本就"处处不透明"(非零像素 100%),所以叠两次之后栅栏的缝隙会被糊掉不少------本来能看穿的缝隙,在两道墙重叠的区域会变成实心 。想要确定的结果,给 MaterialAppearance 传 closed: true(代价:从圈内往外看时看不到墙)。不过本篇用的是 entity,改不了 MaterialAppearance------要走这条路得换成自己管的 Primitive(像 #46 那样)。这是 entity 容器的一个硬限制 :它替你决定了 closed = false,你没有任何入口去改。

⑧ fract 在墙顶跳变。 见第二节,从 152 掉到 63。

⑨ color.a == 0.0 是死分支。 滑块下限 0.1,所以 color.a 永远不可能是 0.0。这个分支里那两行(material.alpha = colorImage.a、material.diffuse = colorImage.rgb)的作用是"完全不染原色、直接用贴图自己的颜色"------考虑到贴图是纯灰度(第二节提过,R=G=B),就算真走得到,出来的也是一堵灰墙 。所以这个分支不只是走不到,走得到也没什么用。本系列第二次见(#100 的 PolylineImageTrail 里有一模一样的一句)。

⑩ 描边与点击定位。 outline: true 走 WallOutlineGeometry(#102 实测 10 顶点 / 26 索引 / 13 段线,其中底边几乎总被地面挡住);onLeftClick 用 pickPosition 三级回退却只取经纬度,carto.height 算了不用。两条都和 #102 一字不差------同一个库、同一份 demo 模板,连毛病都是复制过来的。这也再次说明"复制粘贴式开发"会把同一处问题带到每一个新案例里:#102 有、#104 有,等 #105 大概率还在。

⑩ 描边与点击定位。 outline: true 走 WallOutlineGeometry(#102 实测 10 顶点 / 26 索引 / 13 段线,底边几乎总被地面挡住);onLeftClick 用 pickPosition 三级回退却只取经纬度,carto.height 算了不用。两条都和 #102 一字不差。

⑪ repeat 这个 uniform 本篇没有。 WALL_IMAGE_TRAIL(#105)有 uniform vec2 repeat 且 getValue 里只在 WallImageTrail 分支塞 result.repeat;本篇的 WallTrail 没有这个 uniform,所以贴图密度不可调 ------就算把 u 改成 st.s,一圈墙也只能铺一个周期。想要 N 个周期,得自己加 repeat。

顺带把 WallTrail 和 WallImageTrail 的差别列全,因为它们的名字太像,很容易搞混:

维度 WallTrail(本篇 #104) WallImageTrail(#105)
采样坐标 vec2(fract(st.t - time), st.t) vec2(fract(st.s - time), st.t)
repeat uniform 无 有(默认 Cartesian2(12, 1))
st 预处理 无 materialInput.st * repeat
默认色 (0.2, 0.8, 0.4, 0.7) (1.0, 0.8, 0.2, 0.8)
默认 speed 2 6
diffuse 写法 max(rgb × alpha × 3.0, rgb) colorImage.rgb × color.rgb × 3.0
color.a == 0.0 分支 有(死代码) 无

唯一的实质差别就是采样坐标和 repeat ------其余都是参数默认值不同。所以第七节那句"改完之后两者只差一个 repeat"不是玩笑:把本篇的 u 换成 st.s 之后,这两个材质就真的只剩下"有没有 repeat"这一点区别了。

顺带说一句这两个名字:WallTrail 与 WallImageTrail 里都有 Trail,但一个沿墙高、一个沿墙长,实际效果完全不同。如果你要在两者之间选,别看名字------直接看 WALL_*_SOURCE 里那句 texture()。 这条建议对整条墙线都适用:这份库里的材质名只标识"用了哪张图",不标识"怎么动"。 我自己在 #103 结尾就因为按名字推断而猜错了方向,算是交过一次学费。

这十一条按"会不会立刻咬人"归个类。必改的是 ①(沿墙长零变化)和 ②(贴图 96.9% 白给) ------它们决定了这个特效到底长什么样,而且修起来只差一个字符;建议改的是 ⑤(169 档全量重建)、⑦(浓度偏差)、⑧(墙顶跳变) ;知道就行的是 ③④⑥⑨⑩⑪。第二节那个"只对了一半的流动"严格说不算 bug(观感上确实在动),但它和 ① 是同一个根因,改 ① 的时候会一起好。

把本篇和前两篇放一起看,能看出这条支线的一个规律:#102 的毛病在材质系数(× 2.0、× 0.8),#103 的毛病在动画口径(speed 是除数、两条分支不同步),本篇的毛病在采样坐标(u 和 v 同源)。 三篇的材质体系、实体封装、几何类全是一套,坑却一处都不重样------这大概就是"同一套代码换个参数就能出新特效"的代价:共用部分被反复验证过所以很稳,而每次新写的那几行才是真正没被检查过的地方。 本篇那几行就是一句 texture()。


七、抄之前先改的八处

1. 把 st.t 换成 st.s(一字)。 这是本篇最值钱的一处,改动只有一个字符:

glsl 复制代码
// 现在:u 与 v 都来自 st.t ⇒ 采样一条对角线,沿墙长零变化
vec4 colorImage = texture(image, vec2(fract(st.t - time), st.t));
// 改成:u 沿墙长、v 沿墙高 ⇒ 贴图铺满墙面,时间只作用在 u 上
vec4 colorImage = texture(image, vec2(fract(st.s - time), st.t));

改完之后三件事同时发生:贴图整幅被使用(3.10% → 100%)、沿墙长有变化了(四面墙不再雷同)、时间只作用在 u 上,v 不再参与平移 ⇒ 图案变成干净的横向流动,第三节那个"只对了一半"的问题自己消失。

注意 #105 的 WALL_IMAGE_TRAIL 正是这么写的 ------同一个文件里,上一个函数写 fract(st.s - time),下一个函数写 fract(st.t - time)。这基本可以判定是笔误,而不是有意设计。

2. 给相机补 orientation。 本系列第四次,不解释:

ts 复制代码
viewer.camera.setView({
  destination: Cartesian3.fromDegrees(DEFAULT_LON, DEFAULT_LAT, 2000),
  orientation: { heading: 0, pitch: CesiumMath.toRadians(-35), roll: 0 }
})

3. 帧率依赖换成墙钟。 第九次了,修法照旧:

glsl 复制代码
uniform float phase;   // 由 JS 每帧写入
// JS: phase = (performance.now() - t0) / 1000 * speed / 1000.0,且 dt 用 Math.min(dt, 0.1) 限幅
float time = fract(phase);

这样周期恒等于 1000/speed 秒,与帧率无关,也顺便把 #103 那类"切标签页回来暴走"的问题一起解决了。

4. 想清楚 max(... × 3.0, ...) 还要不要。 它在 62.7% 的采样点上生效,所以确实在发光;但它是"因为 alpha 塞不下才借道 diffuse"的权宜之计。真要 HDR,走 material.emission 或后期 bloom 更干净。至少把那个 3.0 提成 uniform。

5. 删掉 color.a == 0.0 那个分支,或者把滑块下限改成 0。留着只会让人误以为"透明度拉到 0 有专门处理"。

6. 改颜色 / 改速度别走全量重建。 和 #102 第 5 条是同一处:让 WallMaterialProperty 的 setter 改值但不抛事件,靠 Batch.update 里那句"每帧 getValue"把新值带进去;几何相关的滑块(墙宽 / 墙深 / 墙高)才走重建。

7. rectPositions 换成测地或 ENU。 千分之三的偏差在 2 km 的墙上是 6.5 m。想要省事就照 #103 那样用 Transforms.eastNorthUpToFixedFrame 铺点(误差 −0.0100%)。

8. 若需要沿墙长铺 N 个图案,给 WallTrail 补一个 repeat uniform。 #105 那套已经是现成的模板(默认 Cartesian2(12, 1)),搬过来即可。加上之后,st.s 那一维终于有了"密度"这个旋钮:

glsl 复制代码
uniform vec2 repeat;   // 默认 Cartesian2(1, 1)
vec2 st = materialInput.st * repeat;
float time = fract(czm_frameNumber * speed / 1000.0);
vec4 colorImage = texture(image, vec2(fract(st.s - time), st.t));

注意改完之后 st.s 的取值范围还是 0~1(每面墙各 1/4),乘上 repeat.x = 12 就是"一圈墙铺 12 个周期"------但 4 面墙各占 1/4 的 s,所以每面墙分到 3 个周期 。这和 #102 那面方墙的分法完全一样,也和 #103 那圈 128 段的墙一样:st.s 是段序号,不是弧长,所以"密度"是"每段几个周期"而不是"每米几个周期"。 想让密度跟真实长度走,得自己按段长反算每段的 repeat。

把八处按成本排一下:第 1 处只改一个字符、收益最大(贴图从 3.10% 用到 100%、沿墙长有变化、流动变干净三件事一起解决),是本篇唯一的"必做项";第 2 处(相机)也是一行,但只影响观感不影响正确性;剩下六处都属于"想做得更像样再做"。

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


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

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

代码在 src/cases/wall-trail/ 目录下,核心就一个 WallTrailDemo.vue,材质在 wall-effects-lib/materials.ts 的 WALL_TRAIL_SOURCE(L19-38,20 行 GLSL),贴图是 public/images/wall-fence.png(64×64)。搬去自己项目时,盯住上面这八处------最要紧的是第一处(一个字符)和第二处(相机)。


第104篇 一张栅栏铺上墙,六十四格只走斜;

纵流一半横不动,帧号九度催岁华;

六千米高墙作纸,俯身二里始见花。

相关推荐
西伯利亚小火龙8 分钟前
我以为是流体解算,结果只是一张贴图:复刻 Lusion 的屏幕流体特效
webgl·three.js
程序员大阳12 分钟前
副队长ES6+教程(3)--解构赋值
前端·javascript·es6·赋值·解构
旖旎夜光28 分钟前
【C++ 基础入门】C++11 新特性(中):右值引用、移动语义与完美转发
开发语言·c++·笔记·学习
神膘护体小月半30 分钟前
css - 元素背景图由 hover 触发,初次加载时闪动的解决办法
前端·javascript·css
ShineWinsu1 小时前
对于Qt:QTabWidget常见属性的解析
开发语言·数据库·c++·qt·浏览器·qtabwidget·标签页
程序员大阳1 小时前
副队长ES6+教程(2)--let 与 const
前端·javascript·es6·const·let·var
普通人61 小时前
WebGL与WebGPU入门指南
开发语言·webgl·1024程序员节
꧁细听勿语情꧂2 小时前
直接选择排序,堆排序,冒泡排序,递归快速排序、hoare版本、挖坑法、lomuto前后指针,非递归快速排序
c语言·开发语言·数据结构·算法
SomeOtherTime2 小时前
Rust集合(AI回答)
开发语言·后端·rust