Cesium官方教程10--高级粒子特效

原文地址:https://cesiumjs.org/tutorials/Particle-Systems-More-Effects-Tutorial/

高级粒子系统特效

这篇教程学习更多的效果,包括天气和火箭推进器。
如果没有学习过粒子系统基础知识,请学习这篇教程 粒子系统介绍 .

天气

下雪
下雨

最开始下雪的教程是来自 追踪圣诞老人项目里的实现。

步骤

我们即将介绍如何做下雪效果,然后怎么把下雪变为下雨效果。
我们将给每个粒子添加雪花图片,然后在updateParticle函数里定义每个粒子的移动属性和其他动态属性。

粒子图片
关于表示粒子的图片,我们可以从任一纯色(红,绿,白等)图片开始。我们使用png格式,因为它支持透明度,所以透明的部分不可见。在本教程里,下面三张png图片用来创建粒子效果。最左侧的图片用来表示下雨;中间的图片用来表示下雪;右侧的图片我们在上一教程里已经用到了。

粒子图片

一旦我们选定了一张图片,我们可以在Cesium里修改他的外观,后面会解释。比如,左侧圆形粒子图片将会变成前面效果图里长长的,蓝色的更像雨滴。这个火的图片会变成绿色的树叶,黄色的电火花,甚至白色的瀑布下面的浪花和泡沫。这里需要创造力。

除此之外,雨雪效果里我们设置了最开始透明度为0,最后透明度为可见性要求的值。也就是说粒子在创建后是完全不透明的。这就是为什么在粒子的生成位置不会突然出现粒子的原因。

更新函数
使用更新函数,我们能更自由的去控制粒子的分布、移动、以及可视化。这里可以简单的修改粒子的颜色、图片大小、生命周期等等。使用这个函数根据你需求或多或少的修改相关属性。甚至在这个函数内部可以基于它和相机的距离修改粒子属性(下面是示例代码),也可以相对某个模型或者地球去计算。

下面是我们的跟新函数代码:

// 下雪
var snowGravityScratch = new Cesium.Cartesian3();
var snowUpdate = function(particle, dt) {
    snowGravityScratch = Cesium.Cartesian3.normalize(particle.position, snowGravityScratch);
    snowGravityScratch = Cesium.Cartesian3.multiplyByScalar(snowGravityScratch,
                                                            Cesium.Math.randomBetween(-30.0, -300.0),
                                                            snowGravityScratch);
    particle.velocity = Cesium.Cartesian3.add(particle.velocity, snowGravityScratch, particle.velocity);

    var distance = Cesium.Cartesian3.distance(scene.camera.position, particle.position);
    if (distance > (snowRadius)) {
        particle.endColor.alpha = 0.0;
    } else {
        particle.endColor.alpha = snowSystem.endColor.alpha / (distance / snowRadius + 0.1);
    }
};

第一部分代码,使粒子像受重力影响一样的落下。

这个代码还增加一个功能,检测粒子距离相机的距离,距离越远,粒子越模糊(透明度越大),就像一种随距离加重的雾效果。

其他天气效果
除了随着距离渐隐的粒子效果,这个示例还把 雾 和 大气效果设置成匹配 我们正在模拟的天气效果 。

hueShift 属性控制了光谱颜色(the color along the color spectrum.)。saturationShift 属性控制了实际效果的明暗分界线(how much color versus black and white the visual actually entails)。brightnessShift 属性控制了颜色对比有多强烈(how vivid the colors are)。
雾的密度(density)属性控制了雾颜色覆盖在地球上有多浓厚。minimumBrightness属性设置了雾颜色明亮度的最小值,小于这个值让雾彻底覆盖(acts as a way to darken the fog)。

scene.skyAtmosphere.hueShift = -0.8;
scene.skyAtmosphere.saturationShift = -0.7;
scene.skyAtmosphere.brightnessShift = -0.33;

scene.fog.density = 0.001;
scene.fog.minimumBrightness = 0.8;

上面的雪天,大气颜色变得更黑,几乎没有颜色;雾是非常浓的白色。

最终效果

因为效果完全不同,我们创建两个不同的粒子系统,一个模拟下雪,一个模拟下雨。

下雪和下雨

下雪
下面的代码使用一个基于中心位置(相机位置)的球体发射器去创建粒子系统。另外,每个粒子的图片大小是随机的,在给定大小和两倍大小之间随机,这样粒子更加多种多样。
这个雪的粒子系统有下面这些 和 前面我们讨论过的所有属性:

var snowParticleSize = scene.drawingBufferWidth / 100.0;
var snowRadius = 100000.0;

var snowSystem = new Cesium.ParticleSystem({
    modelMatrix : new Cesium.Matrix4.fromTranslation(scene.camera.position),
    minimumSpeed : -1.0,
    maximumSpeed : 0.0,
    lifetime : 15.0,
    emitter : new Cesium.SphereEmitter(snowRadius),
    startScale : 0.5,
    endScale : 1.0,
    image : "../../SampleData/snowflake_particle.png",
    emissionRate : 7000.0,
    startColor : Cesium.Color.WHITE.withAlpha(0.0),
    endColor : Cesium.Color.WHITE.withAlpha(1.0),
    minimumImageSize : new Cartesian2(snowParticleSize, snowParticleSize),
    maximumImageSize : new Cartesian2(snowParticleSize * 2.0, snowParticleSize * 2.0),
    updateCallback : snowUpdate
});
scene.primitives.add(snowSystem);

下雨
下雨的粒子系统和下雪的很接近,只有一点点不同:
和下雪一样,下面的代码也是创建了一个基于中心位置(相机位置)的球体发射器的粒子系统。可是,我们用了不同的图片表示雨滴, circular_particle.png,我们把它着上蓝色,并垂直拉长跟想雨滴。和雪不太一样,图片大小不需要随机,而是和imageSize属性一致,这里设置高度是宽度的2倍。

rainSystem = new Cesium.ParticleSystem({
    modelMatrix : new Cesium.Matrix4.fromTranslation(scene.camera.position),
    speed : -1.0,
    lifetime : 15.0,
    emitter : new Cesium.SphereEmitter(rainRadius),
    startScale : 1.0,
    endScale : 0.0,
    image : "../../SampleData/circular_particle.png",
    emissionRate : 9000.0,
    startColor :new Cesium.Color(0.27, 0.5, 0.70, 0.0),
    endColor : new Cesium.Color(0.27, 0.5, 0.70, 0.98),
    imageSize : new Cesium.Cartesian2(rainParticleSize, rainParticleSize * 2),
    updateCallback : rainUpdate
});
scene.primitives.add(rainSystem);

此外,下雨模拟的更新函数,有一个小小不同,雨滴的下落速度比雪花的速度快多了。下面代码里我们对重力乘了一个倍率去模拟这个速度,我们也无需修改particle.velocity 而是直接修改particle.position

rainGravityScratch = Cesium.Cartesian3.normalize(particle.position, rainGravityScratch);
rainGravityScratch = Cesium.Cartesian3.multiplyByScalar(rainGravityScratch,
                                                        -1050.0,
                                                        rainGravityScratch);

particle.position = Cesium.Cartesian3.add(particle.position, rainGravityScratch, particle.position);

最后,确保整个环境和场景匹配,我们修改大气和雾效果和下雨天匹配。下面代码修改为深蓝色天空,还有一点薄雾。

scene.skyAtmosphere.hueShift = -0.97;
scene.skyAtmosphere.saturationShift = 0.25;
scene.skyAtmosphere.brightnessShift = -0.4;

scene.fog.density = 0.00025;
scene.fog.minimumBrightness = 0.01;

如果需要了解多一些,请查看 Sandcastle中雨雪示例.

彗星和火箭尾焰

彗星尾
火箭

使用多个粒子系统

天气系统里仅仅需要一个粒子系统,为了创建火箭尾焰效果,我们需要多个粒子系统。示例中每个位置的一圈粒子实际是一个完整的粒子系统。也就是说我们创建了一圈粒子系统,每个系统发射的粒子都是从喷发位置 向外发射。这就让我们更好的控制了整体系统的移动。一个简单的可视化调试手段是设置cometOptions.numberOfSystems为2,设置cometOptions.colorOptions 仅仅包含两种颜色,效果就像下面的图片展示的。这样就更容易跟踪每个系统创建的粒子的运行轨迹。

两个粒子系统

为了系统的不同设置,我们创建了了火箭示例和彗星示例的不同配置数组。

var rocketSystems = [];
var cometSystems = [];

此外,为了便与组织程序,同时创建了两个不同的配置对象。一个彗星版本,另一个是火箭版本。不同的初始化个数,不同的偏移位置等等配置参数导致了两个效果的巨大差异。

var cometOptions = {
    numberOfSystems : 100.0,
    iterationOffset : 0.003,
    cartographicStep : 0.0000001,
    baseRadius : 0.0005,

    colorOptions : [{
        red : 0.6,
        green : 0.6,
        blue : 0.6,
        alpha : 1.0
    }, {
        red : 0.6,
        green : 0.6,
        blue : 0.9,
        alpha : 0.9
    }, {
        red : 0.5,
        green : 0.5,
        blue : 0.7,
        alpha : 0.5
    }]
};

var rocketOptions = {
    numberOfSystems : 50.0,
    iterationOffset :  0.1,
    cartographicStep : 0.000001,
    baseRadius : 0.0005,

    colorOptions : [{
        minimumRed : 1.0,
        green : 0.5,
        minimumBlue : 0.05,
        alpha : 1.0
    }, {
        red : 0.9,
        minimumGreen : 0.6,
        minimumBlue : 0.01,
        alpha : 1.0
    }, {
        red : 0.8,
        green : 0.05,
        minimumBlue : 0.09,
        alpha : 1.0
    }, {
        minimumRed : 1,
        minimumGreen : 0.05,
        blue : 0.09,
        alpha : 1.0
    }]
};

此外,每个的colorOptions是一个数组,包含了随机颜色,那么效果更加随机化。这就是说不是采用一个固定的初始化颜色,而是依据当前正在创建的粒子系统的序号来决定用哪个颜色。下面代码里,i表示当前的遍历序号。

var color = Cesium.Color.fromRandom(options.colorOptions[i % options.colorOptions.length]);

开始

使用下面的代码初始化每个系统

function createParticleSystems(options, systemsArray) {
    var length = options.numberOfSystems;
    for (var i = 0; i < length; ++i) {
        scratchAngleForOffset = Math.PI * 2.0 * i / options.numberOfSystems;
        scratchOffset.x += options.baseRadius * Math.cos(scratchAngleForOffset);
        scratchOffset.y += options.baseRadius * Math.sin(scratchAngleForOffset);

        var emitterModelMatrix = Cesium.Matrix4.fromTranslation(scratchOffset, matrix4Scratch);
        var color = Cesium.Color.fromRandom(options.colorOptions[i % options.colorOptions.length]);
        var force = forceFunction(options, i);

        var item = viewer.scene.primitives.add(new Cesium.ParticleSystem({
            image : getImage(),
            startColor : color,
            endColor : color.withAlpha(0.0),
            particleLife : 3.5,
            speed : 0.00005,
            imageSize : new Cesium.Cartesian2(15.0, 15.0),
            emissionRate : 30.0,
            emitter : new Cesium.CircleEmitter(0.1),
            bursts : [ ],
            lifetime : 0.1,
            forces : force,
            modelMatrix : particlesModelMatrix,
            emitterModelMatrix : emitterModelMatrix
        }));
        systemsArray.push(item);
    }
}

下来过一遍这个粒子系统创建函数,options 表示我们要创建一个彗星尾焰或者火箭尾焰。就像前面提到得,systemsArray 保存了依据输入的options创建的所有粒子系统。

两个尾焰非常相似,除了color和force之外其他的配置都相同。另外,emitterModelMatrix 也是对每个粒子系统都完全不同,在我们创建的圆环上做一个旋转偏移,那么它产生的粒子和前一个粒子系统产生的粒子会有一点点偏移。
我们没有加载一个图片文件。而是使用 HTML canvas直接绘制了一张图片。 尽管这里我们只是绘制一个圆圈,但是这种方法非常有扩展性。比如,给 getImage增加一个当前遍历序号的参数,依据这个参数做一个小小的改变,那就会产生不同的可视化效果。

从零开始创建粒子图片

既然我们已经有了思路,那就实现这个过程。不像前面直接加载图片,我们用代码来创建图片,使用代码还能实现更多的方法。

var particleCanvas;
function getImage() {
    if (!Cesium.defined(particleCanvas)) {
        particleCanvas = document.createElement('canvas');
        particleCanvas.width = 20;
        particleCanvas.height = 20;
        var context2D = particleCanvas.getContext('2d');
        context2D.beginPath();
        context2D.arc(8, 8, 8, 0, Cesium.Math.TWO_PI, true);
        context2D.closePath();
        context2D.fillStyle = 'rgb(255, 255, 255)';
        context2D.fill();
    }
    return particleCanvas;
}

把粒子添加到系统里

准备好,我们开始最关键的一步,让粒子动起来。下面的代码是我们要在updateCallback函数内实现的:

var func = function(particle) {
    scratchCartesian3 = Cesium.Cartesian3.normalize(particle.position, new Cesium.Cartesian3());
    scratchCartesian3 = Cesium.Cartesian3.multiplyByScalar(scratchCartesian3, -1.0, scratchCartesian3);

    particle.position = Cesium.Cartesian3.add(particle.position, scratchCartesian3, particle.position);

    scratchCartographic = Cesium.Cartographic.fromCartesian(particle.position,
                                                            Cesium.Ellipsoid.WGS84,
                                                            scratchCartographic);

    var angle = Cesium.Math.PI * 2.0 * iterationOffset / options.numberOfSystems;
    iterationOffset += options.iterationOffset;
    scratchCartographic.longitude += Math.cos(angle) * options.cartographicStep;
    scratchCartographic.latitude += Math.sin(angle) * options.cartographicStep;

    particle.position = Cesium.Cartographic.toCartesian(scratchCartographic);
};

但是,这是什么?这个函数和设置到粒子系统里的回调函数不同。在创建粒子系统部分,我们设置 force参数用了 var force = forceFunction(options, i);。这个其实调用了一个辅助函数,辅助函数内部返回了实际的更新函数。

var scratchCartesian3 = new Cesium.Cartesian3();
var scratchCartographic = new Cesium.Cartographic();
var forceFunction = function(options, iteration) {
    var iterationOffset = iteration;
    var func = function(particle) {
        scratchCartesian3 = Cesium.Cartesian3.normalize(particle.position, new Cesium.Cartesian3());
        scratchCartesian3 = Cesium.Cartesian3.multiplyByScalar(scratchCartesian3, -1.0, scratchCartesian3);

        particle.position = Cesium.Cartesian3.add(particle.position, scratchCartesian3, particle.position);

        scratchCartographic = Cesium.Cartographic.fromCartesian(particle.position,
                                                                Cesium.Ellipsoid.WGS84,
                                                                scratchCartographic);

        var angle = Cesium.Math.PI * 2.0 * iterationOffset / options.numberOfSystems;
        iterationOffset += options.iterationOffset;
        scratchCartographic.longitude += Math.cos(angle) * options.cartographicStep;
        scratchCartographic.latitude += Math.sin(angle) * options.cartographicStep;

        particle.position = Cesium.Cartographic.toCartesian(scratchCartographic);
    };
    return func;
};

我们这么做有两个原因。首先,在 JavaScript语言里,虽然可以在for循环里内创建函数,但是强烈不推荐 这么做。其次,我们粒子更新函数需要访问迭代器,通过它计算合适的旋转偏移(根据angleiterationOffset参数计算)(这里实际利用了js语言的闭包特性)。为了解决这些问题,我们创建一个辅助函数,在它内部返回了一个适合的更新函数。

解析这个 Force Function

updateCallback 函数和 forceFunction函数实际都干了什么? createParticleSystems的时候,我们沿着圆形偏移创建了每个粒子系统,同时我们也希望,当粒子从他们的初始位置移动的时候,也是也是沿着圆形偏移的方向来再偏移。
这个粒子的迭代偏移不仅仅是创建了圆形旋转效果,而其是控制了这个圆形旋转效果的平滑度,就像彗星和火箭的可视化效果对比。不仅仅是依据当前角度的cosine和sin计算一个位置,我们实际上跌加了前一个位置。因此,太小的迭代迭代偏移也不足以调整这个角度,让半径稳定的变大才能保证系统的连续性。想法,更大的迭代偏移将会使角度更快的增加到原始位置。这么做才能做出来很密集形的圆柱状喷射效果。 (这块我读起来也很费解,我的理解大概是说,在火箭尾端构造了一圈粒子系统,但是这些粒子系统并不是向下喷射,而是说一边向下,一边旋转,所以不同粒子系统里的粒子实际在更新位置的时候也会考虑它在圆圈的位置,这种旋转下来的效果,让粒子看起来更密集,如果不这么做,那么这一圈无论增加多少粒子系统,总是会有一些缝隙,效果不好)

在这个教程里,我们大量使用了sine和cosine函数来生成圆圈效果。可是,用户也可以扩展一下,做成各种形状,比如 Lissajous curve, Gibbs phenomenon, 甚至 square wave 。另外,用户也可以不用三角函数,而是基于位置的噪音函数来控制粒子的位置,那样也许更有趣。这样将是非常有创意的。

相对定位

火箭尾焰和彗星尾

就像我们在上一篇粒子系统教程,现在我们已经做出来了效果,现在要把它合并到合适的位置,飞机的尾巴。因为我们的粒子系统是垂直的,为了得到相对于飞机的合适位置,我们需要使用 particleOffset属性做一个细微偏移。使用particlesModelMatrix 当作每个系统的全局位置矩阵。如同createParticleSystems 函数里,对于每一个我们创建的粒子系统,我们使用emitterModelMatrix 来体现它在发射圆圈上的相对偏移位置。

//  设置飞机的位置
var planePosition = Cesium.Cartesian3.fromDegrees(-75.59777, 40.03883, 800.0);
var particlesOffset = new Cesium.Cartesian3(-8.950115473940969, 34.852766731753945, -30.235411095432937);

// 设置粒子系统的相对位置
var transl = Cesium.Matrix4.fromTranslation(particlesOffset, new Cesium.Matrix4());
var translPosition = Cesium.Matrix4.fromTranslation(planePosition, new Cesium.Matrix4());
var particlesModelMatrix = Cesium.Matrix4.multiplyTransformation(translPosition, transl, new Cesium.Matrix4());

更多可以参考Sandcastle 中关于尾焰的粒子.

更多示例代码:

推荐阅读更多精彩内容