迈向3D!RayMarch原理以及在WPF中的使用

没有顶点着色器的遗憾

WPF不支持顶点着色器,只支持像素着色器。这可能是为了入门简便考虑,让使用者专心在像素着色器上。毕竟会的越少,钻得越深。
我去瞧了一下ShaderToy,上面似乎也只提供了像素着色器。
缺少顶点着色器的控制,一些效果就不好实现了,比如顶点动画,3D图形渲染。
但我发现了一种叫RayMarching,又叫光线步进的算法。这种算法在只提供像素着色器的情况下也能进行3D图形渲染。
这个算法不算新,我在大学时就听说过了。当时正是GPU大发展的时候,RTX系列显卡的推出,让实时光线追踪ray tracing进入游戏。然后上课的时候有个实验,叫99行代码实现光线追踪。使用C++代码实现了一个光线追踪场景。

image

然后还给了一些反向光线追踪的案例。我在网上搜索相关信息时就见到过叫RayMarching的算法。RayMarching入门特别简单,或许只需要半个小时就能完成demo。
通过前面的两篇学习,我已经能够编写像素着色器,并和CPU程序传递数据(变量)了,可以借此从wpf向像素着色器传递场景模型。但是最简单的RayMarching场景搭建在像素着色器中就能完成。比如一个球体,一个平面。接下来我演示一个平面上有球体的场景,并且在像素着色器中用RayMarching算法渲染出这个场景。

RayMarching带来的新体验

  • 场景:一个高度为-6的平面,一个高度为0,半径为5的平面,一个位于原点的摄像机,一个离相机距离1的成像平面
    这个成像平面是电脑显示器在场景中的坐标,相机坐标你可以想象在我们脑袋前面到后面之间的一个点,他们组成一个锥形,而球体在显示器后面,水平面就是脚踩的地面

  • 渲染过程:遍历成像平面(显示器或UI控件)的每个像素,光线从相机出发,经这个像素前进。每前进一次,计算光线当前位置和球体以及平面的最小距离,如果没有到0或一个极小值,就向前步进这个距离,直到撞到球体或平面,或者100步之后没撞到物体停止计算。

  • 渲染结果:
    image

  • 程序解析:Ray Marching着色器分为3部分,主函数、Rry Marching、光线和场景求距离

    • 主函数做了两件事。第一件事是将品目坐标原点从左上角移动到中心。第二件事是准备并提供Ray Marching函数需要的射线起点和射线方向,然后调用Ray Marching函数获取当前像素的颜色
    float4 main(float2 uvt : TEXCOORD) : COLOR 
    { 
    	//将屏幕原点从左上角转化到中心,并且将向下增大的y坐标反向
    	float2 uv=float2(uvt.x-0.5,1-uvt.y-0.5);	
    	//位于原点的相机
    	float3 cameraPosition = float3(0.0, 0.0, 0.0);    
    	//经像素投射的光线方向,由于摄像机在原点,所以像素空间坐标也刚好是光线方向
    	float3 rayDirection = normalize(float3(uv, 1.0));
    	//光线步进算法求出来的像素颜色    
    	return rayMarching(cameraPosition, rayDirection);
    }
    
    • Ray Marching函数就是控制光线一步一步不断前进,判断是否命中场景。具体的命中测试则交给了distanceToSianl函数去做,所以代码也很简单。但Ray Marching这种渲染算法能实现一个正常来说不会出现的有趣效果,就是物体融合。算是以外的收获。
    //计算当前点和由球体和水平面组成的场景的最小距离
    float distanceToSianl(float3 position){
    	float d2s=distanceToSphere(position,float3(0.0, 0.0, 5.0), 1.0);
    	float d2p=distanceToPlane(position,-5);
    	return min(d2s,d2p );
    }
    
    //光线步进算法
    float4 rayMarching(float3 rayOrigin, float3 rayDirection) : SV_Target {
    	float totalDistance = 0.0;  
    	const int maxSteps = 40;   
    
    	for (int i = 0; i < maxSteps; ++i) {
    		//这里是用的射线的参数方程,射线上的一个点由射线起点、射线方向和到起点的距离确定
    		float3 currentPos = rayOrigin + totalDistance * rayDirection;       
    		//光线当前点到场景的距离
    		float dist = distanceToSianl(currentPos);        
    		//阈值,因为在离散计算中,光线很少能刚好和物体表面相撞,而是在一个极小距离内
    		//这会带来一个新奇的效果,光线从两个物体间隙(小于阈值)穿过,光线可能以为这里不是间隙,而是物体
    		if (dist < 0.01) {            
    			return float4(1.0, 1.0, 1.0, 1.0);
    		}        
    		//没有相撞,光线继续前进
    		totalDistance += dist;        
    		//光线前进方向上没有物体,结束计算
    		if (totalDistance >= 100.0) {
    			break;
    		}
    	}
    	//没有物体的空白处显示黑色    
    	return float4(0.0, 0.0, 0.0, 1.0);
    }
    
    • 光线和场景求距离不是一个固定的函数,而是与场景相关的函数,场景变,他就变。比如说场景中有一个球体,那这个函数就是求和球体举例的函数,如果两两个球体,那这个函数就是求和两个球体最小距离的函数。一句话概括,就是求和场景所有物体最小举例的函数。
    //计算当前点和球体的距离
    float distanceToSphere(float3 position, float3 center, float radius) {
    	return length(position - center) - radius;
    }
    
    //计算当前点和水平面的距离
    float distanceToPlane(float3 position, float3 planeHeight) {
    	return position.y-planeHeight;
    }
    
    //计算当前点和由球体和水平面组成的场景的最小距离
    float distanceToSianl(float3 position){
    	float d2s=distanceToSphere(position,float3(0.0, 0.0, 5.0), 1.0);
    	float d2p=distanceToPlane(position,-5);
    	return min(d2s,d2p );
    }
    
  • 样例代码

//raymarching.fx

//暂不使用输入的纹理
//sampler2D input : register(s0);

//计算当前点和球体的距离
float distanceToSphere(float3 position, float3 center, float radius) {
    return length(position - center) - radius;
}

//计算当前点和水平面的距离
float distanceToPlane(float3 position, float3 planeHeight) {
    return position.y-planeHeight;
}

//计算当前点和由球体和水平面组成的场景的最小距离
float distanceToSianl(float3 position){
	float d2s=distanceToSphere(position,float3(0.0, 0.0, 5.0), 1.0);
	float d2p=distanceToPlane(position,-5);
	return min(d2s,d2p );
}

//光线步进算法
float4 rayMarching(float3 rayOrigin, float3 rayDirection) : SV_Target {
    float totalDistance = 0.0;  
    const int maxSteps = 40;   
    
    for (int i = 0; i < maxSteps; ++i) {
        //这里是用的射线的参数方程,射线上的一个点由射线起点、射线方向和到起点的距离确定
        float3 currentPos = rayOrigin + totalDistance * rayDirection;       
        //光线当前点到场景的距离
        float dist = distanceToSianl(currentPos);        
        //阈值,因为在离散计算中,光线很少能刚好和物体表面相撞,而是在一个极小距离内
		//这会带来一个新奇的效果,光线从两个物体间隙(小于阈值)穿过,光线可能以为这里不是间隙,而是物体
        if (dist < 0.01) {            
            return float4(1.0,1.0,1.0, 1.0);
        }        
        //没有相撞,光线继续前进
        totalDistance += dist;        
        //光线前进方向上没有物体,结束计算
        if (totalDistance >= 100.0) {
            break;
        }
    }
    //没有物体的空白处显示黑色    
    return float4(0.0, 0.0, 0.0, 1.0);
}

//入口函数
float4 main(float2 uvt : TEXCOORD) : COLOR 
{ 
	//将屏幕原点从左上角转化到中心,并且将向下增大的y坐标反向
	float2 uv=float2(uvt.x-0.5,1-uvt.y-0.5);	
	//位于原点的相机
	float3 cameraPosition = float3(0.0, 0.0, 0.0);    
	//经像素投射的光线方向,由于摄像机在原点,所以像素空间坐标也刚好是光线方向
	float3 rayDirection = normalize(float3(uv, 1.0));
	//光线步进算法求出来的像素颜色    
	return rayMarching(cameraPosition, rayDirection);
}

深度渲染

这样太单调了,既然我们得知的光线和物体相交的坐标,就可以返回这个坐标的Z轴距离。并用这个距离来作为透明度

if (dist < 0.01) {            
			float depth = currentPos.z;  // 获取Z轴深度
			// 通过深度值映射到颜色
            float3 color = lerp(float3(1.0, 1.0, 1.0), float3(0.0, 0.0, 0.0), depth / 40.0);
            return float4(color, 1.0);
        }     

image
效果不是很好,球体几乎看不出来渐变。我调了一下球体的半径和距离后,认为是摄像机视角Fov太小了导致的,因为现在摄像机的视角应该是60.所以我的给摄像机添加一个Zomm参数,将视角增大。

float2 zoom=2.5;
float3 rayDirection = normalize(float3(uv*zoom, 1.0));

我再增加一下球体距离和半径到合适位置。现在有些效果了
image

运动起来

把球体距离设置为输入的参数

/// <summary>Explain the purpose of this variable.</summary>
/// <minValue>30/minValue>
/// <maxValue>50</maxValue>
/// <defaultValue>35</defaultValue>
float SampleInputParam : register(C0);

float d2s=distanceToSphere(position,float3(0.0, 10.0, SampleInputParam), 15.0);

image

posted @ 2024-07-11 19:23  ggtc  阅读(78)  评论(0编辑  收藏  举报
//右下角目录