引子

做为一名技术宅却没有能拿得出手的技术无疑是最可悲的事情。三年前,当我第一次接触Cesium的时候就被它强大和炫丽所折服,最关键的是它还是开源的。以前我一直是机械地敲着业务代码,好像计算机程序就只能干这点事情一样,而 Cesium 就像打开了一扇窗,原来里面的世界如此精彩,原来计算机程序还可以变幻出如此多的花样来。于是,我好像找到了人生方向一般,如饥似渴地踏入了 Cesium 的殿堂。可能是因为以前从未接触过图形学,让我觉得 Cesium 里面涉及的知识都那么高深,那么玄奥,同时学习过程也是那么痛苦。然心向往之,故痛并快乐着。差不多半年有余,对 Cesium 算是初窥门径了,搭上一个框架,堆上一堆功能花架子,在当年也算很不错了。然而,Cesium 的核心技术是图形学,可以说想真正把 Cesium 学好,图形学是必经之路。如果Cesium是修真体系,那么图形学就是功法,而且是高阶功法,想入门都很困难,更遑论小成、大成,圆满更是不敢想象,这也是很多人被挡在门外或只能浅尝辄止的缘故。也是在那时萌生了深入浅出系列的想法,本意就是想大道化简,人人都得以修炼,于是我便开启了深入浅出之路。要说一开始还是挺顺利的,出产了几篇勉强过得去的修炼心得。可是后来因为陷入项目导致这条路中断了,长达两年之久。以至于后来与人交流的时候都明显感觉自己的技术跟不上了,毕竟在你懈怠的时候别人还在努力修炼。果然修炼一途如逆水行舟,不进则退。意识到自己再也不能这样下去了,所以再次捡起修炼。如果按修真的等级划分的话,我现在最多只能算练气期九层,甚至还没筑基!用仙界的话说:筑基之下皆是蝼蚁!艾玛,太恐怖了!不废话了,开始我们的筑基之路。

预期效果

今天要讲的是自定义材质,篇幅可能会很长,很有可能一次更新不完,因为想写的东西太多了,一次能更多少是多少吧。Cesium 的材质我们都很熟了,毕竟从入门开始就使用到材质。只不过通常我们使用的是 Cesium 自带的材质,当我们想实现一个稍微复杂点效果时自带的材质库就不够用了,这时候就该自定义材质登场了。我记得刚接触 Cesium 那会想做一个雷达扫描的效果,自己啥也不会,于是就到网上搜罗了一些案例。有的是使用 Entity 实现的,思路很简单,先在地图上画一个圆,然后贴上预先绘制好的一张静态的雷达图片做为材质,通过 CallbackProperty 的方式让图形旋转起来,这方法的弊端是需要图片资源,不够灵活。还有一种思路是用 PostProcessStage 实现的,这种方式在当时看起来就很高大上,不需要引入图片资源,而且还涉及到了当时的知识盲区 Shader,一堆glsl语言直接让我放弃研究,当起了大自然的搬运工,不过这种方式实现的有瑕疵,就是距离太近的时候雷达出现断层,想要优化也无能为力,只得放弃。其实现在想来,这个效果其实及其简单,只需一个自定义材质就能搞定。

实现原理

照例我们还是先了解一下 Cesium 的材质结构,上API。

构造函数

new Cesium.Material(options)

原文:A Material defines surface appearance through a combination of diffuse, specular, normal, emission, and alpha components. These values are specified using a JSON schema called Fabric which gets parsed and assembled into glsl shader code behind-the-scenes.

翻译:材质是用来定义一个表面外观的,主要是通过漫反射、镜面反射、 法线、自发光和 Alpha  这些分量。这些值是使用 Fabric 方式以 JSON 格式进行赋值的,它们在后台它被解析并组装成 glsl 着色器代码。

选项参数

名字类型描述
options对象可选 具有以下属性的对象:
名字类型默认值描述
strictbooleanfalse可选 严格模式。这种模式下,通常会被忽略的问题(如未使用的 uniforms 或  materials)将会引发错误。
translucentboolean | functiontrue可选 当值为 ture 或函数的返回值为 true 的时候 ,几何图形的材质半透明。
minificationFilterTetureMinificationFilterTextureMinificationFilter.LINEAR可选 纹理缩小筛选器。
magnificationFilterTetureMagnificationFilterTextureMagnificationFilter.LINEAR可选 纹理放大筛选器。
fabricObject用于生成材质的JSON。

成员

materials : object

Maps sub-material names to Material objects.

将子材质名称映射到材质对象。

shaderSource : string

The glsl shader source for this material.

此材质的glsl着色器源码。

translucent : boolean|function

When true or a function that returns true, the geometry is expected to appear translucent.

材质半透明。

type : string

The material type. Can be an existing type or a new type. If no type is specified in fabric, type is a GUID.

材质类型。可以是已存的类型也可以是新类型。如果 fabric 中未指定 type 值,则 type 值未一个 GUID 值。

uniforms : object

Maps uniform names to their values.

统一名称和值的映射。

方法

static Cesium.Material.fromType(type, uniforms) → Material

静态方法,根据类型获取材质。这里获取的是 Cesium 自带的材质,不过如果你将自定义的材质注册到 Cesium,理论上也是能获取到的。

destroy() → void

销毁材质。

isDestroyed() → boolean

返回材质是否已被销毁。如果一个材质可能被销毁了,那么调用材质之前最好先调用这个方法,否则会抛出异常。

isTranslucent() → boolean

返回材质是否为半透明。

以上就是 Material 的基本 API 了,还有一些自带材质类型的静态成员我就不列出来了,毕竟我们今天讲的是自定义材质。

网上看到有很多人写自定义类的时候喜欢二次改造,就是把 Cesium 某个类的源码拷贝过来,然后在上面一顿操作猛如虎,改造完之后连亲妈都不认得了,毕竟 Cesium 底层还是 ES5 时代的代码,那成品一出来我只能说是惨不忍睹。所以我们要善用 ES6 “类”的继承,这会让你的代码看起来更简洁,更易懂。来,上示例!

import {Color, Material} from 'cesium';

/**
 * 雷达扫描材质。
 *
 * @class
 * @author Helsing
 * @since 2023/11/10
 */
export class RadarScanCircleMaterial extends Material {
    /**
     * 构造雷达扫描材质。
     *
     * @param options 选项。
     * @constructor
     */
    constructor(options={}) {
        const newOptions = {
            fabric: {
                type: options.type || 'RadarScanCircle',
                uniforms: options.uniforms || {
                    radians: options.radians ?? 0.00,
                    color: options.color || new Color(0, 1, 1),
                    sectorColor: options.sectorColor || new Color(0, 1, 1),
                    time: options.time ?? 0.00,
                    count: options.count ?? 5.0,
                    gradient: options.gradient ?? 0.01,
                },
                source: options.shaderSource ?? '这里是你要写glsl的地方',
            },
            translucent: options.translucent ?? true,
        };
        super(newOptions);
    }
}

看吧,简简单单几行代码就完成了自定义材质的框架,接下来你只需要关心 shaderSource 的代码了。我们程序员应该向雷布斯学习,写代码要像写诗一样优雅,别人舒心,自己也舒心。下面来看看今天的正主——shaderSource

shaderSource 即材质着色器,我们前面的文章有讲过顶点着色器和片元着色器,它们都是使用相同的语言 glsl。虽然前面也曾接着某个功能详细讲解过着色器代码,但总觉得不够浅,不够浅就无法深入,不符合深入浅出的理念,所以今天要从幼儿园讲起。

一个最简单的材质实现:

uniform vec4 color;

czm_material czm_getMaterial(czm_materialInput materialInput)
{
    czm_material material = czm_getDefaultMaterial(materialInput);
    material.diffuse = color.rgb;
    material.alpha = 0.5;
    return material;
}

上面代码实现了一个颜色为 color 透明度为0.5的材质。

下面我们来简单复习下基础知识: 

uniform 传递了颜色变量 color,颜色值是由四通道的向量类型定义的,分别是 rgba,使用 uniform  关键字定义的变量都是通过 uniforms 从外部来传递进来的。

czm_ 是 Cesium 的专属标记,我们一看到它就知道它是系统函数或变量了,czm_getMaterial 就是 Cesium 的材质函数,我们只要实现这个函数就可以完成自定义材质的编写了。

czm_getDefaultMaterial 函数可以获取到默认的材质,那么这个默认的材质是什么样的呢?没错,是一片漆黑。这是因为材质默认的颜色是黑色,默认的透明度为1。那么我们通过修改默认材质的颜色和透明度就可以实现材质自定义了,即修改 diffuse alpha 值。其实材质的神奇之处就在于这里,简简单单两个属性可以衍生出出千变万化来。

下面我们看一下几种简单常用的材质设置。

纹理x轴方向渐变
uniform vec4 color;

czm_material czm_getMaterial(czm_materialInput materialInput)
{
    czm_material material = czm_getDefaultMaterial(materialInput);
    vec2 st = materialInput.st;
    material.diffuse = color.rgb;
    material.alpha = st.s;
    return material;
}

st 即纹理的 xy 值,分量取值范围0-1。我们知道 alpha 取值范围也是0-1,如果我们把 材质的 alpha 值设置成 xy 分量值,会出现什么样的效果呢?如下图。

没错,我们惊喜地发现,我们居然实现了渐变的材质嘞!其实想想原理就很好解释了,纹理从左到右,数值从0到1逐渐增大,对应着透明度也从透明逐渐变为不透明。同学想一想,如果 alpha 值设置成 st.t,会呈现什么样的效果呢?

纹理y轴方向渐变
uniform vec4 color;

czm_material czm_getMaterial(czm_materialInput materialInput)
{
    czm_material material = czm_getDefaultMaterial(materialInput);
    vec2 st = materialInput.st;
    material.diffuse = color.rgb;
    material.alpha = st.t;
    return material;
}

相信同学们都猜到了,材质呈现了从下到上的渐变效果。由此我们可以看出材质的纹理坐标是从左下角向右上角渐进的。

纹理中心向外部渐变

现在我们已经打开了自定材质的大门了,上面我们一不小心就实现了纹理渐变,不过都是从一侧到另一侧渐变的,如果我们想从中心往四周渐变又该如何做呢?说到这个问题,我们已经能联想到那些扫描特效了,几何形状都是圆形的,从中心往四周扩散的材质无疑比较适合圆形。

uniform vec4 color;

czm_material czm_getMaterial(czm_materialInput materialInput)
{
    czm_material material = czm_getDefaultMaterial(materialInput);
    vec2 st = materialInput.st;
    float dis = distance(st, vec2(0.5));
    material.diffuse = color.rgb;
    material.alpha = dis;
    return material;
}

首先我们要确定一下纹理的中心坐标,左下角坐标是(0.0, 0.0),右上角坐标是(1.0, 1.0),那么中心坐标就是(0.5, 0.5)。中心点的 alpha 值设为0,以中心点为圆心,以0.5为半径,绘制一个圆,在这个圆面上的点,距离中心点越远值越大。因此我们就由了解决方案了,就是计算中心点的距离,使用到的函数是 distance(),里面传入起始点参数即可计算出距离。

看上图发现最外圈边缘颜色比较浅,与预想的不太一样。细想一下上面的圆半径是0.5,所以最大值也只能到0.5。那么我们只要将 dis 乘以2即可,得到效果如下图。

在职在矩形上的效果如下图。

纹理外部向中心渐变

这个很好理解,只须将上面的结果反转一下即可,即用1减去距离。

uniform vec4 color;

czm_material czm_getMaterial(czm_materialInput materialInput)
{
    czm_material material = czm_getDefaultMaterial(materialInput);
    vec2 st = materialInput.st;
    float dis = distance(st, vec2(0.5));
    material.diffuse = color.rgb;
    material.alpha = 1.0 - dis * 2.0;
    return material;
}

查看效果图发现,材质在圆形上很完美,但在矩形上出现了奇怪的现象,这白边是哪里来的呢?仔细一想,在矩形材质中最大距离并不是0.5,而是\frac{\sqrt{2}}{2},约等于0.707,这个通过简单的三角函数就能知道了。上述代码出现了负值,所以导致了白边的出现。这时候我们可以使用clamp函数来解决,即当值大于0.707的时候,我们就将数值置为0。

clamp(x, min, max) 函数:返回 min 和 max 之间的值,如果超出范围,则如果 x < min 返回 min,如果 x > max 返回 max。

uniform vec4 color;

czm_material czm_getMaterial(czm_materialInput materialInput)
{
    czm_material material = czm_getDefaultMaterial(materialInput);
    vec2 st = materialInput.st;
    float dis = distance(st, vec2(0.5));
    material.diffuse = color.rgb;
    material.alpha = clamp(1.0 - dis * 2.0, 0.0, 1.0);
    return material;
}

我们来看一下所有材质的效果。

具体实现 

上面讲了自定义材质的原理,其实是已经将自定义材质的具体实现过程都完成了。不过我们开篇的时候提到的要实现雷达扫描的效果的,这个就稍微复杂些了,不过万变不离其宗,我相信只要你看懂了上面的内容,接下来的内容你将毫不费力。

添加圆形

这个算最基础的添加图元操作了,我完全可以省略掉这部分,但本着完整的原则,还是放上来了。直接上代码。

const position = Cartesian3.fromDegrees(80,40);
const modelMatrix = Transforms.eastNorthUpToFixedFrame(position);
const radius = 40000.0;
viewer.scene.primitives.add(new Primitive{
    geometryInstances:[
        new GeometryInstance({
            geometry: new EllipseGeometry({
                center: position,
                semiMajorAxis: radius,
                semiMinorAxis: radius,
            })
        })
    ],
    appearance: new MaterialAppearance({
        material: new RadarScanCircleMaterial({
            color: 'rgb(0,255,50)',
            sectorColor: 'rgb(0,255,50)',
            radians: Math.PI * 3 / 8,
            offset: 0.2
        }),
        flat: false,
        faceForward: false,
        translucent: true,
        closed: false
    }),
    asynchronous: false,
    modelMatrix: modelMatrix
});

添加雷达扫描材质

通过上面的学习,现在我们已经会创建自定义材质了。首先要做的是创建一个雷达扫描材质类,然后加入下面的着色器代码即可。

uniform vec4 color;
uniform vec4 sectorColor;
uniform float width;
uniform float radians;
uniform float offset;

czm_material czm_getMaterial(czm_materialInput materialInput)
{
    czm_material material = czm_getDefaultMaterial(materialInput);
    vec2 st = materialInput.st;
    float dis = distance(st, vec2(0.5));

    float sp = 1.0 / 5.0 / 2.0;
    float m = mod(dis, sp);
    float alpha = step(sp * (1.0 - width * 10.0), m);
    alpha = clamp(alpha, 0.2, 1.0);
    material.alpha = alpha;
    material.diffuse = color.rgb;
    
    // 绘制十字线
    if ((st.s > 0.5 - width / 2.0 && st.s < 0.5 + width / 2.0) || (st.t > 0.5 - width / 2.0 && st.t < 0.5 + width / 2.0)) {
        alpha = 1.0;
        material.diffuse = color.rgb;
        material.alpha = alpha;
    }
    
    // 绘制光晕
    float ma = mod(dis + offset, 0.5);
    if (ma < 0.25){
        alpha = ma * 3.0 + alpha;
    } else{
        alpha = 3.0 * (0.5 - ma) + alpha;
    }                           
    material.alpha = alpha;
    material.diffuse = sectorColor.rgb;

    // 绘制扇区
    vec2 xy = materialInput.st;
    float rx = xy.x - 0.5;
    float ry = xy.y - 0.5;
    float at = atan(ry, rx);
    // 半径
    float radius = sqrt(rx * rx + ry * ry);
    // 扇区叠加旋转角度
    float current_radians = at + radians;
    xy = vec2(cos(current_radians) * radius, sin(current_radians) * radius);
    xy = vec2(xy.x + 0.5, xy.y + 0.5);

    // 扇区渐变色渲染
    if (xy.y - xy.x < 0.0 && xy.x > 0.5 && xy.y > 0.5){
        material.alpha = alpha + 0.2;
        material.diffuse = sectorColor.rgb;
    }

    return material;
}

 我感觉上面的代码注释已经很详细了,结合我前面讲的材质纹理原理,应该非常容易理解。好了,让我们看看最终的效果吧。

小结 

总算是写完啦,简简单单的一篇文章花了我两天时间,效率低到姥姥家了。主要是期间把之前的 SimpleCesium 捡起来了,文章案例都是在上面完成的。三年未更新了,GitHub上面下载下来竟然跑不起来了,要说Cesium也真是可以,新版本改了引用路径之后,连老版本也受影响,索性直接升到最新版本了,一番升级改造算是勉强能看了。看了文章还不会用的同学可以去看看,再不行您就进组织呗,暗号:854943530。

PS

雷达效果是做出来了,要让里面的扇叶转起来才酷嘛。其实很简单,预留参数 radians 就是干这个的,在 preRender 事件中更新这个参数就可以动起来啦,注意,这个角度是弧度。

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐