迈向3D!RayMarch原理以及在WPF中的使用
没有顶点着色器的遗憾
WPF不支持顶点着色器,只支持像素着色器。这可能是为了入门简便考虑,让使用者专心在像素着色器上。毕竟会的越少,钻得越深。
我去瞧了一下ShaderToy
,上面似乎也只提供了像素着色器。
缺少顶点着色器的控制,一些效果就不好实现了,比如顶点动画,3D图形渲染。
但我发现了一种叫RayMarching
,又叫光线步进
的算法。这种算法在只提供像素着色器的情况下也能进行3D图形渲染。
这个算法不算新,我在大学时就听说过了。当时正是GPU大发展的时候,RTX系列显卡的推出,让实时光线追踪ray tracing
进入游戏。然后上课的时候有个实验,叫99行代码实现光线追踪。使用C++代码实现了一个光线追踪场景。
然后还给了一些反向光线追踪的案例。我在网上搜索相关信息时就见到过叫RayMarching
的算法。RayMarching入门特别简单,或许只需要半个小时就能完成demo。
通过前面的两篇学习,我已经能够编写像素着色器,并和CPU程序传递数据(变量)了,可以借此从wpf向像素着色器传递场景模型。但是最简单的RayMarching场景搭建在像素着色器中就能完成。比如一个球体,一个平面。接下来我演示一个平面上有球体的场景,并且在像素着色器中用RayMarching
算法渲染出这个场景。
RayMarching带来的新体验
-
场景:一个高度为-6的平面,一个高度为0,半径为5的平面,一个位于原点的摄像机,一个离相机距离1的成像平面
这个成像平面是电脑显示器在场景中的坐标,相机坐标你可以想象在我们脑袋前面到后面之间的一个点,他们组成一个锥形,而球体在显示器后面,水平面就是脚踩的地面 -
渲染过程:遍历成像平面(显示器或UI控件)的每个像素,光线从相机出发,经这个像素前进。每前进一次,计算光线当前位置和球体以及平面的最小距离,如果没有到0或一个极小值,就向前步进这个距离,直到撞到球体或平面,或者100步之后没撞到物体停止计算。
-
渲染结果:
-
程序解析: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);
}
效果不是很好,球体几乎看不出来渐变。我调了一下球体的半径和距离后,认为是摄像机视角Fov太小了导致的,因为现在摄像机的视角应该是60.所以我的给摄像机添加一个Zomm
参数,将视角增大。
float2 zoom=2.5;
float3 rayDirection = normalize(float3(uv*zoom, 1.0));
我再增加一下球体距离和半径到合适位置。现在有些效果了
运动起来
把球体距离设置为输入的参数
/// <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);