案例学习——Unity体绘制shader初探

Posted 清清!

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了案例学习——Unity体绘制shader初探相关的知识,希望对你有一定的参考价值。

本文学习资料来源
Volumetric Rendering——Alan Zucconi

案例学习——Unity体绘制shader初探

1 体绘制引入

在3d引擎中,无论球体,立方体还是什么其他物体都是由面片组成的,因而像是Unity这样的光照系统也只能渲染表面的那些三角面片。

就算是要表现半透明的物体材质,也依然是渲染其表面后,通过例如alphablend的技术,将其与后面的物体颜色进行混合,表现出类似透明的效果。

对于GPU来说,整个3D世界 就是一层壳。

当然,为了突破整个限制,人们也想了很多方法。尽管最后是渲染一个壳,但是依然有方法可以深入。

比如体绘制技术(Volume rendering techniques) ,它便会模拟光线在体积中的传播,从而实现复杂的视觉效果。

我们可以发现Unity中的Unlit的基本的片元着色器模板是这样

 fixed4 frag (v2f i) : SV_Target
            
                // sample the texture
                fixed4 col = tex2D(_MainTex, i.uv);
                // apply fog
                UNITY_APPLY_FOG(i.fogCoord, col);
                return col;
            

片元着色器对每一个像素进行调用,在相机视锥内的那些三角形就会被赋予颜色
如下图

跳脱一点,我们可以这么理解,我们用相机在某个角度看到了一些三角形,片元着色器的作用是去给予它们颜色。

但其实并不需要看到什么物体就给它正确的颜色,我们也可以选择“欺骗”性的赋予颜色。

如下图,我们看到的是一个立方体,但是我们完全可以在它的壳上,想方设法给予一个球体的颜色,看起来好像里面内嵌了一个球似的。

这也是所谓立体渲染的基本思想,想方设法模拟光线在内部会发生什么事情。

如果想要模拟上图的效果,假设我们的主要几何形状就是一个立方体,我们想在其内部立体地渲染一个球体。
但是实际上没有与球相关的mesh,我们会通过shader代码进行“假的”渲染。

  • 球体具有一个世界坐标_Centre,半径是_Radius
  • 移动立方体不会影响球体的位置,它被表示为绝对的世界坐标
  • 其他几何体也影响不了它,因为这个球是被渲染出来的“不存在”的球

1.1 光线投射算法(Ray Casting)

体绘制的第一种方法,光线投射算法(Ray Casting)

伪代码会像这样

float3 _Centre;
float _Radius;
fixed4 frag (v2f i) : SV_Target

    float3 worldPosition = ...
    float3 viewDirection = ...
    if ( raycastHit(worldPosition, viewDirection) )
        return fixed4(1,0,0,1); // Red if hit the ball
    else
        return fixed4(1,1,1,1); // White otherwise

  • raycastHit函数中,给定我们要渲染的点以及从中观察的方向,就可以确定我们是否击中了虚拟红色球体。
    于是变成了球体与线段相交的数学问题。

通常Ray Casting效率很低。如果要采用这个方法,则需要用到公式,将线段与某种自定义几何图形求交。这个解决方案限制了可以创建的模型为固定的几种形状,因此很少采用这个方法。

1.2 恒定步长的光线步进(Ray Marching with Constant Step)

就像光线投射的缺点,纯粹的数学解析光线和几何体是否相交是不太灵活的。

如果要模拟任意体积,则需要找到一种不依赖于相交的数学方程的更灵活的技术。

Ray Marching(光线步进)就是一种常用的基于迭代方法的技术。

通常的,射线缓慢扩展到立方体的体积中。在每个步骤中,我们都会查询射线当前是否正在撞击球体。

我们在实现上,让每条射线从当前的片段位置开始,向着视线的方向前进一小步,每一步判断射线到球心的距离是否小于半径。

shader实现如下,还是很简单的

// Upgrade NOTE: replaced '_Object2World' with 'unity_ObjectToWorld'
// Upgrade NOTE: replaced 'mul(UNITY_MATRIX_MVP,*)' with 'UnityObjectToClipPos(*)'

Shader "Unlit/Volu1"

    Properties
    
		_Centre ("Centre",Vector) = (0,0,0)
		_Radius ("Radius", Range(0,1)) = 0.8
    
    SubShader
		
			Tags  "RenderType" = "Opaque" 
			LOD 100

			Pass
			
				CGPROGRAM
				#include "Lighting.cginc"

				#pragma vertex vert
				#pragma fragment frag

				#include "UnityCG.cginc"
		

				struct appdata
				
					float4 vertex : POSITION;
					float2 uv : TEXCOORD0;
				;

				struct v2f 
					float4 pos : SV_POSITION;    // Clip space
					float3 wPos : TEXCOORD1;    // World position
				;

				float3 _Centre;
				fixed _Radius;

				// 判断是否进入球内
				bool sphereHit(float3 p)
				
					return distance(p, _Centre) < _Radius;
				

				//光线步进
				bool raymarchHit(float3 position, float3 direction)
				
					float STEPS = 64;
					float STEP_SIZE = 0.1;
					for (int i = 0; i < STEPS; i++)
					
						if (sphereHit(position))
							return true;

						position += direction * STEP_SIZE;
					

					return false;
				


				v2f vert(appdata v)
				
					v2f o;
					o.pos = UnityObjectToClipPos(v.vertex);
					o.wPos = mul(unity_ObjectToWorld, v.vertex).xyz;
					return o;
				

				fixed4 frag(v2f i) : SV_Target
				
					float3 worldPosition = i.wPos;
					float3 viewDirection = normalize(i.wPos - _WorldSpaceCameraPos);
					 
					if (raymarchHit(worldPosition, viewDirection))
						return fixed4(1,0,0,1); // Red if hit the ball
					else
						return fixed4(1,1,1,1); // White otherwise
				
			ENDCG
			
		

虽然数学判断线段与球体相交会难,但迭代检测点是否位于球体内就很简单了。
结果见下图,仿佛实际上这就是个立方体内无光照的球体

2 距离辅助的光线步进 Distance Aided Raymarching

固定步长的光线步进在第一节已经实现,但固定步长的话效率不太行。

无论填充体积的这个几何体形状如何,光线每次都会前进相同的量。

何况在shader内添加循环会极大地影响着色器的性能。

如果要使用实时体积渲染,则需要找到更好的更有效的解决方案。

我们希望有一种方法可以估算射线在不碰到几何形状的情况下可以传播多远。

为了使该技术起作用,我们需要能够估计与几何体的距离。

我们把之前的判断是否在球内的函数

// 判断是否进入球内
bool sphereHit(float3 p)

	return distance(p, _Centre) < _Radius;

改成估算距离

//估算距离
float sphereDistance(float3 p)

	return distance(p, _Centre) - _Radius;

我们用它来估算距离,它也就变成了另一个看起来高端点的东西有向距离函数(signed distance functions)

非常简单,如果结果是正的,我们就不在球内。当为结果是负数时,我们就在球内;当为零时,我们正处于表面。

它能做的就是提供一个保守的预估距离,告诉我们射线在到达球体之前还要前进多远。如果使用更复杂的几何形状,这个技术就有价值了。

下图展现了它的工作原理,每条射线在靠近物体时都会尽量走大的距离。通过这样的方式,就可以大幅减少射线命中物体所需的迭代步数了

编写shader如下

Shader "Unlit/Volu1"

    Properties
    
		_Centre ("Centre",Vector) = (0,0,0)
		_Radius ("Radius", Range(0,1)) = 0.8
    
    SubShader
		
			Blend SrcAlpha OneMinusSrcAlpha
			Tags  "RenderType" = "Transparent" "Queue" = "Transparent" 
			LOD 100

			Pass
			
				CGPROGRAM
				#include "Lighting.cginc"

				#pragma vertex vert
				#pragma fragment frag

				#include "UnityCG.cginc"
		

				struct appdata
				
					float4 vertex : POSITION;
					float2 uv : TEXCOORD0;
				;

				struct v2f 
					float4 pos : SV_POSITION;    // Clip space
					float3 wPos : TEXCOORD1;    // World position
				;

				float3 _Centre;
				fixed _Radius;

				//估算距离
				float sphereDistance(float3 p)
				
					return distance(p, _Centre) - _Radius;
				
				
				//光线步进
				fixed4 raymarch(float3 position, float3 direction)
				
					float STEPS = 64;
					float STEP_SIZE = 0.01;
					// Loop do raymarcher.
					for (int i = 0; i < STEPS; i++)
					
						float distance = sphereDistance(position);
						if (distance < 0.01)
							return i / (float)STEPS;

						position += distance * direction;
					
					return 0;
				

				// Vertex function
				v2f vert(appdata_full v)
				
					v2f o;
					o.pos = UnityObjectToClipPos(v.vertex);
					o.wPos = mul(unity_ObjectToWorld, v.vertex).xyz;
					return o;
				
				// Fragment function
				fixed4 frag(v2f i) : SV_Target
				
					float3 worldPosition = i.wPos;
					float3 viewDirection = normalize(i.wPos - _WorldSpaceCameraPos);
					return (1-raymarch(worldPosition, viewDirection)) * float4(1,1,1,1);
				
			ENDCG
			
		

重点解释一下函数
如果是和球交不到的那么会return0,在着色时直接纯白
如果和球能交到,则不断步进时,在某一精度返回步进步数,得到一个分数,用来着色时呈现分层透明度

//估算距离
float sphereDistance(float3 p)

	return distance(p, _Centre) - _Radius;

//光线步进
fixed4 raymarch(float3 position, float3 direction)

	float STEPS = 64;
	float STEP_SIZE = 0.01;
	// Loop do raymarcher.
	for (int i = 0; i < STEPS; i++)
	
		float distance = sphereDistance(position);
		if (distance < 0.01)
			return i / (float)STEPS;

		position += distance * direction;
	
	return 0;

效果如下(花纹为GIF压缩问题,实质为纯色)

每一个步进层级随着着色点的旋转而变大变小

3 表面着色

这一节,主要是估算法线和将原本的return i / (float)STEPS;换成了简单的着色模型(兰伯特+布林)

兰伯特和布林就不多赘述了,如何估计法线可以聊一聊

原文给出了一种估算法线方向的技术——对附近点的距离场进行采样,以获得局部表面曲率的估计值。

每个轴上的差异是通过评估该点在这个轴两侧的距离场来计算的

float3 normal(float3 p)

	const float eps = 0.01;
	return normalize
	(float3
		(sphereDistance(p + float3(eps, 0, 0)) - sphereDistance(p - float3(eps, 0, 0)),
			sphereDistance(p + float3(0, eps, 0)) - sphereDistance(p - float3(0, eps, 0)),
			sphereDistance(p + float3(0, 0, eps)) - sphereDistance(p - float3(0, 0, eps))
			)
	);

  • eps代表用于计算表面坡度的距离。该法线估计技术的假设是我们正在着色的表面是相对光滑的。
    不连续曲面的坡度用这个方法,并不会正确地逼近着色点的法线方向。

完整代码如下

Shader "Unlit/Volu1"

    Properties
    
		_Color("Color",Vector) = (1,1,1)
		_Centre ("Centre",Vector) = (0,0,0)
		_Radius ("Radius", Range(0,1)) = 0.8
		_SpecularPower("SpecularPower",Range(0,20)) = 4
		_Gloss("Gloss",Range(0,1)) = 0.8
    
    SubShader
		
			Blend SrcAlpha OneMinusSrcAlpha
			Tags  "RenderType" = "Transparent" "Queue" = "Transparent" 
			LOD 100

			Pass
			
				CGPROGRAM
				#include "Lighting.cginc"

				#pragma vertex vert
				#pragma fragment frag

				#include "UnityCG.cginc"
		

				struct appdata
				
					float4 vertex : POSITION;
					float2 uv : TEXCOORD0;
				;

				struct v2f 
					float4 pos : SV_POSITION;    // Clip space
					float3 wPos : TEXCOORD1;    // World position
				;

				float3 _Centre;
				fixed _Radius;
				fixed _SpecularPower;
				fixed _Gloss;
				fixed4 _Color;


				//估算距离
				float sphereDistance(float3 p)
				
					return distance(p, _Centre) - _Radius;
				
				float3 normal(float3 p)
				
					const float eps = 0.01;
					return normalize
					(float3
						(sphereDistance(p + float3(eps, 0, 0)) - sphereDistance(p - float3(eps, 0, 0)),
							sphereDistance(p + float3(0, eps, 0)) - sphereDistance(p - float3(0, eps, 0)),
							sphereDistance(p + float3(0, 0, eps)) - sphereDistance(p - float3(0, 0, eps))
							)
					);
				

				fixed4 simpleLambertBlinn(fixed3 normal,float3 direction) 
					fixed3 viewDirection = direction;
					fixed3 lightDir = _WorldSpaceLightPos0.xyz;    // Light direction
					fixed3 lightCol = _LightColor0.rgb;        // Light color
					fixed NdotL = max(dot(normal, lightDir), 0);
					fixed4 c;
					// Specular
					fixed3 h = (lightDir - viewDirection) / 2.;
					fixed s = pow(dot(normal, h), _SpecularPower) * _Gloss;
					c.rgb = _Color * lightCol * NdotL + s;
					c.a = 1;					
					return c;
				
				
				fixed4 renderSurface(float3 p, float3 direction)
				
					float3 n = normal(p);
					return simpleLambertBlinn(n, direction);
				


				//光线步进
				fixed4 raymarch(float3 position, float3 direction)
				
					float STEPS = 64;
					float STEP_SIZE = 0.01;
					// Loop do raymarcher.
					for (int i = 0; i < STEPS; i++)
					
						float distance = sphereDistance(position);
						if (distance < 0.01)
							//return i / (float)STEPS;
							return renderSurface(position, direction);
						position += distance * direction;
					
					return 1;
				
				// Vertex function
				v2f vert(appdata_full v)
				
					v2f o;
					o.pos = UnityObjectToClipPos(v.vertex);
					o.wPos = mul(unity_ObjectToWorld, v.vertex).xyz;
					return o;
				
				// Fragment function
				fixed4 frag(v2f i) : SV_Target
				
					float3 worldPosition = i.wPos;
					float3 viewDirection = normalize(i.wPos - _WorldSpaceCameraPos);
					return  raymarch(worldPosition, viewDirection);
				
			ENDCG
			
		



4 有向距离函数(Signed Distance Functions)

本节主题是如何在shader中,体绘制出复杂的三维模型。

有向距离函数/有向距离场(Signed Distance Functions) 是用来描述球形,盒子及环面几何体的数学工具。

和传统的由三角形组成的3D模型相比,有向距离函数提供了几乎无限的分辨率,并且适合进行几何体操作。

原文展示了一个动画,如何使用简单的形状去创建一个蜗牛:

4.1 介绍

大多数现代的3D引擎(比如Unity)都会使用三角形来处理几何体。每一个物体,无论多复杂,都是由原始的三角形所组成。

尽管这是计算机图形学中的实际标准,但不是所有物体都能用三角形表示。像是球形以及其它曲面几何形状就无法被很好细分为平面实体。

虽然我们可以通过在表面覆盖很多很多的小三角形来得到一个近似的球体,但这会增加更多的图形绘制成本。

有什么好的方法可以替代近似呢?其中一个就是使用有向距离函数,它们是我们要表示的对象的数学描述。

当用一个球体方程来取代它的几何形状时,就能把所有由近似描述所带来的错误移除。

我们可以将有向距离函数视作与矢量图等量的三角形,可以放大缩小由SDF构成的几何形状而不丢失细节。

无论离边缘有多近,球体永远都是光滑的。

有向距离函数基于这样一种思想,即每个原始对象都必须有一个对应的函数。它以3D坐标作为参数,并返回一个值,用于表示这个点与物体表面的距离。

在我们之前的球形判断中,就用到了球的SDF函数(这里改了个名)

float sdf_sphere (float3 p)

	return distance(p, _Centre) - _Radius;

4.2 并集和交集

使用SDF的另一个原因,是因为它们易于合成。

给定两个不同球体的SDF,我们可以将它们合并为一个SDF
返回交集的部分函数如下,很简单,返回距离两个球距离的min

float sdf_sphere(float3 p, float3 c, float r)

	return distance(p, c) - r;

	
//估算距离
float sphereDistance(float3 p)

	return min
	(
		sdf_sphere(p, _Centre1, _Radius1), // Left sphere
		sdf_sphere(p, _Centre2, _Radius2)  // Right sphere
	);

效果如下

完整shader如下

Shader "Unlit/Volu1"

    Properties
    
		_Color("Color",Vector) = (1,1,1)
		_Centre1("Centre1",Vector) = (1.5,0,0)
		_Centre2("Centre2",Vector) = (-1.5,0,0)
		_Radius1("Radius1", Range(0,5)) = 0.8
		_Radius2("Radius2", Range(0,5)) = 0.8
		_SpecularPower("Specular

以上是关于案例学习——Unity体绘制shader初探的主要内容,如果未能解决你的问题,请参考以下文章

Unity Shader 学习之旅-初探

Unity Shader入门精要学习笔记 - 第6章 开始 Unity 中的基础光照

Unity Shaders初探Surface Shader背后的机制

unity shader

Unity Shader 之 基础光照

Unity Shaders学习笔记——SurfaceShader两个结构体和CG类型