ThreeJS 中体渲染,利用噪声模拟烟,云

体渲染的东西也看了一段时间了,这里结合Three.js中体积云的例子,实现shdertoy中的一个效果,先放效果图。

threejs volume threejs volume render_threejs volume

Fire2 (shadertoy.com), 这里是参考的效果,可以自行参看源码。

体渲染,Volume Rendering

传统建模方式,可以理解为表面建模,通过构建物体外表面,在三维中展示实际物体。相对的,体渲染是从三维数据中生成图像,典型的例子就是医疗上的CT。本文中不涉及体渲染中的光学模型,仅是对数据进行采样,上色。同时简化计算,使用的几何体为圆球,当然也可以换成立方体,计算方式不会复杂,之后附带立方体和球体的射线检测。

体渲染在表现自然现象,云、雾、火等,相对于表面建模,或者贴图有很大优势。最大的不同就是,实心的,当然效果也好太多了。

3D纹理,sample3D

本次主要要渲染动态更新的体数据,就不需要提前生成体数据了。通过fragmentshader来对采样的射线进行噪声处理,达到动画效果。

相关算法

噪声

噪声函数相关的内容,可以自行搜索,这里贴一个IQ大神的博客地址。Inigo Quilez :: fractals, computer graphics, mathematics, shaders, demoscene and more (iquilezles.org)。

文中涉及的噪声,分型布朗都是从shadertoy获取,代码仅作适当说明。

立方体射线求交,AABB

先解释一下AABB,Axis-Aligned Bounding Box, 轴对称包围盒。我们在放置立方体时,立方体各边都同三个坐标轴平行,可以极大简化计算。如下图,计算射线threejs volume threejs volume render_three.js_02进入一组平面(可以理解为立方体的两个对立面),计算进入和射出的位置时,可以只采用对应轴的分量即可。如下图右侧的公式。

threejs volume threejs volume render_3d渲染_03

假设平面P垂直于X轴,则计算过程可以仅考虑x方向的分量。图中threejs volume threejs volume render_threejs volume_04为平面p在x轴处的值,threejs volume threejs volume render_three.js_05分别为:射线原点的x值,射线朝向threejs volume threejs volume render_three.js_06在x轴的分量(射线朝向为单位向量时,可以理解为,射线进入出去两个平面的时间)。

球体射线求交

球体就是射线方程同球面方程,利用求根公式解方程组了,不再介绍。

Three.js中的体渲染

基础内容介绍完,先看一下Three提供的体渲染的例子。场景中主要包括天空盒,和承载体渲染结果的立方体。这些不做说明,重点看一下渲染使用的着色器。

渲染立方体的材质要使用RawShaderMaterial,采用自定义的着色器。

顶点着色器

vertexshader中主要处理相机位置,和相机朝向,用于传入到fragment中。

Three中相机位置为世界坐标,通过模型变换的逆矩阵,将相机位置换算到模型本地坐标中。模型在本地坐标中处于原点位置,可以简化计算。相机朝向通过顶点位置减相机的本地坐标即可得到。

in vec3 position;

uniform mat4 modelMatrix;		//模型本地坐标系,转换到世界坐标系
uniform mat4 modelViewMatrix;	//模型世界坐标系,转换到相机坐标系空间
uniform mat4 projectionMatrix;	//投影矩阵
uniform vec3 cameraPos;			//相机位置

out vec3 vOrigin;
out vec3 vDirect
void main() {
	//相机空间坐标
	vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
	//相机在模型本地坐标的位置
	vOrigin = vec3( inverse( modelMatrix ) * vec4( cameraPos, 1.0 ) ).xyz;
	//相机在模型本地坐标的朝向
	vDirection = position - vOrigin;
	gl_Position = projectionMatrix * mvPosition;
}

片元着色器

相机位置作为射线原点,相机朝向作为射线方向,这条射线将参与后续的体数据的采样。

代码中的hitBox即为上面提到的AABB方法。得到的vec2,其中x为最近,y为最远。

void main(){
	vec3 rayDir = normalize( vDirection );
	vec2 bounds = hitBox( vOrigin, rayDir );
	// 丢弃立方体外的像素
	if ( bounds.x > bounds.y ) discard;
	// 当相机位置在立方体内部时,x为负值,射线反向不需要采样,设置为0,即从内部开始采样
	bounds.x = max( bounds.x, 0.0 );
	// p为射线第一次进入立方体的位置
	vec3 p = vOrigin + bounds.x * rayDir;
	vec3 inc = 1.0 / abs( rayDir );
	float delta = min( inc.x, min( inc.y, inc.z ) );
	delta /= steps;

	// Nice little seed from
	// https://blog.demofox.org/2020/05/25/casual-shadertoy-path-tracing-1-basic-camera-diffuse-emissive/
	uint seed = uint( gl_FragCoord.x ) * uint( 1973 ) + uint( gl_FragCoord.y ) * uint( 9277 ) + uint( frame ) * uint( 26699 );
	vec3 size = vec3( textureSize( map, 0 ) );
	float randNum = randomFloat( seed ) * 2.0 - 1.0;
	// 对开始位置进行偏移,可能是为了避免射线在表面这种临界条件吧
	p += rayDir * randNum * ( 1.0 / size );
	
	vec4 ac = vec4( base, 0.0 );
	// 从射线进入,到射线穿过立方体,依次采样
	for ( float t = bounds.x; t < bounds.y; t += delta ) {
		float d = sample1( p + 0.5 );
		d = smoothstep( threshold - range, threshold + range, d ) * opacity;
		float col = shading( p + 0.5 ) * 3.0 + ( ( p.x + p.y ) * 0.25 ) + 0.2;
		ac.rgb += ( 1.0 - ac.a ) * d * col;
		ac.a += ( 1.0 - ac.a ) * d;
		// 采样颜色累积到接近不透明时,停止采样
		if ( ac.a >= 0.95 ) break;
			p += rayDir * delta;
		}

		color = ac;

		if ( color.a == 0.0 ) discard;

}

main方法中的shading可以理解为上色的过程。

修改片元着色器

以上了解了体渲染基本流程后,开始修改片元着色器以期实现shadertoy中的效果。

首先,shadertoy中的造型是基于球体做的,我们修改几何体为球体。添加hitSphere方法,t0,t1依次为进入和出去的时间。

vec2 hitSphere(vec3 origin,vec3 dir){
	float b=dot(dir,origin);
	float c=dot(origin,origin)-_SphereRadius*_SphereRadius;

	float t0=-b-sqrt(b*b-c);
	float t1=-b+sqrt(b*b-c);
	t0=max(t0,0.);
	return vec2(t0,t1);
}

RayMarch

由于我们需要在shader中实现球体的绘制,因此需要通过射线原点到球体的距离来绘制球体。这里类似于cloud页面中的步进采样。

vec4 rayMarch(vec3 rayOrigin,vec3 rayStep,out vec3 pos)
{
	vec4 sum=vec4(0.,0.,0.,0.);
	pos=rayOrigin;
	for(int i=0;i<_VolumeSteps;i++)
	{
		vec4 col=volumeFunc(pos);
		col.a*=_Density;
		col.rgb*=col.a;
		sum=sum+col*(1.0-sum.a);
		pos+=rayStep;
	}
	return sum;
}

渲染球体

同时为了简化计算,射线累计的采样次数都为固定值,不再单独计算。

void main(){
	vec3 rayDir = normalize( vDirection );

	vec2 bounds=hitSphere(vOrigin,rayDir);
	if(bounds.y<0.) discard;

	vec3 hitPos;
	//射线第一次进入球的位置
	vec3 p=vOrigin+bounds.x*rayDir;

	vec4 col=rayMarch(p,rayDir*_StepSize,hitPos);
	color = col;

	if ( color.a == 0.0 ) discard;

}

rayMarch中的volumeFunc直接返回射线到球面的距离时,你将能得到非常光滑的球。

threejs volume threejs volume render_threejs volume_07

接下来就是对这个球进行造型了,噪声函数很多,可以自行测试,添加噪声函数后,可能得到如下,实时计算的噪声,在GTX1650显卡上,还是很流畅的,再低一些就不好使了,卡成PPT:

threejs volume threejs volume render_three.js_08

这个球还是太规则,添加fbm(分形布朗)后,增加不规则程度,即可得到如下:

threejs volume threejs volume render_three.js_09

最后附上全部代码。代码比较乱,放了几种噪声函数,可以自己试试不同的形状,谨慎观看。在线地址

最后贴一个不同的着色效果。

threejs volume threejs volume render_3d渲染_10

threejs volume threejs volume render_3d渲染_11