上一篇2D符号距离函数及其变换操作,用shader画个掘金logo介绍了怎么简单画2D图形,接下来学习怎么用shader画3D图形!
1.RayMarching光线步进算法
RayMarching算法: 为了找到视图光线和场景之间的交点,从相机开始,一点一点地沿着视图光线移动一个点,如果点到场景的SDF距离为负数,则点在场景表面内,存在相交。如果不为负,则沿着光线方向继续移动一段安全的最大步长(点到场景表面的SDF距离)。 简单来说就是,计算画布上的每个像素点坐标,沿着视线方向,一步一步往前移动,直到与场景相交,计算出每个像素点坐标与场景的距离。
- 下面参考了iq大神的raymarching
const float minDistance = 0.01;//最小距离
const float maxDistance = 100.0;//最远距离
const int rayCount = 128;//光线步进移动次数
//pos像素点位置,direction视线方向
float raymarching(vec3 pos, vec3 direction) {
float t = 0.0;
for(int i = 0; i < rayCount; i++) {
//移动后的点坐标
vec3 p = pos + direction * t;
//坐标点与场景距离
float d = scene(p);
if(d < minDistance //判断是否在场景内
|| t > maxDistance//判断是否在可视范围外
)//符合条件,停止移动
break;
//增加距离成为下一个点做准备
t += d;
}
return t;
}
2.3D符号距离函数
使用3D符号距离函数绘制场景。
//https://iquilezles.org/articles/distfunctions/
//球体,p坐标点,s半径
float sdSphere(vec3 p, float s) {
return length(p) - s;
}
//平面,p坐标点
float sdPlane(vec3 p) {
return p.y;
}
//场景形状组合
float scene(vec3 p) {
return min(sdSphere(p - vec3(0., 1., 0.), 1.), sdPlane(p));
}
更多的3D形状请看iq大神的3D SDF
- 结合上面的rayMarching算法,可以获取相机到三维场景的距离,绘制3D形状
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec2 uv = getUV(fragCoord);
//相机位置
vec3 ro = vec3(0., 1., -3.);
//视线方向
vec3 rd = normalize(vec3(uv, 1.0));
//采用光线步进算法,计算与场景相交点距离
float d = raymarching(ro, rd);
//因为d的值有可能大于1,要缩小一下
//d距离越远的值越大,(1.-d)后,距离越远值越小,符合近大远小的规律
float m = (1.0 - d * 0.1);
//背景与场景颜色混合成最终显示颜色
vec3 color = vec3(m);
fragColor = vec4(color, 1.);
}
3.添加光照
使用SDF距离计算出相交点,计算出相交点的法向量
//https://iquilezles.org/articles/normalsSDF/
vec3 calcNormal(in vec3 p) // for function f(p)
{
const float eps = 0.0005; // or some other value
const vec2 h = vec2(eps, 0);
return normalize(vec3(scene(p + h.xyy) - scene(p - h.xyy), scene(p + h.yxy) - scene(p - h.yxy), scene(p + h.yyx) - scene(p - h.yyx)));
}
- 根据法向量计算出添加平行光和环境光后颜色,光照的计算公式请看我之前写的文章WebGL光照、贴图和帧缓冲
// nor是法向量,color是物体颜色
vec3 setLight(vec3 p, vec3 color) {
//计算相交点的法向量
vec3 nor = calcNormal(p);
//平行光方向
const vec3 lightDirection = vec3(1., 2., -3.);
//平行光照强度和颜色,强度1.0
float intensity = 1.0;
vec3 lightColor = intensity * vec3(1.0, 1.0, 1.);
//平行光方向,归一化
vec3 lightNor = normalize(lightDirection - p);
//计算光线方向和法向量的点积
float nDotL = max(dot(lightNor, nor), 0.0);
//计算漫反射颜色
vec3 diffuse = lightColor * nDotL;
//环境光
vec3 amb = vec3(0.3);
return (amb + diffuse) * color;
}
- 绘制有光照的3D形状
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec3 color = vec3(0.);
vec2 uv = getUV(fragCoord);
//相机位置
vec3 ro = vec3(0., 1., -3.);
//视线方向
vec3 rd = normalize(vec3(uv, 1.0));
//采用光线步进算法,计算与场景相交点距离
float d = raymarching(ro, rd);
if(d < maxDistance) {//可视范围内进行着色处理
//相交点坐标
vec3 p = ro + rd * d;
color = setLight(p, vec3(1.0));
}
fragColor = vec4(color, 1.);
}
- 将平行光改成点光源光,点光源的光线方向等于光源位置减去坐标点的单位向量。
//点光源位置
const vec3 lightPos = vec3(2., 2., -2.);
//光照强度和颜色,强度1.0
float intensity = 1.5;
vec3 lightColor = intensity * vec3(1.0, 1.0, 1.);
//光线方向,归一化
vec3 lightNor = normalize(lightPos - p);
//计算光线方向和法向量的点积
float nDotL = max(dot(lightNor, nor), 0.0);
//计算漫反射颜色
vec3 diffuse = lightColor * nDotL;
4.添加阴影
简单阴影的计算过程类似raymarching,只不过返回的不是距离,而是0.1和1,沿光线方向与场景相交就是0.1,颜色变深呈现阴影,沿光线方向与场景没有相交就是1,保持原来颜色,没有阴影。
//阴影 ro场景坐标点,rd光线方向
float calcShadow(in vec3 ro, in vec3 rd) {
float t = 0.;
for(int i = 0; i < rayCount && t < maxDistance; i++) {
float h = scene(ro + rd * t);
if(h < minDistance)
return 0.1;
t += h;
}
return 1.0;
}
- 阴影是相对于点光源和平行光产生的,因此作用于光的漫反射。
//相交点偏移一点点
vec3 p1 = p + nor * minDistance;
//阴影,从相交点偏移点出发,沿着光线方向,与场景是否相交
float shadow = calcShadow(p1, lightNor);
//光的漫反射与阴影距离相乘,得到阴影投射后的颜色
diffuse *= shadow;
- 可以看到上图的阴影有点生硬,可以使用柔和阴影优化一下效果,更多柔和阴影函数请看iq大神的soft shadow
//柔和阴影
float softshadow(in vec3 ro, in vec3 rd, float k) {
float res = 1.0;
float t = minDistance;
for(int i = 0; i < 256 && t < maxDistance; i++) {
float h = scene(ro + rd * t);
if(h < minDistance)
return minDistance;
res = min(res, k * h / t);
t += h;
}
return res;
}
- 柔和阴影使用
float shadow = softshadow(p1, lightNor, 1.);
diffuse *= shadow
可以看到阴影边缘柔和渐变
5.添加摄像机
- 让摄像机看向球体,围绕其运动
// target相机看向的目标坐标,position相机位置
mat3 setCamera(vec3 target, vec3 position) {
//z轴向量
vec3 z = normalize(target - position);
//x轴向量
vec3 x = normalize(cross(z, vec3(0.0, 1.0, 0.0)));
//y轴向量
vec3 y = normalize(cross(x, z));
return mat3(x, y, z);
}
- 因为raymarching是基于摄像机沿着视线方向与场景相交距离计算的,所以可以通过相机矩阵来改变视线方向。
float a = iTime * PI * 0.5;
//相机位置动起来
vec3 ro = vec3(3. * sin(a), 1., 3. * cos(a));
//相机矩阵
mat3 camera = setCamera(vec3(0.), ro);
//视线方向
vec3 rd = normalize(camera * vec3(uv, 1.0));
- 让鼠标操作相机,iMouse.z大于0的时候为鼠标按下,当鼠标按下时取鼠标x轴坐标,相对于画布的比例,取横向360度的对应旋转弧度。
//默认自动旋转
float a = iTime * PI * 0.2;
//按下鼠标跟随鼠标旋转
if(iMouse.z > 0.1) {
a = (iMouse.x / iResolution.x) * PI * 2.;
}
- 除了左右横向旋转,还可以上下纵向旋转,在横向操作相机的基础上,乘以纵向的旋转值,纵向旋转角度的范围
[-90,90],对应sin的范围是[-1,1]。 这里限制视角范围[0,90]避免钻平面下什么都看不见。
//默认自动旋转
float x = iTime * PI * 0.2;
float y = 0.5;
//按下鼠标跟随鼠标旋转
if(iMouse.z > 0.1) {
//左右旋转镜头
x = (iMouse.x / iResolution.x) * PI * 2.;
//上下旋转镜头
y = (iMouse.y / iResolution.y) * PI * 0.5;
}
//相机位置动起来
vec3 ro = vec3(3. * sin(x) * cos(y), 2. * sin(y) + 1., 3. * cos(x) * cos(y));
//相机矩阵
mat3 camera = setCamera(vec3(0.), ro);
可以看到背面阴影部分场景颜色太接近,难以分辨细节,这时候可以添加环境光遮蔽。
6.添加环境光遮蔽OA
环境光遮蔽(AO)是来描绘物体和物体相交或靠近的时候遮挡周围漫反射光线的效果,增加明暗的层次感,特别是阴影的细节。
float calcAO(in vec3 pos, in vec3 nor) {
float occ = 0.0;
float sca = 1.0;
for(int i = 0; i < 5; i++) {
float hr = 0.01 + 0.12 * float(i) / 4.0;
vec3 aopos = nor * hr + pos;
float dd = scene(aopos);
occ += (hr - dd) * sca;
sca *= 0.95;
}
return clamp(1.0 - 2.0 * occ, 0.0, 1.0);
}
- 给环境光添加环境光遮蔽作用
//环境光
vec3 amb = vec3(0.3);
//环境光遮蔽,丰富阴影细节
amb *= calcAO(p, nor);
可以看到背面阴影部分球体底部与平面有清晰的层次感。
7.添加抗锯齿
虽然上面的效果已经很不错,但是仔细看,会发现有些许锯齿。
Anti-Aliasing抗锯齿实现过程非常简单,向周围上下左右偏移一个像素,分别计算颜色值,加起来再取平均值。
- 大概增加了两层遍历计算周围像素颜色值,其他逻辑不变。
//Anti-Aliasing抗锯齿
const int AA = 2;
void mainImage(out vec4 fragColor, in vec2 fragCoord) {
vec3 color = vec3(0.);
float isColor=0.;
float a = iTime * PI * 0.2;
//相机位置动起来
vec3 ro = vec3(3. * sin(a), 1., 3. * cos(a));
mat3 camera = setCamera(vec3(0.), ro);
vec3 total = vec3(0.);
//抗锯齿:上下左右偏移一个像素,分别计算颜色值,加起来再取平均值
for(int i = 0; i < AA; i++) {
for(int j = 0; j < AA; j++) {
vec2 offset = vec2(float(i), float(j)) / float(AA) - 0.5;
vec2 uv = getUV(fragCoord + offset);
//视线方向
vec3 rd = normalize(camera * vec3(uv, 1.0));
//采用光线步进算法,计算与场景相交点距离
float d = raymarching(ro, rd);
if(d < maxDistance) {//可视范围内进行着色处理
//相交点坐标
vec3 p = ro + rd * d;
total += setLight(p, vec3(1.0));
isColor++;
}
}
}
if(isColor== float(AA * AA))
color = total /isColor;
fragColor = vec4(color, 1.);
}
8.平滑最小值
让物体之间平滑过渡融合,更多平滑最小值请看iq大神的smoothmin
//平滑最小值
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
//场景形状融合
float scene(vec3 p) {
float t = sin(PI * iTime * 0.2) + 0.5;
return smin(sdSphere(p - vec3(0., t, 0.), 1.), sdPlane(p), 1.0);
}
9.给场景物体赋不同颜色
- 重写一下min函数,给场景对应物体添加标志数值,对应raymarching,calcNormal等函数也要调整一下
vec2 minV(vec2 a, vec2 b) {
return a.x < b.x ? a : b;
}
//场景形状组合
vec2 scene(vec3 p) {
vec2 a = vec2(sdSphere(p - vec3(0., 1., 0.), 1.), 2.0);
vec2 b = vec2(sdPlane(p), 1.);
return minV(a, b);
}
- f第一个值x是与场景相交的距离,第二个值y是标志数值
vec2 f = raymarching(ro, rd);
float d = f.x;
if(d < maxDistance) {//可视范围内进行着色处理
//相交点坐标
vec3 p = ro + rd * d;
total += setLight(p, f.y);
}
- 标志数值传入函数中判断并赋予对应颜色
// nor是法向量,t物体数值标志
vec3 setLight(vec3 p, float t) {
vec3 color = vec3(0.);
if(t > 1.5) {//球体 红色
color = vec3(1.0, 0.0, 0.0);
} else {//平面 白色
color = vec3(1.);
}
//...
}
- 当然还可以给物体赋予图案纹理,因为平面的y坐标固定,x和z坐标可以看做是2D平米的uv,那么可以给平面画上黑白格子.
//黑白网格
vec3 groundGrid(vec3 p) {
vec3 c1 = vec3(1.0);
vec3 c2 = vec3(0.);
float s = mod(floor(p.x + floor(p.z)), 2.);
return mix(c1, c2, s);
}
// nor是法向量,t物体数值标志
vec3 setLight(vec3 p, float t) {
vec3 color = vec3(0.);
if(t > 1.5) {//球体 红色
color = vec3(1.0, 0.0, 0.0);
} else {//平面 黑白网格
color = groundGrid(p);
}
}
10.3D布尔运算
跟二维的布尔运算一样的逻辑,二维的布尔运算请看我之前的文章[2D符号距离函数及其变换操作,用shader画个掘金logo]
- 联合,只需要简简单单的min函数就能实现。
//联合
float unionShape(vec3 p) {
return min(sdSphere(p - vec3(0., 1., 0.), 1.), sdBox(p - vec3(0., 1., 0.), vec3(.5, 1., 1.5)));
}
//场景形状组合
vec2 scene(vec3 p) {
vec2 a = vec2(unionShape(p), 2.0);
vec2 b = vec2(sdPlane(p), 1.);
return minV(a, b);
}
- 相交,长方体和球体相交的部分,类似一个圆鼓的形状
//相交
float intersectionShape(vec3 p) {
return max(sdSphere(p - vec3(0., 1., 0.), 1.), sdBox(p - vec3(0., 1., 0.), vec3(0.5, 1., 1.5)));
}
//场景形状组合
vec2 scene(vec3 p) {
vec2 a = vec2(intersectionShape(p), 2.0);
vec2 b = vec2(sdPlane(p), 1.);
return minV(a, b);
}
- 差,长方体减去球体的部分,形成一个有圆洞的墙面
//差
float subtractionShape(vec3 p) {
return max(-1. * sdSphere(p - vec3(0., 1., 0.), 1.), sdBox(p - vec3(0., 1., 0.), vec3(0.5, 1., 1.5)));
}
//场景形状组合
vec2 scene(vec3 p) {
vec2 a = vec2(subtractionShape(p), 2.0);
vec2 b = vec2(sdPlane(p), 1.);
return minV(a, b);
}
- 当然,A-B与B-A的是不一样的,下面是球体减去长方体的部分,变成两片圆大饼
float subtractionShape(vec3 p) {
return max(sdSphere(p - vec3(0., 1., 0.), 1.), -1. * sdBox(p - vec3(0., 1., 0.), vec3(0.5, 1., 1.5)));
}
11.变换操作
- 移动操作,对应加减xyz坐标
//场景形状组合
vec2 scene(vec3 p) {
vec3 p1 = p - vec3(0., cos(iTime * PI * 0.5) * 0.8 + 1.3, -0.5);
vec2 a = vec2(sdSphere(p1, 0.5), 2.0);
vec2 b = vec2(sdPlane(p), 1.);
return minV(a, b);
}
- 缩放操作
//场景形状组合
vec2 scene(vec3 p) {
float s = cos(iTime * PI * 0.5) * 0.5 + 0.8;
vec3 p1 = p - vec3(0., s, -0.5);
vec2 a = vec2(sdSphere(p1, s), 2.0);
vec2 b = vec2(sdPlane(p), 1.);
return minV(a, b);
}
- 旋转操作,旋转矩阵请看我之前博客3D图形学基础
//绕v向量方向上的旋转rad弧度
mat3 rotation(vec3 v, float rad) {
vec3 n = normalize(v);
float cosa = cos(rad);
float sina = sin(rad);
float x = n.x, y = n.y, z = n.z;
return mat3(pow(x, 2.) * (1. - cosa) + cosa, x * y * (1. - cosa) - z * sina, x * z * (1. - cosa) + y * sina, x * y * (1. - cosa) + z * sina, pow(y, 2.) * (1. - cosa) + cosa, y * z * (1. - cosa) - x * sina, x * z * (1. - cosa) - y * sina, y * z * (1. - cosa) + x * sina, pow(z, 2.) * (1. - cosa) + cosa);
}
//场景形状组合
vec2 scene(vec3 p) {
vec3 p1 = rotation(vec3(1., 1., 1.), iTime * PI * 0.5) * (p - vec3(0., 1., 0.));
vec2 a = vec2(sdBox(p1, vec3(0.5)), 2.0);
vec2 b = vec2(sdPlane(p), 1.);
return minV(a, b);
}
12.用shader画个3D哆啦A梦
就是运用灵活运用上面的布尔运算,平滑最小值等操作3D SDF形状,即可拼接出来。
具体代码我放shaderToy上面了,请自行取用~
-https://www.shadertoy.com/view/MX3fWB
好开心!iq大神评论说Nice~
Github地址
https://github.com/xiaolidan00/awesome-bg
参考