关于本文的说明

  最近看到国内一位cesium大牛的博客,讲材质material(材质)的,于是对照源码看了下,发现很有研究的意义,首先,源码的项目搭建用的是dojo,所有的样式业务绘制前端都是良好分离,关键是不存在多份拷贝,方便调试,相较于傻瓜式且过度模块化封装的vue而言,这点好很多,不过这些不是本文的重点,本文涉及到的水特效其实只是Cesium原生提供的22种材质中的一种而已,cesium除了定义了22中常用材质,每种都可以自定义渲染属性,且能互相组合使用,还允许用户自定义任意材质,确实很方便。而本文提到的火特效跟材质没啥关系,只是因为水和火总是容易让人联系到一起,既然研究了水渲染,顺便理解下火渲染也无妨。
  Cesium官方给出的22中材质及其uniform属性说明官网链接

水特效

水特效示例代码

以下是从官方Material中删除其余不在本文中关心的材质示例代码后的最简代码:

var worldRectangle;

		function applyWaterMaterial(primitive, scene) {
			//Sandcastle.declare(applyWaterMaterial); // For highlighting in Sandcastle.
			primitive.appearance.material = new Cesium.Material({
				fabric: {
					type: 'Water',
					uniforms: {
						// baseWaterColor:Cesium.Color.RED,
						// blendColor:Cesium.Color.DARKBLUE,
						specularMap: '../images/earthspec1k.jpg',
						normalMap: Cesium.buildModuleUrl('Assets/Textures/waterNormals.jpg'),
						frequency: 10000.0,
						animationSpeed: 0.01,
						amplitude: 1
					}
				}
			});
		}

		function toggleWorldRectangleVisibility() {
			worldRectangle.show = true;
		}

		function createPrimitives(scene) {
			worldRectangle = scene.primitives.add(new Cesium.Primitive({
				geometryInstances: new Cesium.GeometryInstance({
					geometry: new Cesium.RectangleGeometry({
						rectangle: Cesium.Rectangle.fromDegrees(-180.0, -90.0, 180.0, 90.0),
						vertexFormat: Cesium.EllipsoidSurfaceAppearance.VERTEX_FORMAT
					})
				}),
				appearance: new Cesium.EllipsoidSurfaceAppearance({
					aboveGround: false
				}),
				show: false
			}));
		}

		var viewer = new Cesium.Viewer('cesiumContainer');
		var scene = viewer.scene;
		createPrimitives(scene);
		toggleWorldRectangleVisibility();
		applyWaterMaterial(worldRectangle, scene);

水特效属性设置说明

  cesium水材质的uniform中定义包括如下属性:

  • baseWaterColor
    水的颜色
  • blendColor
    从水到非水区域混合时使用的rgba颜色

以上两个颜色如果不设置,默认为:
在这里插入图片描述
如果设置为:
baseWaterColor:Cesium.Color.RED,
blendColor:Cesium.Color.DARKBLUE,
则显示效果如下:
在这里插入图片描述

  • specularMap
    一张黑白图用来作为标识哪里是用水来渲染的贴图,如果不指定,则代表使用该material的primitive区域全部都是水,如果指定全黑色的图,则表示该区域没有水,如果是灰色的,则代表水的透明度,这里一般是指定都是要么有水,要么没有水,而且对于不是矩形的primitive区域,最好定义全是白色,不然很难绘制出一张贴图正好能保证需要的地方有水,不需要的地方没有水
    示例中的贴图如下:
    在这里插入图片描述
    如上图所示,白色区域将会渲染成水面,黑色部分将不会渲染,由于默认叠加了世界底图所以黑色部分保留陆地,这里使用这个贴图的原因是定义的primitive范围为全球范围,正好是一个矩形:
    在这里插入图片描述
    所以我们实际应用中如果只是为了渲染一个自定义不规则图形为水面的话,这里直接赋值一张白色的图片即可。
  • normalMap
    用来生成起伏效果的法线贴图,关于法线贴图可参考
    示例中的贴图如下:
    在这里插入图片描述
    我们可以修改这个图从而生成不一样的波纹效果
  • frequency
    用来控制水浪的波动频率
  • animationSpeed
    用来控制水流速度的数字
  • amplitude
    用来控制水波振幅的数字
  • specularIntensity
    控制镜面反射强度的数字

水特效实际应用代码

  在实际应用中,我们可能多数情况下只是为了展示一个指定polygon区域的水面特效,对吧,所以在理解了每个属性的含义之后,我们只要在此基础上稍加修改即可满足需要。
于是我们这里将primitive的坐标修改成自定义的:

worldRectangle = scene.primitives.add(new Cesium.Primitive({
				geometryInstances: new Cesium.GeometryInstance({
					geometry: new Cesium.PolygonGeometry({
						polygonHierarchy : new Cesium.PolygonHierarchy(
							Cesium.Cartesian3.fromDegreesArray([
								120.0, 40.0,
								120.0, 35.0,
								125.0, 30.0,
								120.0, 30.0,
								118.0, 40.0
							])
						)
					})
				}),
				appearance: new Cesium.EllipsoidSurfaceAppearance({
					aboveGround: false
				}),
				show: false
			}));

然后将specularMap设置去除,或者设置成一张全白色的图即可。效果如下所示:
在这里插入图片描述
放大就是水面特效:
在这里插入图片描述
当然我们还可以自定义其他属性

火特效

  火焰特效和喷泉、喷火等等特效都是粒子特效,cesium对这块有很好的支持,其实就是使用各种属性,只要看看api文档说明即可,其实我个人更愿意花时间多看看材质方面,毕竟内容比较多使用比较复杂,但是无奈这种特效很多客户都喜欢,老是要做,所以只好单独看看熟悉下了。

火特效实现代码

  无论是喷火也好、喷水也好、喷烟也好,随便你要喷什么东西,反正实现的原理都一样,使用粒子效果。
  阅读Cesium官方示例,甚至发现了一些意外的惊喜。不过下面还是言归正传,分析官网粒子效果的实现代码。
首先,粒子效果是在一定时间范围内生成的,所以需要开启cesium的时间动画,即需要设置:viewer.clock.shouldAnimate = true;

//Sandcastle_Begin
var viewer = new Cesium.Viewer('cesiumContainer');

//Set the random number seed for consistent results.
//Cesium.Math.setRandomNumberSeed(3);

//Set bounds of our simulation time
var start = Cesium.JulianDate.fromDate(new Date(2015, 2, 25, 16));
var stop = Cesium.JulianDate.addSeconds(start, 120, new Cesium.JulianDate());

//Make sure viewer is at the desired time.
viewer.clock.startTime = start.clone();
viewer.clock.stopTime = stop.clone();
viewer.clock.currentTime = start.clone();
viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP; //Loop at the end
viewer.clock.multiplier = 1;
viewer.clock.shouldAnimate = true;

//Set timeline to simulation bounds
viewer.timeline.zoomTo(start, stop);

  示例第一步是初始化一个view,然后设置randomNumberSeed,这个主要是用于生成随机数的,本示例中注释掉也没关系,因为没有用到nextRandomNumber函数:
在这里插入图片描述
  接下来是设置动画开始和结束时间start和end,这里应该是下面还需要用到所以单独定义了相应变量,并且赋值clock,其中clockRange设置为LOOP_STOP代表这段动画完成以后时间序列重头开始重新来过,一直循环下去,除了这个枚举值以外,ClockRange还可以定义UNBOUNDED(没有边界一直往后),CLAMPED(到达end后停止),cesium默认clock是UNBOUNDED,所以这里需要单独设置,multiplier属性用来定义每次加多少时间,最后将时间范围定位到开始和结束范围。

  示例第二步是进行数据绑定,设定监听订阅分发,类似vue中的watch和数据绑定,由于Cesium原生用的不是vue,所以这里使用的轻量级的数据绑定Knockout,knockout已经集成到Cesium中,引用了cesium自动就能使用,如果不用cesium也可以在其他应用中直接使用knockout,直接应用原生html原生,不依赖任何插件,以前没遇到过所以单独实验了一把,实践证明,在vue中也绝地可以使用,只是要用原生的input,而不是用其他类似element这样的插件。

var viewModel = {
    emissionRate : 5.0,
    gravity : 0.0,
    minimumParticleLife : 1.2,
    maximumParticleLife : 1.2,
    minimumSpeed : 1.0,
    maximumSpeed : 4.0,
    startScale : 1.0,
    endScale : 5.0,
    particleSize : 25.0
};
Cesium.knockout.track(viewModel);
var toolbar = document.getElementById('toolbar');
Cesium.knockout.applyBindings(viewModel, toolbar);

数据绑定部分代码如下:

Cesium.knockout.getObservable(viewModel, 'emissionRate').subscribe(
    function(newValue) {
        particleSystem.emissionRate = parseFloat(newValue);
    }
);

Cesium.knockout.getObservable(viewModel, 'particleSize').subscribe(
    function(newValue) {
        var particleSize = parseFloat(newValue);
        particleSystem.minimumImageSize.x = particleSize;
        particleSystem.minimumImageSize.y = particleSize;
        particleSystem.maximumImageSize.x = particleSize;
        particleSystem.maximumImageSize.y = particleSize;
    }
);

Cesium.knockout.getObservable(viewModel, 'minimumParticleLife').subscribe(
    function(newValue) {
        particleSystem.minimumParticleLife = parseFloat(newValue);
    }
);

Cesium.knockout.getObservable(viewModel, 'maximumParticleLife').subscribe(
    function(newValue) {
        particleSystem.maximumParticleLife = parseFloat(newValue);
    }
);

Cesium.knockout.getObservable(viewModel, 'minimumSpeed').subscribe(
    function(newValue) {
        particleSystem.minimumSpeed = parseFloat(newValue);
    }
);

Cesium.knockout.getObservable(viewModel, 'maximumSpeed').subscribe(
    function(newValue) {
        particleSystem.maximumSpeed = parseFloat(newValue);
    }
);

Cesium.knockout.getObservable(viewModel, 'startScale').subscribe(
    function(newValue) {
        particleSystem.startScale = parseFloat(newValue);
    }
);

Cesium.knockout.getObservable(viewModel, 'endScale').subscribe(
    function(newValue) {
        particleSystem.endScale = parseFloat(newValue);
    }
);

对应html绑定数据方法:

<div id="toolbar">
    <table>
        <tbody>
        <tr>
            <td>Rate</td>
            <td>
                <input type="range" min="0.0" max="100.0" step="1" data-bind="value: emissionRate, valueUpdate: 'input'">
                <input type="text" size="5" data-bind="value: emissionRate">
            </td>
        </tr>
        ...

  什么意思呢,个人理解:粒子效果需要用viewModel其中的一些属性来设置,比如emissionRate,如果我们想从界面中通过控制滑块、输入框这些输入组件的值动态控制粒子效果的显示结果,就需要用到数据绑定,Cesium.knockout.getObservable这个函数是将界面控制导致的数据改变写入ParticleSystem响应属性中,而最后一段html代码是将页面控制和数据绑定起来,整体用applybinding进行viewmodel和html控件绑定,大致示意图如下所示:
在这里插入图片描述

为了便于理解,实验代码如下所示,比如我想通过页面的一个滑块控制图层的不透明度,滑块从0-100代表图层不透明度从0-100变化,于是乎代码简写如下:
html代码:

 <el-form id="mapsettingForm">
            <el-form-item label="透明度:" prop="opacity">
                <input type="range" min="0" max="100" step="1" data-bind="value: opacity, valueUpdate: 'input'">
            </el-form-item>
 </el-form>

js代码:

Cesium.knockout.track(this.mapsettingForm);
			var toolbar = document.getElementById('mapsettingForm');
			Cesium.knockout.applyBindings(this.mapsettingForm, toolbar);

			let that=this;
			Cesium.knockout.getObservable(this.mapsettingForm, 'opacity').subscribe(
				function(newValue) {
					if (that.mapnode)
						changeOpacityFunc(newValue);
				}
			);

这样就可以了,同样的功能使用vue框架实现如下:
html代码:

 <el-form id="mapsettingForm" :model="mapsettingForm">
            <el-form-item label="透明度:" prop="opacity">
                <el-slider data-bind="value: opacity" :min="0" :max="100"></el-slider>
            </el-form-item>
 </el-form>

js代码:

watch:{
'mapsettingForm.opacity'(newVal, oldVal) {
				if (this.mapnode)
					changeOpacityFunc(newValue);
			},
}

  相较而言,vue代码似乎更简洁,对吧,因为vue底层帮助我们封装了实现,所以数据可以用:model这样的写法绑定,而不用data-bind借助knockout绑定,另外,watch可以代替getObservable.subscribe函数。

  示例第三步是定义一些变量和函数,其实是为entity和粒子系统服务的,暂且不管,后面用到时再来看比较清晰,直接进入第四步骤是定义一个移动的小车,为了展示需要,该示例显示效果为小车在一条康庄大道上移动,然后呢车尾不断地喷射彩色污染烟雾。所以这里需要首先定义个移动的小车:

var pos1 = Cesium.Cartesian3.fromDegrees(-75.15787310614596, 39.97862668312678);
var pos2 = Cesium.Cartesian3.fromDegrees(-75.1633691390455, 39.95355089912078);
var position = new Cesium.SampledPositionProperty();

position.addSample(start, pos1);
position.addSample(stop, pos2);

var entity = viewer.entities.add({
    availability : new Cesium.TimeIntervalCollection([new Cesium.TimeInterval({
        start : start,
        stop : stop
    })]),
    model : {
        uri : '../../SampleData/models/CesiumMilkTruck/CesiumMilkTruck-kmc.glb',
        minimumPixelSize : 64
    },
    viewFrom: new Cesium.Cartesian3(-100.0, 0.0, 100.0),
    position : position,
    orientation : new Cesium.VelocityOrientationProperty(position)
});
viewer.trackedEntity = entity;

  这里有个有趣的应用,是给entity设定一个动态的position,以前自己做过的应用全部都是给entity设定静态的地址,这里发现其实可以给它设定动态的地址,方法是使用SampledPositionProperty,关于该类,参考一篇文章写的非常详细,再对照看官方api文档基本够了,反正自己看完后觉得涨知识了,原来自己以前用的那种原始的方法在每一帧动态计算简直low爆。它的好处在于,我们只需要定义时间点和位置,系统可以自动内插出中间的各个时间点对应的位置,无需我们手动去计算控制了,最出彩的地方是下面orientation属性竟然可以根据position计算出对应的方向,我擦,简直不要太爽,要知道,以前这些值都是自己写各种矩阵变换去实时计算出来的啊,这里面不需要费任何代码系统自动搞定。
VelocityOrientationProperty的官方定义说明
SampledPositionProperty官方定义说明

  为了验证自己的理解,于是自己手动创建一个entity,不设置特效也不设置跟踪模式,只是一个白色小点position,然后看看使用sampledposition的移动情况,简化代码如下,验证结果如己所愿,一个小白点场景里做循环运动:

        var viewer = new Cesium.Viewer('cesiumContainer');
        //Set bounds of our simulation time
		var start = Cesium.JulianDate.fromDate(new Date(2015, 2, 25, 16));
		var mid = Cesium.JulianDate.addSeconds(start, 60, new Cesium.JulianDate());
		var stop = Cesium.JulianDate.addSeconds(start, 120, new Cesium.JulianDate());
        //Make sure viewer is at the desired time.
		viewer.clock.startTime = start.clone();
		viewer.clock.stopTime = stop.clone();
		viewer.clock.currentTime = start.clone();
		viewer.clock.clockRange = Cesium.ClockRange.LOOP_STOP; //Loop at the end
		viewer.clock.multiplier = 1;
		viewer.clock.shouldAnimate = true;
        //Set timeline to simulation bounds
		viewer.timeline.zoomTo(start, stop);
		var pos1 = Cesium.Cartesian3.fromDegrees(-75.15787310614596, 39.97862668312678);
		var pos2 = Cesium.Cartesian3.fromDegrees(-75.1633691390455, 39.95355089912078);
		var pos3 = Cesium.Cartesian3.fromDegrees(-75.1643691390455, 39.95355089912678);
		var position = new Cesium.SampledPositionProperty();
		position.addSample(start, pos1);
		position.addSample(mid, pos3);
		position.addSample(stop, pos2);
		var entity = viewer.entities.add({
			position: position,
			point: {
				pixelSize: 10,
				color: Cesium.Color.WHITE,
			}
		});
		viewer.zoomTo(entity);

  实际显示效果如下所示,注意图中白色小圆点在移动:
在这里插入图片描述

  示例代码中还有一处之前自己没有使用的功能,即设置model的minimumPixelSize,这样可以保证model的最小像素大小,即若地图缩小了,保证model本身并不会缩小,可以在小比例尺地图场景下看到该模型。
大比例尺下显示:
在这里插入图片描述
小比例尺下显示:
在这里插入图片描述

  示例程序的第四步骤就是定义粒子系统了,程序如下所示:

var scene = viewer.scene;
var particleSystem = scene.primitives.add(new Cesium.ParticleSystem({
    image : '../../SampleData/smoke.png',

    startColor : Cesium.Color.LIGHTSEAGREEN.withAlpha(0.7),
    endColor : Cesium.Color.WHITE.withAlpha(0.0),

    startScale : viewModel.startScale,
    endScale : viewModel.endScale,

    minimumParticleLife : viewModel.minimumParticleLife,
    maximumParticleLife : viewModel.maximumParticleLife,

    minimumSpeed : viewModel.minimumSpeed,
    maximumSpeed : viewModel.maximumSpeed,

    imageSize : new Cesium.Cartesian2(viewModel.particleSize, viewModel.particleSize),

    emissionRate : viewModel.emissionRate,

    bursts : [
        // these burst will occasionally sync to create a multicolored effect
        new Cesium.ParticleBurst({time : 5.0, minimum : 10, maximum : 100}),
        new Cesium.ParticleBurst({time : 10.0, minimum : 50, maximum : 100}),
        new Cesium.ParticleBurst({time : 15.0, minimum : 200, maximum : 300})
    ],

    lifetime : 16.0,

    emitter : new Cesium.CircleEmitter(2.0),

    emitterModelMatrix : computeEmitterModelMatrix(),

    updateCallback : applyGravity
}));

  其中有一些涉及到其他函数和代码,为了便于理解,这里先不考虑,先从总体上浏览一遍:

  • 粒子效果是通过primitive来加载,既然是通过primitive,那么必然存在位置属性信息(废话),然而我们看到这里并没有定义确切的位置信息,其实位置信息赋值在particleSystem的modelmatrix属性中,这一部分后面再看;
  • 查看ParticleSystem的定义全部都是optional,也就是说全部都可以不用赋值:
    在这里插入图片描述
  • 粒子系统的设置属性主要包括如下几个:
  1. 常规控制:show、updateCallback、modelMatrix、loop
  2. 关于emitter的设置,具体包括:emitter、emitterModelMatrix、emissionRate、bursts
  3. 关于scale的设置:scale、startScale、endScale
  4. 关于color的设置:color、startColor、endColor
  5. 关于image的设置:image、imageSize、minimunImageSize、maximunImageSIze
  6. 关于speed的设置:speed、minimunSpeed、maximunSpeed
  7. 关于lifetime的设置:lifetime、particleLift、minimunParticleLife、maximumParticleLife
  8. 关于mass的设置:mass、minimunMass、maximunMass

火特效属性设置说明

位置的设置

  位置设置在scene的preUpdate监听事件中:

viewer.scene.preUpdate.addEventListener(function(scene, time) {
    particleSystem.modelMatrix = computeModelMatrix(entity, time);

    // Account for any changes to the emitter model matrix.
    particleSystem.emitterModelMatrix = computeEmitterModelMatrix();

    // Spin the emitter if enabled.
    if (viewModel.spin) {
        viewModel.heading += 1.0;
        viewModel.pitch += 1.0;
        viewModel.roll += 1.0;
    }
});

scene的相关事件如下所示:
在这里插入图片描述
  于是就有在preUpdate的事件监听程序中,设置particleSystem的modelMatrix从而改变粒子效果的位置,其中使用了entity的computeModelMatrix方法,该方法可以取到entity的模型矩阵:从物体坐标到世界坐标,包括平移缩放旋转。

重力(Gravity)的控制

  通过设定particle的updateCallback属性进行设置,在这个事件回调函数中,传入两个参数:particle和dt,其中dt是距离上一次调用的时间间隔,所以用的是微分形式dt,计算方式如下所示:

var gravityScratch = new Cesium.Cartesian3();

function applyGravity(p, dt) {
    // We need to compute a local up vector for each particle in geocentric space.
    var position = p.position;

    Cesium.Cartesian3.normalize(position, gravityScratch);
    Cesium.Cartesian3.multiplyByScalar(gravityScratch, viewModel.gravity * dt, gravityScratch);

    p.velocity = Cesium.Cartesian3.add(p.velocity, gravityScratch, p.velocity);
}

  分析其中代码,发现这里使用的是带有惯性的速度叠加,因为首先进行了position的归一化,在归一化的基础上叠加重力的加速度:v1=v0+at,其中加速度为g,示例代码中g=0,因为模拟没有重力作用的烟雾效果。

粒子喷射间歇的强度的控制

  使用的是burst,注意,这里burst和lifetime是有关系的,如果没有设置lifetime,只是设置了burst数组,则系统只在burst数组内设定的时间点内按照对应munimun和maxmun设定进行爆发粒子,若设置了lifetime也设置了burst数组,则系统会按照lifetime时间进行周期爆发粒子,所以lifetime最好设定为burstarray时间点最长的时间加1,这样一个周期可以覆盖所有的burst,且每一秒会按照emissionRate进行分次爆发,致使每一次的爆发数量较burst数组内部设定的进行平均。

其他属性设置

  其他属性可以从字面中理解,比如image,size,color等。系统中定义了粒子image,imagesize,开始和结束颜色和缩放比例,还是最大最小速度,粒子发射频率,和emitter定义的发射类别。可以通过修改属性值查看到不同配置引发的区别。

总结感想

  个人很喜欢“权力的游戏”中珊莎的一句话:没有人能够保护我。感觉这种能够适应违背个人价值观的“逆生长”成长方式的人很不简单,人按照自己的本性去成长,虽然同样需要遭遇挫折和痛苦,但是这比不按照本性生长好在身心统一。程序的世界远比真实的世界简单,牺牲掉的却是多样性和随机性带来的惊喜。正因如此, 我觉得自己老是容易迷失在程序带来的秩序性和真实世界带来的丰富情感中,徘徊在两者之间,举棋不定,不知道该往哪个方向投入精力多一点或者少一些,也许是因为固执,老是试图理解却无法做到,但是没关系,不理解那就乐在其中吧。

Logo

前往低代码交流专区

更多推荐