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

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

这是一些美丽的风景图片,你可能觉得是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显卡,多谢老婆**❤️**

欢迎转载,请注明作者嚎叫兽:lerrain@gmail.com

下面会一步一步讲述整个开发过程。

一 、 从画一个带光照的球开始

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,分形布朗运动)可以解决这个问题。其基础公式如下:
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) 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的积分
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)} 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的那个成品还有不小距离,只是后面越来越抽象,描述清楚越来越困难。

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

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

八、体积云

九、加入时间的动态画面

十、太阳角度与大气散射

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

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

相关推荐
扶尔魔ocy2 天前
【qt openGL入门笔记】windows编译MinGW版本的osgearth(2.10.1)
qt·opengl·osg
扶尔魔ocy3 天前
【QT OpenGL】知识点编目及传送门
qt·opengl·osg
扶尔魔ocy15 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行并进行态势标绘(四)
qt·opengl·osg
:mnong17 天前
Web 三维可视化技术对比:ParaView Web vs 本项目 wasm + Three.js
前端·webgl·opengl·opencl·科学可视化
扶尔魔ocy18 天前
【qt openGL入门笔记】OSG实现显示飞机模型飞行的点迹(二)
qt·opengl·osg
扶尔魔ocy18 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行(三)
opengl·osg
扶尔魔ocy19 天前
【qt openGL入门笔记】OSG实现飞机模型在网格图上飞行
qt·opengl·osg
扶尔魔ocy23 天前
【qt openGL入门笔记】FPS相机
qt·opengl
扶尔魔ocy23 天前
qt openGL入门笔记】FPS相机和轨道相机
qt·opengl
扶尔魔ocy25 天前
【qt openGL入门笔记】基础光照
qt·opengl