头图

CesiumJS 有两种卷帘,一种是 ImageryLayer 的对比,一种是 3DTiles 或 Model 的对比。

官方示例均能找到,目前仅支持左右对比。

1. 核心原理

1.1. 影像图层对比原理

通过控制不同 ImageryLayer 所处的地球瓦片(QuadtreeTile、GlobeSurfaceTile)的透明度实现。

可以在 GlobeFS.glsl 着色器代码中找到这么一段:

#ifdef APPLY_SPLIT
    float splitPosition = czm_splitPosition;
    // Split to the left
    if (split < 0.0 && gl_FragCoord.x > splitPosition) {
       alpha = 0.0;
    }
    // Split to the right
    else if (split > 0.0 && gl_FragCoord.x < splitPosition) {
       alpha = 0.0;
    }
#endif

这段着色器代码的 alpha 变量最终会参与到影像瓦片的颜色着色中,0 即代表不显示。而 czm_splitPosition 即水平分割点(像素坐标),由 canvas 尺寸和 scene.splitPosition 计算而来。

1.2. 模型对比原理

通过控制 Model 的片元是否舍弃实现。

可以在 ModelSplitterStageFS.glsl 找到这么一段(仅限 1.97 新 Model 架构之后):

void modelSplitterStage()
{
    // Don't split when rendering the shadow map, because it is rendered from
    // the perspective of a totally different camera.
#ifndef SHADOW_MAP
    if (model_splitDirection < 0.0 && gl_FragCoord.x > czm_splitPosition) discard;
    if (model_splitDirection > 0.0 && gl_FragCoord.x < czm_splitPosition) discard;
#endif
}
在渲染 SHADOW_MAP 时,不进行分割(保留完整的遮挡信息)。

2. 影像分割对比

从 1.1 可以看到,除了分割位置 splitPosition 外,还有一个 split,这个值负责辨别当前片元是分割点左边还是右边的,以方便设置全透明。

2.1. 每个瓦片的分割方向值 - split

这个值作用在 ImageryLayer 上,值类型是 SplitDirection

SplitDirection 是 js 时代没有枚举类型用静态对象,并不是真的就这个类型,真正的 jsdoc/ts 类型还是 SplitDirection 的成员的类型 - number
rightLayer.splitDirection = SplitDirection.RIGHT;

它是怎么传递到着色器中的呢?

2.2. GLSL 函数 - sampleAndBlend 的调用

首先看看在 GlobeFS.glsl 中它的消费之处,是一个函数:

vec4 sampleAndBlend(
  // ... 一堆参数
  float split,
  // ...
)
{
// ...

#ifdef APPLY_SPLIT
    float splitPosition = czm_splitPosition;
    // Split to the left
    if (split < 0.0 && gl_FragCoord.x > splitPosition) {
       alpha = 0.0;
    }
    // Split to the right
    else if (split > 0.0 && gl_FragCoord.x < splitPosition) {
       alpha = 0.0;
    }
#endif

// ...
}

这个函数在 GlobeSurfaceShaderSet.js 模块中被拼接调用:

GlobeSurfaceShaderSet.prototype.getShaderProgram = function (options) {
  // ...
  if (applySplit) {
    fs.defines.push("APPLY_SPLIT");
  }
  // ...
  computeDayColor += "\
    color = sampleAndBlend(\n";
  // ...
}

2.3. 每个瓦片的分割值 uniform 数组 - u_dayTextureSplit

需要明白的是,在 JS 这边是对着色器代码文本的拼接,所以实际上对 sampleAndBlend 函数的 split 参数传参,其实还没有发生,这里只是拼接出即将传给这个函数执行的变量名,而这个变量名又是代码文本,由 JS 控制:

applySplit ? `u_dayTextureSplit[${i}]` : "0.0"

显然,这个值来自一个数组的元素 u_dayTextureSplit[i] ,从命名风格上看,是一个 glsl 中的 uniform 值,很容易在 GlobeFS.glsl 中找到:

#ifdef APPLY_SPLIT
uniform float u_dayTextureSplit[TEXTURE_UNITS];
#endif

是一个 float 数组,这个 TEXTURE_UNITS 是一个由 GlobeSurfaceShaderSet.js 根据当前视角下有多少个瓦片动态设定的 GLSL 宏,也是一个数字,可以说这个数组记录了每一个地球瓦片是在对比线的左边还是右边。

那这个 u_dayTextureSplit[i] 值又是什么时候设置的呢?

全文搜索,可以在 GlobeSurfaceTileProvider.js 模块中找到 createTileUniformMap 函数,它外抛了一个 uniform 字典对象,里面就有对 u_dayTextureSplit 的设定,来自 dayTextureSplit 属性:

// GlobeSurfaceTileProvider.js

function createTileUniformMap(/* ... */) {
  const uniformMap = {
    u_dayTextureSplit: function () {
      return this.properties.dayTextureSplit;
    },
    properties: {
      // ...
      dayTextureSplit: [],
    }
  };
  // ...
  return uniformMap;
}

在同一个模块下,找得到设置这个数组的地方,这个函数是每个地球瓦片设置 DC 的地方,相当长:

function addDrawCommandsForTile(tileProvider, tile, frameState) {
  // ...
  uniformMapProperties.dayTextureSplit[numberOfDayTextures] =
    imageryLayer.splitDirection;
  applySplit =
    applySplit ||
    uniformMapProperties.dayTextureSplit[numberOfDayTextures] !== 0.0;
  // ...
}

applySplit 即将会在这个函数稍下方调用 2.2 中提及的 getShaderProgram 使用,不细细展开了,有兴趣在模块内搜即可。

显然,这个 u_dayTextureSplit 数组的每个元素值,是根据 imageryLayer.splitDirection 设定的,这数组挂在 createTileUniformMap 返回的 uniformMap 上,也会合并到 DC 的 uniform 值中去,进而着色器程序可以访问到对应的值。

2.4. 分割位置 - splitPosition

这个值既可以作用于 ImageryLayer,也可以作用于 ModelCesium3DTileset,是一个 0~1 之间的相对值,相对于 canvas 的像素宽度(当前 CesiumJS,写此文时 1.110 及以下版本仅支持左右分割)。

它是一个自动统一值,表面上是 Scene 的属性,但是实际上是 FrameState 的值:

/**
 * Gets or sets the position of the splitter within the viewport.  Valid values are between 0.0 and 1.0.
 * @memberof Scene.prototype
 *
 * @type {number}
 */
splitPosition: {
  get: function () {
    return this._frameState.splitPosition;
  },
  set: function (value) {
    this._frameState.splitPosition = value;
  },
},

每一次 render,Scene 都会更新整体的 UniformState:

// Scene.js

function render(scene) {
  // ...
  us.update(frameState);
  // ...
}

在更新时就会把相对值根据 canvas 的宽度计算出绝对像素宽度:

// UniformState.js

UniformState.prototype.update = function (frameState) {
  // ...
  // Convert the relative splitPosition to absolute pixel coordinates
  this._splitPosition =
    frameState.splitPosition * frameState.context.drawingBufferWidth;
  // ...
}

最终,自动统一值(AutomaticUniforms)会被更新到着色器中,它在这里收集了 uniformState.splitPosition 的值:

// AutomaticUniforms.js

/**
 * An automatic GLSL uniform representing the splitter position to use when rendering with a splitter.
 * This will be in pixel coordinates relative to the canvas.
 *
 * @example
 * // GLSL declaration
 * uniform float czm_splitPosition;
 */
czm_splitPosition: new AutomaticUniform({
  size: 1,
  datatype: WebGLConstants.FLOAT,
  getValue: function (uniformState) {
    return uniformState.splitPosition;
  },
}),

在以前有文章介绍过 CesiumJS 的统一值,就不详细说自动统一值的更新了。

2.5. 影像分割小结

通过控制瓦片片元的透明度实现分割对比,非常简单。当然,如果读者不熟悉 CesiumJS 地球瓦片的渲染 - 瓦片四叉树的话,可能有点难理解。

每个地球瓦片(GlobeSurfaceTile)都会生产出 TileImagery,这个 tileImagery 会从每个 ImageryLayer 中读取 ImageryProvider 提供的瓦片源作为纹理,从 terrainProvider 获取地形瓦片作为形状。

所以每个要渲染的地球瓦片一般都有自己的 DrawCommand(下文简称 DC),体现在 GlobeSurfaceTileProvider 模块下的 addDrawCommandsForTile 函数。

  • czm_splitPosition - FrameState 贯穿始终,把分割位置 frameState.splitPosition 传给每个地球瓦片的 DC
  • split - TileImagery 把 ImageryLayer 的 splitDirection 传给 uniform 数组,通过 sampleAndBlend 函数传参使用
  • 最终在 GlobeFS.glsl 中完成这两个值的联合判断,实现片元着色的全透明控制,即影像分割

3. 模型或 3DTiles 的分割对比

普通的模型,在 1.97 版本之后的模型新架构下通过 ModelSplitterStage 这个阶段作用分割值:

// ModelSplitterStageFS.glsl

void modelSplitterStage()
{
    // Don't split when rendering the shadow map, because it is rendered from
    // the perspective of a totally different camera.
#ifndef SHADOW_MAP
    if (model_splitDirection < 0.0 && gl_FragCoord.x > czm_splitPosition) discard;
    if (model_splitDirection > 0.0 && gl_FragCoord.x < czm_splitPosition) discard;
#endif
}

3.1. 在模型对象上作用分割阶段着色函数与设置分割方向

模型的阶段拼装在以前源码解读的文章讲解过,不赘述。

直达目的地:

ModelSplitterPipelineStage.process = function (/* ... */) {
  // ...
  shaderBuilder.addDefine(
    "HAS_MODEL_SPLITTER",
    undefined,
    ShaderDestination.FRAGMENT
  );
  shaderBuilder.addFragmentLines(ModelSplitterStageFS);

  const stageUniforms = {};

  shaderBuilder.addUniform(
    "float",
    ModelSplitterPipelineStage.SPLIT_DIRECTION_UNIFORM_NAME,
    ShaderDestination.FRAGMENT
  );
  stageUniforms[
    ModelSplitterPipelineStage.SPLIT_DIRECTION_UNIFORM_NAME
  ] = function () {
    return model.splitDirection;
  };

  renderResources.uniformMap = combine(
    stageUniforms,
    renderResources.uniformMap
  );
}

代码不长,给片元着色器作用分割阶段的着色函数后,立马为 ModelSplitterPipelineStage.SPLIT_DIRECTION_UNIFORM_NAME 这个 uniform 变量 model_splitDirection 设置了统一值,即 model.splitDirection,意义同 imageryLayer.splitDirection,可参考 API 文档,SplitDirection 类有明确解释。

3.2. 一个特殊的地方 - 3DTiles 1.0 pnts 动态点云

源码中存在一个类 - TimeDynamicPointCloud,用到了 PointCloud 类,而 PointCloud 类用到了 Splitter 模块,这个模块作用就是修改片元着色器,与 ModelSplitterPipelineStage 干的事情一致:

const Splitter = {
  modifyFragmentShader: function modifyFragmentShader(shader) {
    shader = ShaderSource.replaceMain(shader, "czm_splitter_main");
    shader +=
      // czm_splitPosition is not declared because it is an automatic uniform.
      "uniform float czm_splitDirection; \n" +
      "void main() \n" +
      "{ \n" +
      // Don't split when rendering the shadow map, because it is rendered from
      // the perspective of a totally different camera.
      "#ifndef SHADOW_MAP\n" +
      "    if (czm_splitDirection < 0.0 && gl_FragCoord.x > czm_splitPosition) discard; \n" +
      "    if (czm_splitDirection > 0.0 && gl_FragCoord.x < czm_splitPosition) discard; \n" +
      "#endif\n" +
      "    czm_splitter_main(); \n" +
      "} \n";

    return shader;
  },
  // ...
};

看得出代码都是类似的,只不过代表模型分割方向的 model_splitDirection 变成了 czm_splitDirection,这个 Splitter 模块作用的 TimeDynamicPointCloud 数据只是一堆 pnts 集合,没有形成一个 Cesium3DTileset。

为什么要单列出来这一点呢?考虑到文章的全面性,列出来方便想修改源码的读者。

4. 总结

分割功能在国内一般称作 “卷帘对比”,通过片元着色器中一个相对简单的逻辑实现,在地球对象和一般模型对象上逻辑略有区别,但是核心思路都是一样的,判断出当前片元所处的 gl_FragCoord 是否需要被 “消失”,或透明,或直接舍弃着色。

官方也提供了相应的例子。

当然,目前它只能在左右方向上对比,可以通过一些修改源码的手段实现上下方向的垂直对比。


岭南灯火
83 声望60 粉丝

一介草民