最新文章
泰课在线 | 微信拼团成功后如何获取课程?
08-09 17:57
Unity教程 | 使用ARKit为iOS开发AR应用
07-31 17:23
Unity Pro专业版7折订阅四选一工具包之VR开发与艺术设计
07-28 11:47
网友使用虚幻UE4实现CAVE 多通道立体渲染的沉浸式环境
07-27 11:57
VR晕动症调查:未来5年内大部分VR晕动症将得到解决
07-27 11:26
AMD CEO:未来3-5年最重要 希望5年达1亿VR用户
07-27 10:44
Unity中如何使用Raymarching实现惊艳的图形效果
本文将由David Arppe分享一些在游戏中使用Raymarching技术的建议,以及他已用于实际游戏中的Raymarching代码。Raymarching技术其实历史颇为悠久,早在一些经典老游戏中就已得到应用,以下为您介绍两款具有代表性的游戏:
- 《Tennis for Two》(William Higinbotham,1958):这款游戏被广泛认为是最早的视频游戏之一,它借助示波器进行播放,创意十足。
- 《Donkey Kong》(Nintendo Research and Development 1,1981):诞生于视频游戏黄金年代的街机游戏,主角Jumperman就是如今大家熟知的马里奥。它被视为首批带有故事情节的视频游戏之一,玩家在屏幕上仿佛“看电影”一般,会惊恐地目睹公主一次次被绑架。
这些古老的游戏极具创意,它们突破了当时计算机软硬件的限制,超前地运用了至今仍不过时的技术。
什么是Raymarching技术
Raymarching是一种计算机图形渲染方式,但其潜力尚未被完全挖掘。它通常用于渲染体积纹理、高度图以及解析曲面。如今,大多数游戏采用OpenGL或Direct3D(DirectX)借助显卡的硬件加速器来绘制多边形,电脑能够以每秒60帧的速度渲染数百万个三角面。尽管Raymarching不如这些图形API知名,但它仅用两个三角面就能实现无与伦比的细节。
本文旨在表明这种古老的渲染技术可以重新回归游戏领域,并通过并行处理和计算技术进行优化。
两个三角面,无限细节
RayMarching是一种基于数学的渲染方式,它由距离场(点到一个图元的距离)、固定步长(常用于体积渲染)和根定位(一种数学方法)共同实现。
创建传统场景往往需要借助三维工具(如Maya、Blender、3DsMax)和纹理工具(如Photoshop、Gimp、MSPaint)。而采用Raymarching技术,场景可以通过数学方法创建并渲染,不再受渲染三角面数量的限制。不过,Raymarching技术并非万能,它的渲染速度较慢,有其独特的应用方式,因此我认为它应与多边形渲染结合使用,后续代码会对此进行详细解释。
如何在游戏中添加Raymarching
将Raymarching与多边形两种渲染方式相结合并非难事,但首先需要理解它们之间的差异:
- Raymarching并非百分百精确。使用距离场可以趋近于希望渲染的表面,但几乎无法得到想要的真正距离。
- 渲染多边形(透视模式下)使用了投影矩阵,这里涉及的是深度,而非距离。
通常,将两者结合使用时,最简单的方式是从多边形渲染开始,以Raymarching渲染结束。使用距离缓存进行深度测试较为困难,且会局限于固态物体。Raymarching阶段需要在所有渲染结束后进行(就如同固态物体无法在透明物体之前渲染)。接下来,我们将探讨如何准备深度缓存,并将其转化为距离缓存。
以下是一个在Unity中,将摄像机深度缓存转化为距离缓存的Shader代码示例:
float GetDistanceFromDepth(float2 uv, out float3 rayDir)
{
// 使用UV坐标校正空间,用于后面的矩阵算法
float2 p = uv * 2.0f - 1.0f; // from -1 to 1
// 指出将深度转化为距离的参数
// 从摄像机原点到相应UV的距离
// 最近平面的坐标
float3 rd = mul(_invProjectionMat, float4(p, -1.0, 1.0)).xyz;
// 创建几个变量。_ProjectionParams y 和 z分别是平面的近端和远端。
float a = _ProjectionParams.z / (_ProjectionParams.z - _ProjectionParams.y);
float b = _ProjectionParams.z * _ProjectionParams.y / (_ProjectionParams.y - _ProjectionParams.z);
float z_buffer_value = tex2D(_CameraDepthTexture, uv).r;
// Z buffer数值如下:
// z_buffer_value = a + b / z
// 计算linearEyeDepth的倒数
float d = b / (z_buffer_value - a);
// 该方法也会返回射线方向,后面会用到(很重要)
rayDir = normalize(rd);
return d;
}
这里使用了投影矩阵的倒数,来找出UV坐标(将[-1, -1]变换为[1, 1])位于近平面(x, y, -1)的位置。由于没有使用视图矩阵,所以假定摄像机位于原点([0, 0, 0])。该坐标的长度会随不同的UV坐标而变化,其中UV坐标[0.5, 0.5]与近平面的距离应保持一致。
获得这些数据后,需要对rayDir变量进行正规化处理,因为Raymarching的原理就是投射射线。
Raymarching的工作原理
准备工作完成后,获取深度缓存中的距离,就可以处理相交问题。通过逆投影矩阵计算出正确的射线,以匹配游戏中摄像机的视角,然后定位摄像机。
以下是在Unity中控制摄像机当前位置的代码:
fixed4 frag(v2f i) : SV_Target
{
float3 rayDirection;
float dist = GetDistanceFromDepth(i.uv.xy, rayDirection);
// 摄像机位置(世界坐标)
float3 rayOrigin = _cameraPos;
// 计算摄像机旋转
rayDirection = mul(_cameraMat, float4(rayDirection, 0.0)).xyz;
//...
// 未完!
}
使用float类型来存储距离,输出变量为float3,以便此函数输出正确的FOV,但该过程丢失了摄像机的旋转信息。可以使用标准的Uniform变量来获取摄像机位置(_cameraPos)。将rayDirection与视图矩阵相乘,这里将w参数设为0.0,是因为仅需要旋转摄像机,而不希望将摄像机位置存储在此变量中。
在Unity中的效果如图所示,两个黄色球体与一个长方体相交。其中右边的球体使用多边形渲染,它按照预期与立方体相交;左边的球体则根据游戏摄像机计算出的正确FOV、位置和旋转信息进行渲染。
需要注意的是,与右侧多面球体相比,使用Raymarching渲染的球体与立方体相交处的表面边缘非常平滑。
渲染其它内容
使用Raymarching渲染需要具备较深的数学功底,下面我们将使用除球体以外的形状来实现一些特殊物体。
float sdTorus( float3 p, float2 t )
{
float2 q = float2(length(p.xz) - t.x, p.y);
return length(q) - t.y;
}
这是一个环面的距离公式,该距离函数返回从点到距离图元最近的点的距离,可用于渲染甜甜圈。在图中可以看到黑色图形、红圈、蓝点及红线。左下角的蓝点代表摄像机,右上角的蓝点是正在观察的点。除了知道与最近平面(底部中心粗短的黑线)的距离外,没有其他额外信息。因此,我们利用这个距离向前移动,不断重复该过程,直到到达最终想要的平面,从而得到目标平面的距离。
要实现甜甜圈的渲染,还需要完成以下步骤:
- 获取射线源(摄像机位置)。
- 获取射线方向(摄像机FOV、长宽比以及旋转角度)。
- 在函数中添加一个距离函数(环形)。
- 将光线投射到图形上。
- 在该光线上获取到图形表面的距离。
下面,我们首先计算一个点。使用标准的point - along - a - vector方程,沿着所投射的射线移动一定的距离,然后计算到图元的距离。将刚刚计算的数值加上沿射线移动的距离,然后通过FOR循环重复该过程。
以下是用光线追踪这个圆环的代码:
// 将要计算的距离存储到这里
float d = 0.0f;
// 将沿射线移动64次。这个数值可以改变
for (int i = 0; i < 64; i++)
{
// 沿射线计算前进位置的地方。初始值为rayOrigin
float3 pos = rayOrigin + rayDirection * d;
// 从点到环形上最近点的距离
float torusDistance = sdTorus(pos, float2(0.5, 0.25));
d += torusDistance;
}
//...
// 未完!
上述代码的作用是沿着射线进行迭代,直到获得最终距离。
获取G - Buffer信息
为了使用光照模型,我们还需要更多信息。目前,我们仅拥有一条射线的距离。要在图形上进行更多操作,需要知道以下信息:
- 3D坐标。
- 表面法线。
这些属性的获取方法如下:
// 有了距离后,只需沿着射线移动,就可以获取世界空间坐标
float3 pos = rayOrigin + rayDirection * d;
// 抵消坐标的X轴,Y轴和Z轴,并将其归一化以估算表面法线
// 将eps声明为float3,便于后续使用
float3 eps = float3( 0.0005, 0.0, 0.0 );
// 可以将它封装在一个函数中。为所有的距离函数都创建一个距离字段,通常称之为“map”
#define TORUS(p) sdTorus(p, float2(0.5, 0.25)).x
float3 nor = float3(
TORUS(pos + eps.xyy) - TORUS(pos - eps.xyy),
TORUS(pos + eps.yxy) - TORUS(pos - eps.yxy),
TORUS(pos + eps.yyx) - TORUS(pos - eps.yyx) );
#undef TORUS
nor = normalize(nor);
//...
// 未完!
运行结果如图所示,我们得到了一个带有法线和世界坐标的圆环。接下来,我们将为其添加光照。
使用光照模型
我们使用标准的Phong光照模型(可参考维基百科)为圆环添加光照:
// 声明几个需要的变量
float3 l = normalize(sundir);
float3 e = normalize(rayOrigin); // raymarching中,eyePos就是rayOrigin
float3 r = normalize(-reflect(l, nor));
// 环境条件
float3 ambient = 0.3;
// 漫反射
float3 diffuse = max(dot(nor, l), 0.0);
diffuse = clamp(diffuse, 0.0, 1.0);
// 这里有一些不容易用代码编写的数值
float3 specular = 0.04 * pow(max(dot(r, e), 0.0), 0.2);
specular = clamp(specular, 0.0, 1.0);
// 现在,可以完成环形了
float4 torusCol = float4(ambient + diffuse + specular, 1.0);
//...
// 未完
添加光照后的效果看起来很不错。
投影映射
接下来,我们要为甜甜圈添加一些纹理,让它看起来更像真正的甜甜圈。可以在此获取所用的纹理,代码如下:
fixed4 frag(v2f i) : SV_Target
{
// ..
// 上面所有代码
// ..
// 从两个面放面团纹理。使用Z和X法线来确保不会出现不想要的颜色
doughnutColor = tex2D(_Dough, pos.xy - float2(0.5, 0.5)).rgb * abs(nor.z);
doughnutColor += tex2D(_Dough, pos.zy - float2(0.5, 0.5)).rgb * abs(nor.x);
// 从sprinkles纹理上取样,使用上下面
// 这应该是几个明显的不同情况。使用if声明
// 使用一些噪声来获得“洒”糖果的效果。
float noiseOffset = tex2D(_Noise, pos.xz * 0.2).x * 0.5f;
if (nor.y + noiseOffset > 0.7)
{
doughnutColor = tex2D(_Sprinkles, pos.xz).rgb;
} else {
doughnutColor += float3(1.0, 0.75, 0.5); // 这是一种颜色
}
torusCol.rgb *= doughnutColor;
// 在此选用深度测试模式
return (dist < d ? tex2D(_MainTex, uv) : torusCol);
}
最终结果如图所示。
总结
本文介绍了Raymarching这种计算机图形渲染方式,并通过代码实现了一个“甜甜圈”的渲染。当“古老”的技术与现实碰撞时,往往能产生意想不到的效果。希望大家通过本文对Raymarching技术有更深入的了解,并能在实际项目中灵活运用。