一、原理
体积云与普通材质云最大的区别就是体积云有厚度(体积感),能够进入云中
图为普通材质云 效果可见进阶篇中shadertory着色器一节
图为体积云 本章需要实现的效果
关于体积云更多的知识及原理可以参考以下文章,大部分都是基于游戏引擎的,因为体积云最早就是出现在游戏引擎中。
https://zhuanlan.zhihu.com/p/501039307
https://zhuanlan.zhihu.com/p/645281439
https://zhuanlan.zhihu.com/p/622654876
https://zhuanlan.zhihu.com/p/640248737
https://juejin.cn/post/6844904054955458573
也可以百度搜索 体积云 查看更多信息。目前实现体积云最常用的方式是云图噪声+光线步进。
通过云图噪声生成云的基本形状,然后通过光线步进采样云的密度
1、云图噪声:云图噪声主要用来生成云的形状,噪声的生成可以通过glsl实时生成,也可以通过工具将其生成为2D/3D纹理数据,方便加载使用。比如 Shadertoy柏林+沃利噪声。
2、光线步进:因为云的形状是不规则的,所以无法使用简单的几何算法判断交点,而光线步进则可以很好地解决云的求交问题。因为光线步进的原理是模拟光的前进,所以光从哪儿前进,每次前进多少,最多前进多远这些参数的设置对程序的性能有很大影响。
对于局部的体积云,一般使用一个Box来表示云的范围,为了提高程序性能,光线步进一般只在该Box中进行,所以对于每条光线,首先需要获取其在Box上的起点和终点。
Cesium实现体积云的方式:体积云不是实体,所以是没有顶点信息的,因此我们只能通过片元着色器来实现,在Cesium中实现体积云有两种方式:
1、基于Primitive:基于Primitive的方式可见“体渲染”相关章节的体渲染实现,此种方式是通过将体内的计算结果显示到Geometry表面上来,所以虽然看起来像体,其实还是Geometry表面渲染。
2、基于后处理:基于后处理实现体渲染,更接近真实的体渲染效果,但是此方式需要在片元着色器中还原世界坐标,相对麻烦一点,不过在后处理中还原世界坐标的相关知识,已经在进阶篇中介绍过,假设您还没有Cesium后处理相关知识,可以先参考进阶篇。
本章优先使用后处理进行体积云的实现,学完后您也可以使用Primitive方法进行实现。要实现体积云,我们可以先参考游戏引擎的相关代码,作者实现体积云也是参考的其他引擎的代码,所以本章的重点在于讲解在Cesium的整体实现思路,至于涉及到的着色器里面的计算原理,作者也是shader菜鸡,也只能看懂大概的执行过程,至于里面的一些数学计算方法也是懵逼得很,所以涉及到的着色器计算不会一一讲解。
二、后处理实现
要在Cesium后处理实现体积云(局部),因为前面我们说了,局部体积云一般是在一个Box内进行渲染,所以我们要先知道如何在后处理中绘制一个Box。首先我们回想一下使用Geometry绘制一个Box,一般需要知道Box的坐标原点,然后是Box的大小信息(长、宽、高),我们先假定Box的坐标为-75.59670696331766, 40.0387958759308, 90.62678445553416,长宽高都为20。
let entity = new Cesium.Entity({
position:position,
box:{
dimensions: new Cesium.Cartesian3(20.0, 20.0, 20.0),
material:Cesium.Color.BLUE,
}
})
viewer.entities.add(entity);
在后处理中要绘制一个同样的Box,我的思路是这样的:首先以Box中心点建立一个局部坐标系,每个片元还原到世界坐标,然后转到这个局部坐标系,通过判断坐标值的大小就可以判断这个片元是否在该Box内,如果在内,就设置颜色为蓝色。我们按照思路编写代码,因为要转坐标系,首先我们通过Box的原点坐标建立一个局部坐标系:
//矩阵
let transform = Cesium.Transforms.eastNorthUpToFixedFrame(position);
//逆矩阵
let inverse = Cesium.Matrix4.inverse(transform, new Cesium.Matrix4());
然后将次坐标系信息传入后处理着色器,着色器中每个片元坐标先还原为世界坐标,然后转到该坐标系下,最后进行坐标数值比较。
let shader=`
uniform sampler2D colorTexture;
uniform sampler2D depthTexture;
in vec2 v_textureCoordinates;
uniform mat4 inverse;
void main(){
out_FragColor = texture(colorTexture, v_textureCoordinates);
vec4 rawDepthColor = texture(czm_globeDepthTexture, v_textureCoordinates);
float depth = czm_unpackDepth(rawDepthColor);
if (depth == 0.0) {
depth = 1.0;
}
vec4 eyeCoordinate4 = czm_windowToEyeCoordinates(gl_FragCoord.xy, depth);
vec3 eyeCoordinate3 = eyeCoordinate4.xyz/eyeCoordinate4.w;
vec4 worldCoordinate4 = czm_inverseView * vec4(eyeCoordinate3,1.) ;
vec3 worldCoordinate = worldCoordinate4.xyz / worldCoordinate4.w;
vec4 local= inverse * vec4(worldCoordinate,1.);
if(local.x>-20.&&local.x<20.&&local.y>-20.&&local.y<20.&&local.z>-20.&&local.z<20.){
out_FragColor=vec4(0.,0.,1.,1);
}
}
`;
let stage = new Cesium.PostProcessStage({
fragmentShader: shader,
uniforms: {
inverse: inverse
}
});
在代码中,我们将位于Box内的片元都设置为蓝色,运行代码看看结果
从结果中,我们可以看到,虽然在Box范围内的片元被设置成了蓝色,但是并没有出现一个像上面那样立体的Box,这是因为后处理就是一副画,这是一个二维的概念,所以肯定没有立体的效果。但是如果又想要立体的效果呢?是不是没有办法呢?您也可以先试想以下,先不捉急看下面。要实现立体效果,其实我们只需要将这个Box所遮蔽的片元设置为蓝色即可,这里就涉及到一个重要的知识,Box所遮蔽的片元如何求取?
求取方式为:相机到片元(世界坐标)的射线与Box如果有交点,那么该片元就被遮蔽,所以问题转为相机到片元的射线和Box求交,射线和Box求交有很多算法,这里采用AABB的方式,shader代码如下:
//边界框最小值 边界框最大值
float2 rayBoxDst(float3 boundsMin, float3 boundsMax,
//世界相机位置 光线方向倒数
float3 rayOrigin, float3 invRaydir)
{
float3 t0 = (boundsMin - rayOrigin) * invRaydir;
float3 t1 = (boundsMax - rayOrigin) * invRaydir;
float3 tmin = min(t0, t1);
float3 tmax = max(t0, t1);
float dstA = max(max(tmin.x, tmin.y), tmin.z); //进入点
float dstB = min(tmax.x, min(tmax.y, tmax.z)); //出去点
float dstToBox = max(0, dstA);
float dstInsideBox = max(0, dstB - dstToBox);
return float2(dstToBox, dstInsideBox);
}
如果distA&&distA<distB则有交点,否则没有交点,加入代码测试
let shader=`
uniform sampler2D colorTexture;
uniform sampler2D depthTexture;
in vec2 v_textureCoordinates;
uniform mat4 inverse;
vec4 rayBoxDst(vec3 boundsMin, vec3 boundsMax, vec3 rayOrigin, vec3 invRaydir)
{
vec3 t0 = (boundsMin - rayOrigin) * invRaydir;
vec3 t1 = (boundsMax - rayOrigin) * invRaydir;
vec3 tmin = min(t0, t1);
vec3 tmax = max(t0, t1);
float dstA = max(max(tmin.x, tmin.y), tmin.z); //进入点
float dstB = min(tmax.x, min(tmax.y, tmax.z)); //出去点
float dstToBox = max(0., dstA);
float dstInsideBox = max(0., dstB - dstToBox);
return vec4(dstToBox, dstInsideBox,dstA,dstB);
}
void main(){
out_FragColor = texture(colorTexture, v_textureCoordinates);
vec4 rawDepthColor = texture(czm_globeDepthTexture, v_textureCoordinates);
float depth = czm_unpackDepth(rawDepthColor);
if (depth == 0.0) {
depth = 1.0;
}
vec4 eyeCoordinate4 = czm_windowToEyeCoordinates(gl_FragCoord.xy, depth);
vec3 eyeCoordinate3 = eyeCoordinate4.xyz/eyeCoordinate4.w;
vec4 worldCoordinate4 = czm_inverseView * vec4(eyeCoordinate3,1.) ;
vec3 worldCoordinate = worldCoordinate4.xyz / worldCoordinate4.w;
vec4 worldPos= inverse * vec4(worldCoordinate,1.);
vec4 cameraPos= inverse * vec4(czm_viewerPositionWC,1.);
vec3 vDirection=worldPos.xyz-cameraPos.xyz;//方向
vec3 rayDir = normalize( vDirection );
vec3 dim= vec3(20.,20.,20.);//盒子长宽高
vec3 box_min = vec3(0.) - dim / 2.;
vec3 box_max = vec3(0.) + dim / 2.;
vec4 bounds =rayBoxDst(box_min,box_max,cameraPos.xyz,1.0 / rayDir);
bounds.x = max( bounds.x, 0.0 );
if ( bounds.z > bounds.w ) return; //盒子外
out_FragColor=vec4(0.,0.,1.,1.);
}
`;
三、简单的体积云
实现简单的体积云,我们参考这篇博客 体积云渲染实战:ray marching,体积云与体积云光照,这是一篇基于opengl的,为什么选择此示例参考呢?因为该示例一是相对简单,并且流程比较完善,二是涉及到的引擎代码比较少,不像其他示例有很多c#或者c++代码
1、光线步进创建Box
需要注意他的盒子中心应该就是世界坐标的中心点,所以他直接使用相关坐标进行计算,而我们的盒子中心并不在世界坐标的中心,所以不能直接算,需要像上一节那样建立一个局部坐标系当做世界坐标系。
#define bottom 13 // 云层底部
#define top 20 // 云层顶部
#define width 5 // 云层 xz 坐标范围 [-width, width]
// 获取体积云颜色
vec4 getCloud(vec3 worldPos, vec3 cameraPos) {
vec3 direction = normalize(worldPos - cameraPos); // 视线射线方向
vec3 step = direction * 0.25; // 步长
vec4 colorSum = vec4(0); // 积累的颜色
vec3 point = cameraPos; // 从相机出发开始测试
// ray marching
for(int i=0; i<100; i++) {
point += step;
if(bottom>point.y || point.y>top || -width>point.x || point.x>width || -width>point.z || point.z>width) {
continue;
}
float density = 0.1;
vec4 color = vec4(0.9, 0.8, 0.7, 1.0) * density; // 当前点的颜色
colorSum = colorSum + color * (1.0 - colorSum.a)