《用数学画风景》的个人复现

113 阅读7分钟

复现啥

简单来讲,这是时隔一年的兼具我个人图一乐与水一节课的大作业的高效利用的一个复现,主要跟着Inigo_Quilez的风景画教程随便写写。

但没办法,个人太菜了,所以最终效果与实现方式并不是原模原样的shadertoy上的那么好,最终效果也大致只是如下。

下载.png

技术选型

众所周知,webgl是用来浪费GPU性能的东西(x)。

为了让它少吃一点,我们尝试使用wasm来优化一下(但在需要jswasm多次数据传递的情况下似乎收效甚微)。但在这之前,我们最好还是先使用你当前的任意项目template进行一下glsl的开发(热重载还是很重要的)。

webgl的基本配置

webgl很强大,它可以使用gpu进行并行计算,在速度上远远超越常规的canvas在框架中的串行操作。webgl同时也很弱小,因为它并不包括opengl的所有特性。

webgl基本由vertexShader(顶点着色器)与fragmentShader(片元着色器)组成。其中顾名思义顶点对应canvas空间中的顶点(?),片元则对应顶点间补间的每一个像素(对这个东西非常暴力)。

在这个复现,或者说绝大多数靠数字生成工作的着色器中(我看到的),fragmentShader才是重点。

创建 program

首先需要获取canvasgl

const canvas = document.getElementById('glCanvas') as HTMLCanvasElement
const gl = canvas.getContext('webgl')

接着将你的shaderstring形式传入并编译,再将它们与创建的program链接。

function createShaderProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string) {
    const vertexShader = gl.createShader(gl.VERTEX_SHADER)
    gl.shaderSource(vertexShader!, vertexSource)
    gl.compileShader(vertexShader!)
​
    const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
    gl.shaderSource(fragmentShader!, fragmentSource)
    gl.compileShader(fragmentShader!)
    console.log(gl.getShaderInfoLog(fragmentShader!))
    console.log(gl.getShaderInfoLog(vertexShader!))
​
    const program = gl.createProgram()
    gl.attachShader(program!, vertexShader!)
    gl.attachShader(program!, fragmentShader!)
    gl.linkProgram(program!)
​
    return program
  }

传递参数

uniform

uniform是从前端向glsl中传递的参数,常见的有时间等。这里主要向fragmentShader中传参。主要分两步:

  1. 获取uniform变量的location
  2. 向对应location传参。
const iResolutionLocation = gl.getUniformLocation(program, 'iResolution')
const iTimeLocation = gl.getUniformLocation(program, 'iTime')
const roLocation = gl.getUniformLocation(program, 'ro')
const taLocation = gl.getUniformLocation(program, 'ta')
const camMatLocation = gl.getUniformLocation(program, 'camMat')
​
gl.uniform3fv(roLocation, new Float32Array([data.value.ro.x, data.value.ro.y, data.value.ro.z]))
gl.uniform3fv(taLocation, new Float32Array([data.value.ta.x, data.value.ta.y, data.value.ta.z]))
gl.uniformMatrix3fv(camMatLocation, false, [ data.value.camMat[0].x, ... ])
​

使用的api都在gl里,基本的使用都靠关键词和代码补全就彳亍。

attribute

不同于上述的uniform,这里的attribute我们用来渲染基本的四个点(canvas的四点),且相对操作较为复杂。

  1. 获取location
  2. 创建buffer
  3. 绑定bufferARRAY_BUFFER
  4. 传值
  5. 声明在VertexShader中允许(enable
  6. 声明传入buffer的结构(这点在rust等有类型提示的里面看着更方便
const positionLocation = gl.getAttribLocation(program, 'a_position')
const positionBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
  -1,
  -1,
  1,
  -1,
  -1,
  1,
  1,
  1,
]), gl.STATIC_DRAW)
​
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)

渲染

注意在每次更新的末尾运行gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)来重绘。

Vertex Shader

attribute vec4 a_position;
void main() { gl_Position = a_position; }

Fragment Shader

Init Data

我们要渲染一个3d的环境,首先需要这么几个元素。

  1. 目标位置ta
  2. 相机位置ro
  3. 相机matMat3:其中分别为以相机正视方向为z,水平方向为x,垂直方向为y,计图中坐标系是这么定义的)

其中目标位置与相机位置帮助我们计算出相机对应的mat

这段建议在着色器外实现,毕竟像素量很多,每个都计算一遍这属于不小的工程量。

Search Object

Eye Ray

着色器非常暴力地为我们提供了当前像素的坐标(uv: vec2),我们将其转换到[-1,1]的作用域后,就得到了一个视平面。再为它设定一个常数作为它到相机的距离d: float(相机z轴方向),我们就得到了vec3(uv, d),代表相机指向当前像素的向量(相对于相机坐标系),再将其与先前设定的相机mat相乘(camMat * vec3(uv, d)),就得到了相机指向这一点的向量在世界坐标系中的指代(在这一点的视线)。

好,接下来就是一个有关我将这部分称作“search”的暴力算法了。

Intersect

我们已经知道了当前视线,一眼望去,就是需要渲染的物体。为了实现这部分,我们使用一个名为intersect的暴力算法。简而言之,就是不断递归(?)前进,在一个循环中不断基于上一步的位置。寻找与当前位置最近的obj与当前的距离(SDF),然后在当前的基础上加上这段距离(最好情况一头撞上对应obj)。

通过这个算法我们可以得知当前视线方向距离最近obj的距离。

SDF

目前我的理解是,首先你有一个物体表面的方程,然后你将当前位置的坐标(可用)代入进去,再将剩余部分相减,即能得到当前位置距离该物体的距离(正数代表在物体外,负数代表在物体内)。

简单理解可以使用球体,比方你当前坐标(x,y,z),球心(0,0,0),那么d = length - radius就是这里需要使用的公式。

In Fact

好,上文已经向你展示了渲染物体的理念,然后接下来就该解决多个物体渲染的问题了(本例中需要渲染地面、树、云三类物体)。

地面

非常暴力,无需多言。

我们直接使用大家最爱的fbm来生成对应地点的高度,然后和你当前位置高度一减就获得了SDF。

我们渲染的树并不高级,本质只是一个立着的椭圆。这里原项目作者推荐了一种相比椭圆方程更为高效的方式。

  1. 将椭圆的radius: vec3设置为vec3(.5, 1., .5)这样比例的y轴较高的类型。
  2. w = pos - tree_center
  3. ·d = tree = (len ^ 2 - len) / length(w / r^2)

同样暴力的fbm的使用,与地面的区别为云需要明确的厚度。

组合

简单的组合只需要最暴力的实现方式,运行以上三个SDF,然后找出其中最小的作为值返回。

注意:由于画面中还存在第四元素(天空/背景),我们需要采用一个index变量(默认为-1)来导出我们这次找到的物体(主要是为了保证当为天空时不需要渲染物体)。

Material

Cloud

为了让云层更加自然,我们需要为它增加半透明效果,这里采用相当暴力的方法来实现。

首先我们需要建立一个类似intersect的函数,不同的时这次的递归方向不再是视线而是光线。

我们将视线与云层的焦点作为起点,以光线作为向量计算光线穿过云层的厚度thickness,并以此为系数混合天空和云层的颜色来模拟半透明效果。

Floor && Tree

地面与树木在渲染光线时使用同一函数,不同的时material(颜色)。

vec3 doLighting(in vec3 pos, in vec3 nor, in vec3 rd, in float dis, in vec3 mal,
                in float lamda2, in int index, in vec3 lig) {
阴影

先直接叉乘当前位置的法向与光线向量得到lin

采用原项目作者推荐的一种较为柔和的阴影渲染方式。由当前位置向光照方向移动t位置的一点距离下方最近物体的距离除以t,再通过经典smoothstep处理。将处理后的值与lin相乘,得到最终的阴影系数。

使用雾的渲染来为画面增加层次感。

通过dis物体距离相机距离来计算基本的lamda = exp(-0.1 * dis),这可以使得画面较远处会颜色较浅,产生雾 的感觉。同时再exp中增加* vec3(1., 2., 4.)可以调整雾的颜色,使得颜色与天空颜色相接。

Filter

原作者的神奇魔法,用来模拟相片的质感。

Sun

根据光照方向与视线的点乘,模拟光照在相机上的效果。再根据喜好进行调色。

Photo

在最后返回时,对颜色进行pow处理。这里设置指数为小数,就能做到边界位置的调暗处理,产生类似相片边界的感觉。参数根据实际情况调节。

pow(16. * uv.x * (1. - uv.x) * uv.y * (1. - uv.y), 1. / 20.)