本文学习资料来源
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"

本文介绍了体绘制技术,包括光线投射算法、恒定步长的光线步进、距离辅助的光线步进、表面着色以及有向距离函数的应用。通过示例展示了如何在Unity中实现球体、立方体的体绘制,并通过SDF进行形状混合和环境光遮挡的模拟,为体积渲染奠定了基础。
最低0.47元/天 解锁文章
5142

被折叠的 条评论
为什么被折叠?



