数学模拟下的大自然:雪山大海的日出日落

数学模拟下的大自然:雪山大海的日出日落

这是一些美丽的风景图片,你可能觉得是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的那个成品还有不小距离,只是后面越来越抽象,描述清楚越来越困难。

  • 有些老代码还没保留,临时重新手写当初的版本,不一定对。
  • 不少地方的代码写了有一段时间了,临时重现思路可能有点偏差,回头再检查检查。

有能力的自己先看例子的完整代码吧,不知道要歇几天才会想起来接着写,先列个剩余章节的目录。

八、体积云

九、加入时间的动态画面

十、太阳角度与大气散射

十一、云层优化与阳光散射

十二、山体优化(模型算法、植被、雪、潮湿反光)

相关推荐
此刻我在家里喂猪呢12 小时前
qt之opengl使用
qt·opengl
爱看书的小沐4 天前
【小沐学Web3D】three.js 加载三维模型(Angular)
前端·javascript·vue·webgl·three.js·angular.js·opengl
爱看书的小沐5 天前
【小沐学Web3D】three.js 加载三维模型(React Three Fiber)
javascript·react.js·webgl·three.js·opengl·web3d·reactthreefiber
whoispo8 天前
Imgui处理glfw的鼠标键盘的方法
opengl·imgui·gflw·鼠标键盘事件
阿杰在学习13 天前
基于OpenGL ES实现的Android人体热力图可视化库
android·前端·opengl
彼方卷不动了13 天前
【技术学习】在 Android 上用 Kotlin 实现支持多图层的 OpenGL 渲染管线
android·kotlin·opengl
米芝鱼13 天前
LearnOpenGL(九)自定义转换类
开发语言·c++·算法·游戏·图形渲染·shader·opengl
byxdaz13 天前
OpenGL绘制文本
opengl
龙湾14 天前
OpenGLshader开发实战学习笔记:第一章 初识游戏图形
opengl