数学模拟下的大自然:雪山大海的日出日落
这是一些美丽的风景图片,你可能觉得是AI或者是建模软件渲染而成。 其实是用数学公式堆出来的,没有模型也没有贴图。
Glsl代码见我发布在shadertoy的例子:Cloudy Sunrise on Alps Lake www.shadertoy.com/view/tcS3WD
甚至可以逐帧录制成动态风景视频:B站的1080p低码率差画质版
4K完整版150M等我找个地方传上去
全部程序仅10K,无需任何模型或素材,也无任何外部依赖。而且都是简单的数学计算,所以可以轻易用任何语言复写,甚至不用gpu,只用js逐点画canvas也可以(不过速度就很感人了)。
作为矢量图,理论上它可以在无限放大的同时还能保持无限精细。
其实现方式,可以理解为实现一个函数 f(x,y,time) = ... (x,y是坐标点的位置,time是时间),这个函数返回(x,y)这个像素点的在某个时刻的颜色。通过调用这个函数获得每个像素点的颜色,就可以绘制出令人震撼的风景图像或视频。
通过理解这个函数的实现过程,可以一窥渲染软件的底层逻辑。
如果只对光追比较感兴趣,可以看我的另一个例子珠宝皇冠:magnificent crown www.shadertoy.com/view/lcyfR3 里面光追计算较多,有大理石和金属光泽以及用多重循环计算光线在钻石中的反射
本人原创作品(主动引用他人的部分会做说明),这里讲述实现过程,但不是教学,不少算法是反复修改出的,未必是很优。
搞这些研究离不开老婆大人支持的4090显卡,多谢老婆**❤️**
欢迎转载,请注明作者嚎叫兽:[email protected]
下面会一步一步讲述整个开发过程。
一 、 从画一个带光照的球开始
1、html+js最简单易用,从它开始
初期我们先把时间参数隐掉,先画静态的画面,这样f(x,y,time)就先简化成了f(x,y)
先画一个圆,遍历canvas的每个点,调用f(x,y)获得颜色,f函数很简单,就是和零点距离小于1的时候为白,否则为黑
ini
<canvas width="1280" height="720" style="width:1280;height:720" id="test"></canvas>
<script>
function f(x, y) {
var px = (x*2-width)/height;
var py = (y*2-height)/height;
var col;
if (px*px+py*py>0.5*0.5) {
col = [0,0,0];
} else {
col = [1,1,1];
}
return col;
}
const canvas = document.getElementById('test');
const ctx = canvas.getContext('2d');
const width = canvas.width;
const height = canvas.height;
const imageData = ctx.createImageData(width, height);
let i = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
var c = f(x, height-y);
imageData.data[i++] = c[0]*255;
imageData.data[i++] = c[1]*255;
imageData.data[i++] = c[2]*255;
imageData.data[i++] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
</script>
执行效果如下
2、 把圆改成有立体感的球
表面亮度是表面法线和光线夹角的余弦值,关于这个介绍比较多不赘述
修改f函数为:
ini
function normalize(p) { //将p归一化为单位向量,就是把向量长度等比缩放到1
let len = Math.sqrt(p[0]*p[0]+p[1]*p[1]+p[2]*p[2]);
return [p[0]/len,p[1]/len,p[2]/len];
}
var lt = normalize([1,1,1]); //随便确定一个光照方向
function drawBall(px, py) {
var pz = Math.sqrt(1-px*px-py*py); //球得半径是1,z坐标很容易求得,球心是(0,0,0),那么现在的坐标位置就是法线向量
var tt = px*lt[0]+py*lt[1]+pz*lt[2]; //求法线向量和光照向量的夹角余弦值,其实就是向量点乘,glsl中提供了标准函数叫dot。注意只有两个向量长度都是1的时候才能这么算
tt = Math.max(0, tt);
return [tt,tt,tt];
}
function f(x, y) {
var px = (x*2-width)/height;
var py = (y*2-height)/height;
var r = 0.5;
var col;
if (px*px+py*py>r*r) {
col = [0.1,0.1,0.1];
} else {
px /= r;
py /= r;
col = drawBall(px,py);
}
return col;
}
结果如下
这是在绝对漫反射下的情况,实际可能根据材质不同还有高光。高光就是在法线和光线夹角很小的时候,对光源的近似镜面反射。
加一行代码即可: tt = Math.min(1, tt+Math.pow(tt, 100));
效果如下,覆盖了一点光泽
这些光照计算非常简便快速,就是游戏中常用的光栅渲染,如果需要纹理贴图,把光强与贴图直接相乘即可。
二 、放射变换下的基本场景
当一个3d场景展示在2d屏幕上时,远小近大,这在游戏或者软件中一般是通过放射矩阵来实现的,我们可以用光线路径的方式用更容易理解的方式复现这种变换。
这里等我哪天画个示意图就好理解了
把屏幕想象成世界里的一扇窗户,窗外的景色发出的光线穿过窗户进入眼睛,如果窗户是一个仅对进入眼睛的光线敏感的感光片(很多光线穿过窗户没有进入眼睛),那么他拍下的画面就是我们要画的画面。
我们要做的事情就是求出窗户这个屏幕上每个像素点的颜色,只考虑进入眼睛的光线,我们反向追踪光线,找出这光线来自外面哪个物体,根据物体的特性求出这光线的颜色就是最终需要的结果。
1、 首先需要求出入眼的光线方向
- 假定屏幕中心在世界的位置为(0,0,0),屏幕的高度为2,则屏幕的上沿中点为(0,1,0),下沿中点为(0,-1,0)
- 假定眼睛沿着z轴方向看,画面的视角为60度,此时眼睛与屏幕的距离为sqrt(3),眼睛坐标为(0,0,-sqrt(3))
如果像素点是屏幕中心,那么很显然光线就是眼睛看着的方向,是(0,0,-1)。
对于其他任意屏幕像素点,显然z=0,他的空间位置是(x,y,0),那么视野方向就是把(x,y,-sqrt(3))归一化:normalize([x,y,sqrt(3)])
ini
var ro = [0, 0, -Math.sqrt(3)]; //眼睛位置
var ta = [0, 0, 5000]; //眼睛看向的位置
var nl = height / 2; //屏幕高度为2,每个单位长度是多少像素
var xy = [(x - width/2) / nl, (y - height/2) / nl]; //把屏幕像素转换成我们的坐标系,屏幕中点为(0,0)
var rd = normalize([xy[0],xy[1],Math.sqrt(3)]); //视线方向
2、 沿视线方向探测
- 我们先简单设定一个起伏的地形
css
function ground(p) { //随便写一个根据(x,z)返回地表高度函数
return Math.sin(p[0]*0.001) * 500. + Math.cos(p[2]*0.001) * 500. - 1000.; //p[0]是x坐标,p[2]是z坐标,返回地表的高度,人眼的y为0,我们把地表设的低一些
}
- 由于眼高为y=0,我们设定水平面要低一些,为y=-800
步进探测函数:碰到物体时,返回物体的材质和距离,什么都没碰到返回null
步进探测的函数
function raymarch(ro, rd) { //从眼睛出发,沿rd方向前进
for (var f=0; f<10000;) { //最大探测距离10000
var pos = [ro[0]+rd[0]*f, ro[1]+rd[1]*f, ro[2]+rd[2]*f]; //就是pos=ro+rd*f
if (pos[1] <= -800.) { //假设水平面是y=-800
return {
type: 0, //0代表是碰到了水
pos: pos,
far: f
};
}
var y = ground(pos);
if (pos[1] <= y) {
return {
type: 1, //1代表是碰到了土
pos: pos,
far: f
};
}
//这次没有碰到东西,就往前挪一点,远的物体更小,所以越远可以挪的越快
f+=Math.max(1, 0.1*f); //提低数值可以提高精细度
}
return null; //超出了最大探测距离,什么都没碰到
}
3、 修改渲染函数
ini
function f(x, y) {
var ro = [0, 0, -Math.sqrt(3)];
var ta = [0, 0, 5000];
var nl = height / 2; //屏幕高度为2,每个单位长度是多少像素
var xy = [(x - width/2) / nl, (y - height/2) / nl];
var rd = normalize([xy[0],xy[1],Math.sqrt(3)]);
var hit = raymarch(ro, rd);
var col;
if (hit != null) {
if (hit.type == 1) //画土
col = [0, 0.6, 0.7];
else
col = [0.7, 0.7, 0.5]; //画水
} else {
col = [0, 0, 1]; //画天空
}
return col;
}
执行一下,就得到了一个简单的三维场景,青色的山,黄色的水,蓝色的天
可以看到锯齿很严重,这是因为探测步进的速度太快,我们把数值调的低一些,画面就会变得比较细腻: f += Math.max(0.1, 0.01*f);
此时会执行速度会变得很慢,要等一会,但结果好了很多,如下:
4、 加入光照
算法和球类似,用夹角余弦值即可,唯一的问题是如何求碰撞点得法线。
由于我们可以用ground函数求得任意一点的高度,那么我们可以用碰撞点和碰撞点周围几个点高度的偏差,来计算碰撞点的法线
设一个很小的距离0.01,用x轴向左右偏移0.01的两点高度差为x,再用z轴向左右偏移0.01的两点高度差为z,用两边位置差0.02作为y,(x,y,z)归一化得到法线
算法如下:
计算p点的法线
function calcNormal(p) {
return normalize([ground([p[0]-0.01,p[1],p[2]]) - ground([p[0]+0.01,p[1],p[2]]), 0.02, ground([p[0],p[1],p[2]-0.01]) - ground([p[0],p[1],p[2]+0.01])]);
}
有了法线,简单修改一下渲染函数f
ini
var LIGHT = normalize([1,1,1]);
function f(x, y) {
var ro = [0, 0, -Math.sqrt(3)];
var ta = [0, 0, 5000];
var nl = height / 2; //屏幕高度为2,每个单位长度是多少像素
var xy = [(x - width/2) / nl, (y - height/2) / nl];
var rd = normalize([xy[0],xy[1],Math.sqrt(3)]);
var hit = raymarch(ro, rd);
var col;
if (hit != null) {
var nor;
if (hit.type == 1) {
nor = calcNormal(hit.pos);
col = [0.7, 0.7, 0.5];
} else {
nor = [0, 1, 0]; //水面的法线垂直向上
col = [0.0, 0.5, 0.7];
}
var lt = Math.max(0, nor[0]*LIGHT[0]+nor[1]*LIGHT[1]+nor[2]*LIGHT[2]);
col = [col[0]*lt, col[1]*lt, col[2]*lt];
} else {
col = [0, 0, 1];
}
return col;
}
如果嫌步进小渲染速度太慢,那么可以优化一下步进的算法:由于地面没有90度角拔起的悬崖,都是平滑起伏,那么当探测点和地表高度差很大的时候,可以快速步进 f += 0.001+Math.max((pos[1]-y)*.5, 0.001*f);
就可以得到有简单光照强弱的场景
4、 变换视角
之前假设视线是沿z轴,但往往我们需要不同的实现角度来看世界。
实现也很简单,假如我们看向另一个方向,实际上就是主视线向量(就是屏幕中心点的视线向量)从(0,0,1)在三维空间旋转了一下,到了新位置。屏幕上其他所有像素的视线向量也旋转同样的角度即可。
代码如下:
css
function cross(a, b) {
return [a[1]*b[2]-a[2]*b[1], a[2]*b[0]-a[0]*b[2], a[0]*b[1]-a[1]*b[0]];
}
function rotateRD(ro, ta, rd) { //传入眼睛的位置ro,看向的位置ta,和看向z轴时各个像素对应的rd
var cw = normalize([ta[0]-ro[0], ta[1]-ro[1], ta[2]-ro[2]]);
var cp = [0, 1, 0];
var cu = normalize(cross(cw,cp));
var cv = normalize(cross(cu,cw));
//返回旋转后的rd
return [cu[0]*rd[0]+cv[0]*rd[1]+cw[0]*rd[2], cu[1]*rd[0]+cv[1]*rd[1]+cw[1]*rd[2], cu[2]*rd[0]+cv[2]*rd[1]+cw[2]*rd[2]];
}
把之前的rd调用一下这个函数做一下旋转 rd = rotateRD(ro, ta, rd);
调整了视角后,渲染结果如下
四 、用GPU来实现
由于js的速度变得不可接受,此时可以用GPU来加速
浏览器基本都支持openGL的web版本webGL
我们用webGL画一个矩形,刚好撑满屏幕,然后利用片元着色器来填充这个矩形,把js的代码搬进片元着色器,就可以像canvas那样随意在屏幕上逐点画图。另外为了防止放射变幻带来的问题,我们用正交相机。
对于新手自己写有点麻烦,可以用我的例子,或者直接找AI帮你写 这么问它就行:"我想用html+webGL画一个充满屏幕的矩形,用正交相机,通过片元着色器先把矩形画成红色"
代码刷刷的就出来了,静静的伴随着是大家担心失业的心跳声。
其中片元着色器的代码,就是我们要改的部分。
1、 迁移js代码至片元着色器
片元着色器和我们js画图的方式差不多,就是不断调用这段着色器代码,把屏幕上所有点的颜色算出来。
只是他的xy不是函数参数,而是gl_FragCoord这个全局变量,坐标是像素坐标,需要我们自己转换成我们的空间坐标。我们需要自己在js中把屏幕大小传进去,设变量名为iResolution
ini
const iResolution = gl.getUniformLocation(program, 'iResolution');
gl.uniform3fv(iResolution, [canvas.width, canvas.height, 1]);
那么glsl中,js那部分转换坐标的代码就需要改成
ini
vec2 p = (2.0*gl_FragCoord.xy-iResolution.xy) / iResolution.y;
着色器提供了很多函数和类型,我们就不用像js一样自己实现和处理了。 比如2维向量vec2,3维向量vec3,点乘dot,归一化normalize等等,可以自己去找关于glsl的基础文章了解
程序没有什么实际改动,所以结果和前面是完全一样的,就不放了。
2、 鼠标调整视角
由于GPU速度很快,我们可以考虑用鼠标实时调整角度,随时查看渲染结果。
我们把鼠标的xy坐标,像iResolution一样传递给着色器
初始化的时候获取变量
ini
var iMouse = gl.getUniformLocation(program, 'iMouse');
鼠标移动的时候修改参数
ini
var mouse;
function draw(t) { //重画函数
if (mouse != null) //鼠标有动作就传递给shader
gl.uniform4f(iMouse, mouse[0], mouse[1], mouse[2], mouse[3]);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
}
window.addEventListener("mousedown", e => {
mouse = [0,0,1,1];
});
window.addEventListener("mousemove", e => {
if (mouse != null) {
mouse[0] = -e.pageX/window.innerWidth*canvas.width*2;
mouse[1] = -e.pageY/window.innerHeight*canvas.height*2;
requestAnimationFrame(draw); //鼠标有动作就请求重画
}
});
window.addEventListener("mouseup", e => {
mouse = null;
});
完整的例子webGL如下,拖拽可以改变视角
五 、噪声模拟地形
用了GPU计算,就可以上一些更复杂的运算来提高真实度。其中噪声函数是最常见的,噪声函数可以理解为渐变的随机函数。
噪声函数和随机函数的区别,看以下两图就能理解
等我去找两张图
我们应该使用伪随机,伪随机是为了保证每次生成模型时的一致性:比如求x,z的点的高度y,渲染需要对每个像素点并发调用f函数,我希望f函数每次调用高度函数的时候,对于同一个地点的高度,每次结果都必须是一样的,否则结果会完全错乱。
原理文章很多,不再赘述,一个典型的2维噪声函数代码如下,属于基础函数了,代码满天飞,谁写的我也不知道。
二维噪声函数
float n2d(vec2 p) {
vec2 i = floor(p); p -= i;
p *= p*(3. - p*2.);
return dot(mat2(fract(sin(mod(vec4(0, 1, 113, 114) + dot(i, vec2(1, 113)), 6.2831853))*43758.5453))*vec2(1. - p.y, p.y), vec2(1. - p.x, p.x));
}
利用这个代替地形函数
scss
float height(vec2 p) {
return (n2d(p*0.002) - .5) * 1000. - 100.; //可以自定义视角后,水平面调到y=0了
}
执行结果如下:
看起来也没感觉好多少,因为太过光滑了,别急,FBM(Fractional Brownian Motion,分形布朗运动)可以解决这个问题。其基础公式如下:
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> f b m ( p ) = ∑ i = 0 n − 1 A i ∗ n o i s e ( f i ∗ p ) fbm(p) = \sum_{i=0}^{n-1}A_i*noise(f_i*p) </math>fbm(p)=i=0∑n−1Ai∗noise(fi∗p)
简单说就是把多个周期不一样的噪声叠加。最常见的是每次叠加频率翻倍,结果减半,叠加N次后效果就不一样了,代码如下:
css
float fbm(vec2 p) {
float a = .5;
float h = .0;
for (int i=0;i<10;i++) {
h += a*n2d(p);
a *= .5;
p *= 2.;
}
return h;
}
float ground(vec2 p) {
return (fbm(p*0.0015) - .5) * 1000. - 20.;
}
此时效果如下:
看起来好多了,水面和水面法线也类似的办法处理一下:
scss
float water(vec2 p) {
float w = n2d(p*0.1);
w += n2d(p*0.4) * .2;
w += n2d(p*1.5) * .04;
w += n2d(p*5.0) * .008;
return w;
}
vec3 calcWaterNormal(in vec3 pos) {
vec2 eps = vec2( 0.01, 0.0 );
return normalize( vec3(water(pos.xz-eps.xy) - water(pos.xz+eps.xy), 2.0*eps.x, water(pos.xz-eps.yx) - water(pos.xz+eps.yx)) );
}
float raymarch(vec3 ro, vec3 rd, out int type) {
for (float t = 1.; t < 10000.;) {
vec3 pos = ro + t*rd;
float d = 0.001 * t;
float w = water(pos.zx);
if (pos.y - w < d) {
type = 0;
return t;
}
float y = ground(pos.xz);
if (pos.y - y <= d) {
type = 1;
return t;
}
t += 0.001+max((pos.y-max(w,y))*.5, d*2.);
//t += max(0.01, t * 0.01);
}
return 10000.; //未命中
}
顺便在给天空调调色,加上一点渐变
scss
col = mix(vec3(.6, .7, .9), vec3(.35, .62, 1.2), pow(max(rd.y + .15, 0.), .5));
效果如下:
六、高光、环境光与阴影
想让土质更加真实,需要对光照计算的更准确,还要加上阴影。
光追对于阴影的计算是比较擅长的,从碰撞点,向光源方向步进。如果撞山了,就是在阴影内,亮度系数为0,如果什么都没碰到就是有光照,亮度系数为1。
由于光线在物体的边缘会发生衍射,如果距离很远,且步进撞山的位置和山高非常接近,那么需要给亮度系数算个中间值。
代码如下:
ini
float softShadow(in vec3 ro, float dis) {
float start = clamp(dis*0.01,0.1,150.0);
float res = 1.;
vec2 tp;
for( float t=start; t<3000.; t*=2. ) {
vec3 pos = ro + t*LIGHT;
float h = pos.y - ground(pos.xz);
res = min( res, 16.0*h/t );
if( res<0.001 || pos.y>2000. ) break;
}
return clamp( res, 0., 1.0 );
}
把画陆地的代码挪出到一个单独的函数,给陆地加个颜色,除了原来的单纯光照角度决定亮度,改为常用的直接光照+高光这种不准确但好用的模拟,再加上拍脑袋算的环境光。
其中直接光照和高光需要乘以前面的亮度系数。
高光是镜面反射,其反射强度可以用菲涅尔反射定律计算,设入射角度余弦值为fre,我们用Schlick近似公式0.05+0.95*pow(fre,5.0)
来计算
由此得到画陆地的函数
scss
vec3 drawMountain(vec3 pos, vec3 rd, vec3 lgt, float resT) {
vec3 col = vec3(0.18,0.12,0.10)*.85; //给陆地加个颜色
vec3 nor = calcNormal( pos, 0.0005*resT );
float dif = clamp(dot( nor, lgt), 0., 1.);
float ssh = softShadow(pos, resT);
dif *= ssh; //阴影
float bac = clamp(dot(normalize(vec3(-lgt.x,0.0,-lgt.z)),nor),0.,1.);
vec3 lin = 5.5*vec3(1.0,0.9,0.8)*dif; //直接光照
lin += 0.7*vec3(1.1,1.0,0.9)*bac; //随便模拟的背光
col *= lin;
vec3 ref = reflect(rd,nor);
float fre = clamp(1.0+dot(nor,rd),0.,1.);
float spc = clamp(dot(ref,lgt),0.,1.);
float spe = 3.0*pow(spc, 5.0)*(0.05+0.95*pow(fre,5.0));
col += spe*vec3(2.0)*ssh;
return col;
}
把天空也单独挪到一个函数,给天空加一个太阳,视线和光线,点乘后加一个很小的值(太阳的圆面),然后上一个巨大的次方即可。
scss
vec3 drawSky(vec3 rd) {
vec3 c = mix(vec3(.6, .7, .9), vec3(.35, .62, 1.2), pow(max(rd.y + .15, 0.), .5)); //天空
c += pow(max(dot(rd, LIGHT)+.0005, 0.), 3000.); //太阳
return c;
}
效果如下
七、光线追踪下的水面渲染
传统光栅的方法想把水画的真实非常难,但光追的方式就容易太多了。
由于不可能追踪所有光线,光线追踪都是捡主要的,如果某些光线和主要光线存在量级上的差距,就可以忽略它了。
这个是我自己想象的水渲染模型,算法不一定最优,仅供参考。
将光线分三部分计算:
1、 水面反射的光
这部分的比例和刚才高光的地方差不多,遵循菲涅尔反射定律,同样用5次方公式近似 0.02 + 0.98 * pow(1. - max(dot(-rd, normal), 0.), 5.)
它的反射光颜色就很简单了,把当前水面点当作眼睛所在处,反射光线的方向作为视线方向,重新来一次步进+画山画天空的操作就行
画水面反射的景物
vec3 ref = normalize(reflect(rd, normal));
vec3 fCol;
int refType;
float refResT = raymarch(pos, ref, refType);
if (refResT < 10000.) {
vec3 refPos = pos + refResT * ref;
fCol = drawMountain(refPos, ref, LIGHT, refResT + resT);
} else {
fCol = drawSky(ref);
}
2、 水底景物反射出来的光
- 先算折射方向,然后沿折射方向朝水下步进,获得海底碰撞点
- 像画陆地一样计算海底碰撞点的颜色,但需要注意的是,由于折射,水下的光源方向已经和水上有所不同,需要把折射后的方向传给drawMountain。
- 光线到海底有衰减,我们假设每单位长度衰减a,水下步进距离是rT,那么到海底的光线强度应该是
pow(1-a, rT)
- 光线从海底射出同样有衰减,从海底射出水面的距离可以用rT乘以两者光路在y轴上的比值,那么就是
pow(1-a, -rT*ret.y/lightRet.y)
,ret.y和lightRet.y正负相反,所以需要加个负号 我们设a等于0.06,最终结果应该是
scss
vec3 col = drawMountain(pis, ret, lightRet, resT + rT) * pow(0.94, rT-rT*ret.y/lightRet.y);
3、 水自身的颜色
水的颜色,由于水本身存在杂质,光在行进中会不断散射,散射光线往眼睛方向的加总
这里等我哪天画个示意图
视线到海底的光路上任意一点的散射光等于该点的光强*散射系数
设视线从水面沿着光路抵达该点的长度为x,那么光线折射后抵达该点的行进距离应该是-x*ret.y/lightRet.y
,那么光强等于pow(0.94, -x*ret.y/lightRet.y)
假设散射系数为0.01,那么朝人眼的散射光就是0.01*pow(0.94, -x*ret.y/lightRet.y)
但这个光也是要经过x这么长的水域的,他也要衰减,在乘以pow(0.94, x)
,那么最终就是0.01*pow(0.94, x*(1-ret.y/lightRet.y))
然后对它做0到rT的积分
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> c o l o r = ∑ x = 0 r T 0.01 ∗ 0.9 4 x ( 1 − r e t . y / l i g h t R e t . y ) color = \sum_{x=0}^{rT}0.01*0.94^{x(1-ret.y/lightRet.y)} </math>color=x=0∑rT0.01∗0.94x(1−ret.y/lightRet.y)
解得最终值为0.01 / k * (exp(rT*k) - 1.)
其中k = ln(0.94) * (1. - ret.y / lightRet.y)
然后drawSea的代码如下:
ini
vec3 drawSea(vec3 pos, vec3 rd, float resT) {
vec3 seaColor = vec3(0.25, 0.5, 0.75);
vec3 normal = calcWaterNormal(pos, pow(resT*0.05, 2.7)*.0005);
//折射
vec3 ret = refract( rd, normal, 1.0f / 1.3333f );
float rT = 0.01;
vec3 pis;
for(float a; rT < 1000.; rT += 0.01 + max(a*.5, (resT+rT)*.01)) {
pis = pos + rT*ret;
a = pis.y - ground(pis.xz);
if (a <= .01*(resT+rT))
break;
}
vec3 lightRet = -refract(-LIGHT, normal, 1.0f / 1.3333f);
float m = (1. - ret.y / lightRet.y);
float k = log(0.94) * m;
seaColor *= 0.01 / k * (exp(rT*k) - 1.);
if (rT < 1000.) {
vec3 col = drawMountain(pis, ret, lightRet, resT + rT);
seaColor += col * pow(0.94, rT*m);
}
seaColor *= 0.98 - 0.98 * pow(1. - max(dot(LIGHT, normal), 0.), 5.);
//反射
vec3 ref = normalize(reflect(rd, normal));
//ref.y = abs(ref.y);
vec3 fCol;
int refType;
float sun = 0.;
float refResT = raymarch(pos, ref, refType);
if (refResT < 10000.) {
vec3 refPos = pos + refResT * ref;
fCol = drawMountain(refPos, ref, LIGHT, refResT + resT);
} else {
fCol = drawSky(ref);
}
float fresnel = 0.02 + 0.98 * pow(1. - max(dot(-rd, normal), 0.), 5.);
return mix(seaColor, fCol, fresnel);
}
执行一下,效果逐渐变得真实:
可以注意到沿岸的阴影有点问题,这是因为水下的softShadow也有所不同,从海底碰撞点开始,先沿着折射光源方向步进,到了水面上以后,在按着正常光源方向步进,然后计算撞山点。
需要给softShadow加一个参数,阴影步进最大长度。
还需要给drawMountain多加两个参数,一个是传给softShadow的阴影步进最大长度,还有一个是已有阴影强度。然后手工计算已有阴影传给他。中间修改一下陆地的阴影计算dif *= min(waterSsh, ssh)
另外drawSea的画海底部分也要改一下:
drawSea的画海底部分
float ssh = clamp(softShadow(pos, 10000., resT));
vec3 col = drawMountain(pis, ret, lightRet, resT + rT, rT, ssh);
seaColor += col * pow(0.94, rT*m);
未完待续....改天再写
写的好累,离放在shadertoy的那个成品还有不小距离,只是后面越来越抽象,描述清楚越来越困难。
- 有些老代码还没保留,临时重新手写当初的版本,不一定对。
- 不少地方的代码写了有一段时间了,临时重现思路可能有点偏差,回头再检查检查。
有能力的自己先看例子的完整代码吧,不知道要歇几天才会想起来接着写,先列个剩余章节的目录。