分类: 技術博客

  • Games202 作业一 软阴影实现

    Games202 作业一 软阴影实现

    本文内容:JS和WebGL相关知识、2-pass shadow算法、BIAS缓解自遮挡、PCF算法、PCSS、物体移动。

    项目源代码:

    GitHub – Remyuu/GAMES202-Homework: GAMES202-Homework​

    上面这个图画着好玩。


    写在前面

    由于我对JS以及WebGL一窍不通,只能遇事不决 console.log() 。

    除了作业要求的内容,我在coding的时候也有一些疑问,希望大佬解答QAQ。

    1. 如何实现动态的点光源阴影效果?我们需要使用点光源阴影技术才可以实现万向阴影贴图(omnidirectional shadow maps),具体怎么做?
    2. possionDiskSamples函数并不是真正的泊松圆盘分布?

    框架修正

    在作业开始时请先对作业框架做一些修正。框架改动原文:https://games-cn.org/forums/topic/zuoyeziliao-daimakanwu/

    • 框架提供的 unpack 函数算法实现不准确,在不加 bias 时,会导致严重的 banding(地面一半白一半黑而不是典型的 z-fighting 效果),一定程度上影响作业调试。
    // homework1/src/shaders/shadowShader/shadowFragment.glsl
    vec4 pack (float depth) {
        // 使用rgba 4字节共32位来存储z值,1个字节精度为1/255
        const vec4 bitShift = vec4(1.0, 255.0, 255.0 * 255.0, 255.0 * 255.0 * 255.0);
        const vec4 bitMask = vec4(1.0/255.0, 1.0/255.0, 1.0/255.0, 0.0);
        // gl_FragCoord:片元的坐标,fract():返回数值的小数部分
        vec4 rgbaDepth = fract(depth * bitShift); //计算每个点的z值
        rgbaDepth -= rgbaDepth.gbaa * bitMask; // Cut off the value which do not fit in 8 bits
        return rgbaDepth;
    }
    
    // homework1/src/shaders/phongShader/phongFragment.glsl
    float unpack(vec4 rgbaDepth) {
        const vec4 bitShift = vec4(1.0, 1.0/255.0, 1.0/(255.0*255.0), 1.0/(255.0*255.0*255.0));
        return dot(rgbaDepth, bitShift);
    }
    • 清屏还需要添加一个glClear。
    // homework1/src/renderers/WebGLRenderer.js
    gl.clearColor(0.0, 0.0, 0.0,1.0);// Clear to black, fully opaque
    gl.clearDepth(1.0);// Clear everything
    gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

    JS最基础的知识

    变量

    • 在JavaScript中,我们主要使用 var,let 和 const 这三个关键字来声明变量/常量。
    • var是声明变量的关键字,可以在整个函数范围内使用声明的变量(函数作用域)。
    • let行为与 var 类似,也是声明了一个变量,但是 let 的作用域限制在块中(块作用域),比如 for 循环或 if 语句中定义的块。
    • const:用于声明常量。 const 的作用域也是块级别的。
    • 推荐使用 let 和 const 而不是 var 来声明变量,因为它们遵循块级作用域,更符合大多数编程语言中的作用域规则,更易理解和预测。

    一个基本的JavaScript类的结构如下:

    class MyClass {
      constructor(parameter1, parameter2) {
        this.property1 = parameter1;
        this.property2 = parameter2;
      }
      method1() {
        // method body
      }
      static sayHello() {
        console.log('Hello!');
      }
    }

    创建实例:

    let myInstance = new MyClass('value1', 'value2');
    myInstance.method1(); // 调用类的方法

    也可以直接调用静态类(不用创建实例了):

    MyClass.sayHello();  // "Hello!"

    项目流程简述

    程序入口是engine.js,主函数 GAMES202Main 。首先初始化WebGL相关的内容,包括相机、相机交互、渲染器、光源、物体加载、用户GUI界面以及最重要的主循环main loop部分。

    物体加载过程,会调用loadOBJ.js。首先从文件中加载对应的glsl,构建Phong材质、Phong相关阴影还有阴影的材质。

    // loadOBJ.js
    case 'PhongMaterial':
        material = buildPhongMaterial(colorMap, mat.specular.toArray(), light, Translation, Scale, "./src/shaders/phongShader/phongVertex.glsl", "./src/shaders/phongShader/phongFragment.glsl");
        shadowMaterial = buildShadowMaterial(light, Translation, Scale, "./src/shaders/shadowShader/shadowVertex.glsl", "./src/shaders/shadowShader/shadowFragment.glsl");
        break;
    }

    然后,通过MeshRender直接生成2-pass阴影Shadow Map和常规的Phong材质,具体代码如下:

    // loadOBJ.js
    material.then((data) => {
        // console.log("现在制作表面材质")
        let meshRender = new MeshRender(renderer.gl, mesh, data);
        renderer.addMeshRender(meshRender);
    });
    shadowMaterial.then((data) => {
        // console.log("现在制作阴影材质")
        let shadowMeshRender = new MeshRender(renderer.gl, mesh, data);
        renderer.addShadowMeshRender(shadowMeshRender);
    });

    注意到,MeshRender具备一定的通用性,它接受任何类型的材质作为其参数。具体是怎么区分的呢?通过判断传入的material.frameBuffer是否为空,如果是空,将加载表面材质,否则加载阴影图Shadow Map。在MeshRender.js的draw()函数中,看到如下代码:

    // MeshRender.js
    if (this.material.frameBuffer != null) {
        // Shadow map
        gl.viewport(0.0, 0.0, resolution, resolution);
    } else {
        gl.viewport(0.0, 0.0, window.screen.width, window.screen.height);
    }

    利用MeshRender生成了阴影之后,推入到renderer中,可以在 WebGLRenderer.js 中找到对应实现:

    addShadowMeshRender(mesh) { this.shadowMeshes.push(mesh); }

    最后进入mainLoop()主循环实现一帧帧的更新画面。

    项目流程详细解释

    这一章节将会从一个小问题出发,探讨片段着色器是如何构造的。这将会串联起几乎整个项目,而这也是我认为比较舒服的阅读项目流程。

    glsl是在哪里工作? — 从片段着色器的流程入手详细讲解代码流程

    在上文中我们并没有详细提及glsl文件是怎么调用的,这里我们详细说说。

    首先在loadOBJ.js中首次用过路径的方式将.glsl文件引入:

    // loadOBJ.js - function loadOBJ()
    material = buildPhongMaterial(colorMap, mat.specular.toArray(), light, Translation, Scale, "./src/shaders/phongShader/phongVertex.glsl", "./src/shaders/phongShader/phongFragment.glsl");
    shadowMaterial = buildShadowMaterial(light, Translation, Scale, "./src/shaders/shadowShader/shadowVertex.glsl", "./src/shaders/shadowShader/shadowFragment.glsl");

    这里以phongFragment.glsl为例子,phongFragment.glsl通过位于 PhongMaterial.js 的buildPhongMaterial函数中的 getShaderString方法将glsl代码从硬盘中加载进来,与此同时将glsl代码通过构造参数的形式传入并用之构造一个PhongMaterial对象。PhongMaterial在构造的过程中会调用super()函数实现父类Material.js的构造函数,即将glsl代码传到Material.js中:

    // PhongMaterial.js
    super({...}, [], ..., fragmentShader);

    在c++中,子类可以选择是否完全继承父类的构造函数的参数。这里父类的构造函数有5个,实际只实现了4个,这也是完全没问题的。

    在Material.js中,子类通过构造函数的第四个参数#fsSrc将glsl代码传到了此处。至此,glsl代码的传送之路就走到了尽头,接下来等待他的将是一个名为compile()的函数。

    // Material.js
    this.#fsSrc = fsSrc;
    ...
    compile(gl) {
        return new Shader(..., ..., this.#fsSrc,{...});
    }

    至于这个compile函数什么时候调用呢?回到loadOBJ.js的流程中,现在我们已经完全执行完毕buildPhongMaterial()代码,接下来就到了上一小节提及到的then()部分。

    注意,loadOBJ()只是一个函数,不是对象!

    // loadOBJ.js
    material.then((data) => {
        let meshRender = new MeshRender(renderer.gl, mesh, data);
        renderer.addMeshRender(meshRender);
        renderer.ObjectID[ObjectID][0].push(renderer.meshes.length - 1);
    });

    在构造MeshRender对象时,就会调用compile():

    // MeshRender.js
    constructor(gl, mesh, material) {
    ...
        this.shader = this.material.compile(gl);
    }
    // Material.js
    compile(gl) {
        return new Shader(..., ..., this.#fsSrc,{...});
    }

    接下来,我们具体看一下shader.js的构造。Material在构造shader对象时实现了所有的四个构造参数。这里还是挑重点fsSrc看,即继续看看glsl代码接下来的命运。

    // shader.js
    constructor(gl, vsSrc, fsSrc, shaderLocations) {
        ...
        const fs = this.compileShader(fsSrc, ...);
        ...
    }

    在构造shader对象实现fs编译着色器时是通过compileShader()函数的。这个compileShader函数会创建一个全局变量shader,代码如下:

    // shader.js
    compileShader(shaderSource, shaderType) {
        const gl = this.gl;
        var shader = gl.createShader(shaderType);
        gl.shaderSource(shader, shaderSource);
        gl.compileShader(shader);
    
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            console.error(shaderSource);
            console.error('shader compiler error:\n' + gl.getShaderInfoLog(shader));
        }
    
        return shader;
    };

    这个gl是什么呢?是在loadOBJ()通过构造MeshRender对象时以参数renderer.gl一路传到shader.js中的。而renderer则是loadOBJ()的第一个参数,在engine.js中传入。

    实际上loadOBJ.js中renderer是一个WebGLRenderer对象。而renderer.gl的gl是在engine.js中创建的:

    // engine.js
    const gl = canvas.getContext('webgl');

    而gl可以理解为从index.html中获取canvas的WebGL对象。实际上gl为开发者提供了一个接口来与WebGL API进行交互。

    <!-- index.html -->
    <canvas id="glcanvas"></canvas>

    WebGL推荐参考资料:

    1. https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API
    2. https://webglfundamentals.org
    3. https://www.w3cschool.cn/webgl/vjxu1jt0.html

    Tips:网站都有对应的中文版本,但是有能力的还是推荐阅读英文版本~ WebGL API:

    1. https://developer.mozilla.org/en-US/docs/Web/API
    2. https://webglfundamentals.org/docs/

    知道了gl是什么之后,自然也就发现了项目框架是在哪里通过什么方式与WebGL联系在一起的了。

    // Shader.js
    compileShader(shaderSource, shaderType) {
        const gl = this.gl;
        var shader = gl.createShader(shaderType);
        gl.shaderSource(shader, shaderSource);
        gl.compileShader(shader);
    
        if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
            console.error(shaderSource);
            console.error('shader compiler error:\n' + gl.getShaderInfoLog(shader));
        }
    
        return shader;
    };

    也就是说,所有关于gl的方法都是通过WebGL API调用的。gl.createShader就是我们接触到的第一个WebGL API。

    我们只需知道这个createShader()函数会返回 WebGLShader 着色器对象。我们在后文再详细说明,这里先关注shaderSource究竟何去何从。

    • gl.shaderSource:A string containing the GLSL source code to set.

    也就是说,我们一路追踪的GLSL源代码是通过gl.shaderSource函数解析进了WebGLShader中。

    然后通过gl.compileShader()函数编译WebGLShader,使其成为为二进制数据,然后就可以被WebGLProgram对象所使用。

    简单地说,WebGLProgram是一个包含已编译的WebGL着色器的GLSL程序,至少需要包含一个顶点着色器和一个片段着色器。在WebGL中,会创建一个或多个WebGLProgram对象,每个对象包含一组特定的渲染指令。通过使用不同的WebGLProgram,就可以实现各种画面。

    if语句是检查着色器是否成功编译的部分。如果编译失败,则执行括号内的代码。最后,返回编译后(或尝试编译后)的着色器对象shader。

    至此,我们就完成了将GLSL文件从硬盘中取出最后编译进着色器对象的工作。

    但是渲染的流程还没有结束。回到Shadow对象的构造处:

    // Shadow.js
    class Shader {
        constructor(gl, vsSrc, fsSrc, shaderLocations) {
            this.gl = gl;
            const vs = this.compileShader(vsSrc, gl.VERTEX_SHADER);
            const fs = this.compileShader(fsSrc, gl.FRAGMENT_SHADER);
    
            this.program = this.addShaderLocations({
                glShaderProgram: this.linkShader(vs, fs),
            }, shaderLocations);
        }
        ...

    虽然刚才我们只解说了片段着色器的GLSL编译流程,但是顶点着色器也是相当类似的,故此省略。


    这里我们介绍linkShader()链接着色器的流程。代码在文字的下方。

    1. 首先创建一个创建程序命名为WebGLProgram。
    2. 将编译后的顶点着色器和片段着色器vs和fs添加到程序中,这一步叫做附加着色器。具体而言是使用gl.attachShader()将他们附加到WebGLProgram上。
    3. 使用gl.linkProgram()链接WebGLProgram。这会生成一个可执行的程序,该程序结合了前面附加的着色器。这一步叫做链接程序
    4. 最后检查链接状态,返回WebGL对象。
    // Shader.js
    linkShader(vs, fs) {
        const gl = this.gl;
        var prog = gl.createProgram();
        gl.attachShader(prog, vs);
        gl.attachShader(prog, fs);
        gl.linkProgram(prog);
    
        if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
            abort('shader linker error:\n' + gl.getProgramInfoLog(prog));
        }
        return prog;
    };

    WebGLProgram可以被视为着色器的容器,它包含了将3D数据转换为屏幕上的2D像素所需的全部信息和指令。


    得到与着色器链接的程序glShaderProgram后,会与shaderLocations对象一同被载入。

    简单地说shaderLocations对象包含了两个属性

    • Attributes是”个体”的数据(比如每个顶点的信息)
    • Uniforms是”整体”的数据(比如一个灯光的信息)

    框架将载入的流程打包进了addShaderLocations()中。简单地说,经过这一步操作之后,当你需要给这些uniform和attribute赋值时,就可以直接通过已经获取到的位置进行操作,而不需要每次都去查询位置。

    addShaderLocations(result, shaderLocations) {
        const gl = this.gl;
        result.uniforms = {};
        result.attribs = {};
    
        if (shaderLocations && shaderLocations.uniforms && shaderLocations.uniforms.length) {
            for (let i = 0; i < shaderLocations.uniforms.length; ++i) {
                result.uniforms = Object.assign(result.uniforms, {
                    [shaderLocations.uniforms[i]]: gl.getUniformLocation(result.glShaderProgram, shaderLocations.uniforms[i]),
                });
            }
        }
        if (shaderLocations && shaderLocations.attribs && shaderLocations.attribs.length) {
            for (let i = 0; i < shaderLocations.attribs.length; ++i) {
                result.attribs = Object.assign(result.attribs, {
                    [shaderLocations.attribs[i]]: gl.getAttribLocation(result.glShaderProgram, shaderLocations.attribs[i]),
                });
            }
        }
    
        return result;
    }

    回顾一下目前已经完成的工作:成功构建好了一个编译后(或尝试编译后)的Shader着色器对象给MeshRender:

    // MeshRender.js - construct()
    this.shader = this.material.compile(gl);

    至此,loadOBJ的任务已经圆满完成。在engine.js中,这样的加载要做三次:

    // loadOBJ(renderer, path, name, objMaterial, transform, meshID);
    loadOBJ(renderer, 'assets/mary/', 'Marry', 'PhongMaterial', obj1Transform);
    loadOBJ(renderer, 'assets/mary/', 'Marry', 'PhongMaterial', obj2Transform);
    loadOBJ(renderer, 'assets/floor/', 'floor', 'PhongMaterial', floorTransform);

    接下来来到程式主循环mainLoop。也即,一个循环表示一帧:

    // engine.js
    loadOBJ(...);
    ...
    function mainLoop() {...}
    ...

    程序主循环 — mainLoop()

    实际上,执行mainLoop,该函数会再次调用自己,形成一个无限循环。这就是所谓的游戏循环或动画循环的基础机制。

    // engine.js
    function mainLoop() {
        cameraControls.update();
        renderer.render();
        requestAnimationFrame(mainLoop);
    };
    requestAnimationFrame(mainLoop);

    cameraControls.update();在更新相机的位置或方向,例如响应用户的输入。

    renderer.render();场景被渲染或绘制到屏幕上。具体的渲染内容和方式取决于renderer对象的实现。

    requestAnimationFrame的好处是它会尽量与屏幕的刷新率同步,这样可以提供更流畅的动画和更高的性能,因为它不会在屏幕刷新之间无谓地执行代码。

    关于requestAnimationFrame()函数的详细信息可以参考以下文章: https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame

    接下来重点关心render()函数的运作。

    render()渲染函数

    这是一个典型的光源渲染、阴影渲染和最终摄像机视角渲染的流程。此处就不详细展开了,放到后面的多光源部分。

    // WebGLRenderer.js - render()
    const gl = this.gl;
    
    gl.clearColor(0.0, 0.0, 0.0, 1.0); // shadowmap默认白色(无遮挡),解决地面边缘产生阴影的问题(因为地面外采样不到,默认值为0会认为是被遮挡)
    gl.clearDepth(1.0);// Clear everything
    gl.enable(gl.DEPTH_TEST); // Enable depth testing
    gl.depthFunc(gl.LEQUAL); // Near things obscure far things
    
    console.assert(this.lights.length != 0, "No light");
    console.assert(this.lights.length == 1, "Multiple lights");
    
    for (let l = 0; l < this.lights.length; l++) {
        gl.bindFramebuffer(gl.FRAMEBUFFER, this.lights[l].entity.fbo);
        gl.clear(gl.DEPTH_BUFFER_BIT);
        // Draw light
        // TODO: Support all kinds of transform
        this.lights[l].meshRender.mesh.transform.translate = this.lights[l].entity.lightPos;
        this.lights[l].meshRender.draw(this.camera);
    
        // Shadow pass
        if (this.lights[l].entity.hasShadowMap == true) {
            for (let i = 0; i < this.shadowMeshes.length; i++) {
                this.shadowMeshes[i].draw(this.camera);
            }
        }
    }
    // Camera pass
    for (let i = 0; i < this.meshes.length; i++) {
        this.gl.useProgram(this.meshes[i].shader.program.glShaderProgram);
        this.gl.uniform3fv(this.meshes[i].shader.program.uniforms.uLightPos, this.lights[0].entity.lightPos);
        this.meshes[i].draw(this.camera);
    }

    GLSL快速入门 — 分析片段着色器FragmentShader.glsl

    上文我们讨论了如何载入GLSL,这一章节介绍GLSL的概念与实际用法。

    在WebGL中进行渲染时,我们需要至少一个 顶点着色器(Vertex Shader) 和一个 片段着色器(Fragment Shader) 才能绘制出一幅画面。上一节我们以片段着色器为例,介绍了框架是怎么将GLSL文件从硬盘读取进renderer的。接下来我们也以Flagment Shader片段着色器为例子(即phongFragment.glsl),介绍编写GLSL的流程。

    FragmentShader.glsl有什么用?

    Fragment Shader的作用是在光栅化的时候为当前像素渲染正确的颜色。以下是一个Fragment Shader的最简单形式,其包含一个main()函数,在函数其中指定了当前像素的颜色gl_FragColor。

    void main(void){
        ...
        gl_FragColor = vec4(Color, 1.0);
    }

    Fragment Shader接受什么数据?

    Fragment Shader需要知道数据,数据是由以下三种主要方式提供的,具体的用法可以参考 附录1.6

    1. Uniforms (全局变量): 这些是在单个绘制调用中对所有顶点和片段都保持不变的值。常见的例子包括变换矩阵(平移旋转等操作)、光源参数和材质属性。由于它们在绘制调用中是恒定的,所以称为“uniform”。
    2. Textures (纹理): 纹理是图像数据数组,它们可以被片段着色器采样来为每个片段获得颜色、法线或其他类型的信息。
    3. Varyings (可变量): 这些是顶点着色器输出的值,它们在图形基元(如三角形)的顶点之间插值,并传递给片段着色器。这允许我们在顶点着色器中计算值(如变换后的位置或顶点颜色),并在片段之间进行插值,以便在片段着色器中使用。

    项目中用了Uniforms和Varyings两种。

    GLSL基本语法

    这里不会把基本的用法过一篇,因为那样太无聊了。我们直接看项目:

    // phongFragment.glsl - PCF pass
    void main(void) {
        // 声明变量
        float visibility;     // 可见性(用于阴影)
        vec3 shadingPoint;     // 从光源处的视点坐标
        vec3 phongColor;      // 计算出的Phong光照颜色
    
        // 将vPositionFromLight的坐标值归一化到[0,1]范围内
        shadingPoint = vPositionFromLight.xyz / vPositionFromLight.w;
        shadingPoint = shadingPoint * 0.5 + 0.5; // 进行坐标转换,使其在[0,1]范围内
    
        // 计算可见性(阴影)。
        visibility = PCF(uShadowMap, vec4(shadingPoint, 1.0)); // 使用PCF(Percentage Closer Filtering)技术
    
        // 使用blinnPhong()函数计算Phong光照颜色
        phongColor = blinnPhong();
    
        // 计算最终的片段颜色,将Phong光照颜色与可见性相乘,得到考虑阴影的片段颜色
        gl_FragColor = vec4(phongColor * visibility, 1.0);
    }

    和c语言一样,glsl是强类型语言,你不能这样赋值:float visibility = 1;,因为1是int类型。

    矢量或矩阵

    另外,glsl还内置了很多特别的类型,比如浮点类型向量vec2, vec3和 vec4,矩阵类型mat2, mat3 和 mat4。

    上面这些数据的访问方式也比较有意思,

    • .xyzw:通常用于表示三维或四维空间中的点或向量。
    • .rgba:当向量表示颜色时使用,其中r代表红色,g代表绿色,b代表蓝色,a代表透明度。
    • .stpq:当向量用作纹理坐标时使用。

    因此,

    • v.x 与 v[0] 与 v.r 与 v.s 都表示该向量的第一个分量。
    • v.y 与 v[1] 与 v.g 与 v.t 都表示该向量的第二个分量。
    • 对于vec3和vec4,v.z 与 v[2] 与 v.b 与 v.p 都表示该向量的第三个分量。
    • 对于vec4,v.w 与 v[3] 与 v.a 与 v.q 都表示该向量的第四个分量。

    你甚至可以使用一种叫“分量重组”或“分量选择”的方式访问这些类型的数据:

    1. 重复某个分量:
    2. v.yyyy 会得到一个新的vec4,其中每个分量都是原始v的y分量。这与vec4(v.y, v.y, v.y, v.y)的效果相同。
    3. 交换分量:
    4. v.bgra 会得到一个新的vec4,其中的分量按照b, g, r, a的顺序从v中选取。这与vec4(v.b, v.g, v.r, v.a)的效果相同。

    当构造一个矢量或矩阵时可以一次提供多个分量,例如:

    • vec4(v.rgb, 1)与vec4(v.r, v.g, v.b, 1)是等价的
    • vec4(1) 与vec(1, 1, 1, 1)也是等价的

    参考资料:GLSL语言规范 https://www.khronos.org/files/opengles_shading_language.pdf

    矩阵存储方式

    这些提示都可以在glmatrix的Doc中找到:https://glmatrix.net/docs/mat4.js.html。另外,如果看得仔细我们会发现这个组件也都是用列优先存储矩阵的,WebGL和GLSL中也是列有限存储。如下所示:

    将一个物体移动到一个新的位置,可以用mat4.translate()函数,并且这个函数接受三个参数分别是:一个4×4的输出out,传入的4×4矩阵a,一个1×3的位移矩阵v。

    最简单的矩阵乘法可以使用mat4.multiply,缩放矩阵使用mat4.scale(),调整“看向”的方向使用mat4.lookAt(),正交投影矩阵mat4.ortho()。

    实现光源相机的矩阵变换

    如果我们用透视投影操作,则是这里需要将下面Frustum放缩到一个正交视角的空间,如下图所示:

    但是如果我们使用正交投影,那么就可以保持深度值的线性,使得 Shadow Map 的精度尽可能大。

    // DirectionalLight.js - CalcLightMVP()
    let lightMVP = mat4.create();
    let modelMatrix = mat4.create();
    let viewMatrix = mat4.create();
    let projectionMatrix = mat4.create();
    
    // Model transform
    mat4.translate(modelMatrix, modelMatrix, translate);
    mat4.scale(modelMatrix, modelMatrix, scale);
    
    // View transform
    mat4.lookAt(viewMatrix, this.lightPos, this.focalPoint, this.lightUp);
    
    // Projection transform
    let left = -100.0, right = -left, bottom = -100.0, top = -bottom, 
        near = 0.1, far = 1024.0;  
        // Set these values as per your requirement
    mat4.ortho(projectionMatrix, left, right, bottom, top, near, far);
    
    
    mat4.multiply(lightMVP, projectionMatrix, viewMatrix);
    mat4.multiply(lightMVP, lightMVP, modelMatrix);
    
    return lightMVP;

    2-Pass Shadow 算法

    在实现两趟算法之前,先看看main()函数是怎么调用的。

    // phongFragment.glsl
    void main(void){  
      vec3 shadingPoint = vPositionFromLight.xyz / vPositionFromLight.w;
      shadingPoint = shadingPoint*0.5+0.5;// 归一化至 [0,1]
    
      float visibility = 1.0;
      visibility = useShadowMap(uShadowMap, vec4(shadingPoint, 1.0));
    
      vec3 phongColor = blinnPhong();
    
      gl_FragColor=vec4(phongColor * visibility,1.0);
    }

    那么问题来了,vPositionFromLight是怎么来的?是在顶点着色器中算出来的。

    统一空间坐标

    说人话就是,将场景的顶点的世界坐标转换为光相机的NDC空间对应的新坐标。目的为了渲染主相机的某个Shading Point的阴影时,可以在光源相机的空间中取出所需的深度值。

    vPositionFromLight表示从光源的视角看到的一个点的齐次坐标。这个坐标在光源的正交空间中,其范围是[-w, w]。他是由phongVertex.glsl计算出来的。phongVertex.glsl的作用是处理输入的顶点数据,通过上一章计算的MVP矩阵将一系列顶点转化为裁剪空间坐标。将vPositionFromLight转换到NDC标准空间得到shadingPoint,就可以将shadingPoint里面这些需要做阴影判断的Shading Point传入useShadowMap函数中。附上顶点转换的相关代码:

    // phongVertex.glsl - main()
    vFragPos = (uModelMatrix * vec4(aVertexPosition, 1.0)).xyz;
    vNormal = (uModelMatrix * vec4(aNormalPosition, 0.0)).xyz;
    
    gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix *
                vec4(aVertexPosition, 1.0);
    
    vTextureCoord = aTextureCoord;
    vPositionFromLight = uLightMVP * vec4(aVertexPosition, 1.0);

    phongVertex.glsl是和phongFragment.glsl一同在loadOBJ.js中被加载的。

    比对深度值

    接下来实现useShadowMap()函数。这个函数的目的是为了确定片段(像素)是否在阴影中。

    texture2D() 是一个GLSL的内置函数,用于对2D纹理进行采样。

    代码框架中的unpack()和pack()函数是为了增加数值精度而设置的。原因如下:

    • 深度信息是一个连续的浮点数,它的范围和精度可能超出了一个8位通道所能提供的。直接将这样的深度值存储在一个8位通道中会导致大量的精度丢失,从而导致阴影效果不正确。因此我们可以充分利用其他的三个通道,也就是将深度值编码到多个通道中。通过分配深度值的不同部分到R, G, B, A四个通道,我们可以用更高的精度来存储深度值。当我们需要使用深度值时,就可以从这四个通道解码出来。

    closestDepthVec是blocker的深度信息,

    最后,closestDepth与currentDepth进行比对,如果blocker(closestDepth)比主相机要渲染的片元的深度值(shadingPoint.z)大,说明当前的Shading Point没有被遮挡,visibility返回1.0。另外为了解决一些阴影痤疮和自遮挡问题,可以将blocker的位置调大一些,即加上EPS。

    // phongFragment.glsl
    float useShadowMap(sampler2D shadowMap, vec4 shadingPoint){
      // Retrieve the closest depth value from the light's perspective using the fragment's position in light space.
      float closestDepth = unpack(texture2D(shadowMap, shadingPoint.xy));
      // Compare the fragment's depth with the closest depth to determine if it's in shadow.
      return (closestDepth + EPS + getBias(.4)> shadingPoint.z) ? 1.0 : 0.0;
    }

    其实目前还是有点问题。我们目前的光源相机并不是万向的,也就是说其照射范围只有一小部分。如果模型在lightCam的范围内,那么画面是完全正确的。

    但是当模型在lightCam的范围外,就不应该参与useShadowMap的计算。但是目前我们并没有完成相关的逻辑。也就是说,如果在lightCam的MVP变换矩阵范围之外的位置在经过计算之后可能会出现意想不到的错误。再看一下灵魂示意图:

    上一节我们在定向光源脚本中定义了zFar、zNear等信息。如下代码所示:

    // DirectionalLight.js - CalcLightMVP()
    let left = -100.0, right = -left, bottom = -100.0, top = -bottom, near = 0.1, far = 1024.0;

    因此,为了解决模型在lightCam范围之外的问题,我们在useShadowMap或在useShadowMap之前的代码中,加入以下逻辑以剔除不在lightCam范围的采样点:

    // phongFragment.glsl - main()
    ...
    if(shadingPoint.x<0.||shadingPoint.x>1.||
       shadingPoint.y<0.||shadingPoint.y>1.){
      visibility=1.;// 光源看不见的地方,因此不会被阴影所覆盖
    }else{
      visibility=useShadowMap(uShadowMap,vec4(shadingPoint,1.));
    }
    ...

    效果如下图所示,左边是做了剔除逻辑的,右边是没有做剔除逻辑的。当202酱移动到lightCam的视锥体边界时,她就直接被截肢了,非常吓人:

    当然了,不完成这一步也没问题。实际上,在开发中我们会使用万向光源,即lightCam是360度全方位的,我们只需要剔除那些在zFar平面之外的点就可以了。

    添加bias改善自遮挡问题

    当我们从光源的视角渲染深度图时,由于浮点数精度的限制,可能会出现误差。因此,当我们在主渲染过程中使用深度图时,可能会看到物体自己的阴影,这称为自遮挡或阴影失真。

    在完成了2-pass渲染之后,我们会在202酱的头发等多处位置发现了这样的阴影痤疮,十分不美观。如下图所示:

    我们理论上可以通过添加bias缓解自遮挡问题。这里我提供一种动态调整bias的方法:

    // phongFragment.glsl
    // 使用bias偏移值优化自遮挡
    float getBias(float ctrl) {
      vec3 lightDir = normalize(uLightPos);
      vec3 normal = normalize(vNormal);
      float m = 200.0 / 2048.0 / 2.0; // 正交矩阵宽高/shadowmap分辨率/2
      float bias = max(m, m * (1.0 - dot(normal, lightDir))) * ctrl;
      return bias;
    }

    首先当光线和法线几乎垂直的时候,极有可能发生自遮挡现象,比如我们的202酱的后脑勺处。因此我们需要获取光线的方向与法线的方向。其中,m表示光源视图下每个像素代表的场景空间的大小。

    最后将 phongFragment.glsl 的 useShadowMap() 改为下文:

    // phongFragment.glsl
    float useShadowMap(sampler2D shadowMap, vec4 shadingPoint){
      ...
      return (closestDepth + EPS + getBias(.3)> shadingPoint.z) ? 1.0 : 0.0;
    }

    效果如下:

    需要注意,较大的bias值可能导致过度矫正带来的阴影缺失结果,较小的值又可能起不到改善痤疮的效果,因此需要多次尝试。

    PCF

    但是ShadowMap的分辨率是有限的。实际游戏中,ShadowMap的分辨率是远远小于分辨率的(原因是性能消耗太大),因此我们需要一种柔化锯齿的方法。PCF方法就是在ShadowMap上为每个像素取其周边的多个像素做平均计算出Shading Point的。

    最初人们想用这个方法软化阴影,但是做到后面发现这个方法可以做到软阴影的效果。

    在使用PCF算法估计阴影比例之前,我们需要准备一组采样点。对于PCF阴影,在移动设备我们只会采用4-8个采样点,而高质量的画面则来到16-32个。在这一节我们使用8个采样点,在这个基础上通过调整生成的样本的参数从而改进画面,减少噪点等等。

    但是,以上不同采样方式对于最终的画面影响其实不算特别大,最影响画面的其实是做PCF时候的阴影贴图大小,也就是shadow map的大小。具体来说,是代码中的textureSize,但是一般而言这一项在项目中都是固定一个值。

    所以我们接下来的思路是先实现PCF,最后再微调采样方式。

    毕竟,premature optimization是大忌。

    实现PCF

    在main()中,修改使用的阴影算法。

    // phongFragment.glsl
    void main(void){  
        ...
        visibility = PCF(uShadowMap, vec4(shadingPoint, 1.0));
        ...
    }

    shadowMap.xy 是用于在阴影贴图上采样的纹理坐标,shadowMap.z 是该像素的深度值。

    采样函数要求我们传入一个Vec2变量作为随机种子,接着会在一个半径为1的圆域内返回随机的点。

    接着将$[0, 1]^2$的uv坐标中分成textureSize份,设置好滤波窗口之后,就在当前的shadingPoint位置附近采样多次,最后统计:

    // phongFragment.glsl
    float PCF(sampler2D shadowMap,vec4 shadingPoint){
      // 采样 采样结果会返回到全局变量 - poissonDisk[]
      poissonDiskSamples(shadingPoint.xy);
    
      float textureSize=256.; // shadow map 的大小, 越大滤波的范围越小
      float filterStride=1.; // 滤波的步长
      float filterRange=1./textureSize*filterStride; // 滤波窗口的范围
      int noShadowCount=0; // 有多少点不在阴影里
      for(int i=0;i<NUM_SAMPLES;i++){
        vec2 sampleCoord=poissonDisk[i]*filterRange+shadingPoint.xy;
        vec4 closestDepthVec=texture2D(shadowMap,sampleCoord);
        float closestDepth=unpack(closestDepthVec);
        float currentDepth=shadingPoint.z;
        if(currentDepth<closestDepth+EPS){
          noShadowCount+=1;
        }
      }
      return float(noShadowCount)/float(NUM_SAMPLES);
    }

    效果如下:

    image-20230805213129275

    poissonDisk采样参数设置

    在作业框架中,我发现这个possionDiskSamples函数并不是真正的泊松圆盘分布?有点奇怪。个人感觉更像是均匀分布在螺旋线上的点。希望读者朋友可以指导一下。我首先先按照框架中的代码分析。


    框架中poissonDiskSamples的相关数学公式

    // phongFragment.glsl
    float ANGLE_STEP = PI2 * float( NUM_RINGS ) / float( NUM_SAMPLES );
    float INV_NUM_SAMPLES = 1.0 / float( NUM_SAMPLES );
    float angle = rand_2to1( randomSeed ) * PI2;
    float radius = INV_NUM_SAMPLES;
    float radiusStep = radius;

    转换极坐标为笛卡尔坐标: 更新规则: 半径变化:

    具体代码如下:

    // phongFragment.glsl
    vec2 poissonDisk[NUM_SAMPLES];
    
    void poissonDiskSamples( const in vec2 randomSeed ) {
      float ANGLE_STEP = PI2 * float( NUM_RINGS ) / float( NUM_SAMPLES );
      float INV_NUM_SAMPLES = 1.0 / float( NUM_SAMPLES );// 把样本放在了一个半径为1的圆域内
    
      float angle = rand_2to1( randomSeed ) * PI2;
      float radius = INV_NUM_SAMPLES;
      float radiusStep = radius;
    
      for( int i = 0; i < NUM_SAMPLES; i ++ ) {
        poissonDisk[i] = vec2( cos( angle ), sin( angle ) ) * pow( radius, 0.75 );
        radius += radiusStep;
        angle += ANGLE_STEP;
      }
    }

    也就是说,以下参数我们可以调整:

    • 半径变化指数的选取

    关于作业框架中为什么要用0.75这个数字,我做了一个比较形象的动画,展示了在泊松采样时每个结果坐标与圆心的距离(半径)的指数在0.2到1.1之间的变化,也就是说,当数值取到0.75以上时,基本可以认为数据重心会更偏向于取圆心的位置。下面动画的代码我放在了 附录1.2 中,读者可以自行编译调试。

    上面是一则视频,若您是PDF版本则需要前往网站查看。

    • 绕圈数NUM_RINGS

    NUM_RINGS与NUM_SAMPLES一起用来计算每个采样点之间的角度差ANGLE_STEP。

    此时可以有如下分析:

    如果NUM_RINGS等于NUM_SAMPLES,那么ANGLE_STEP将等于$2π$,这意味着每次迭代中的角度增量都是一个完整的圆,这显然没有意义。如果NUM_RINGS小于NUM_SAMPLES,那么ANGLE_STEP将小于$2π$,这意味着每次迭代中的角度增量都是一个圆的部分。如果NUM_RINGS大于NUM_SAMPLES,那么ANGLE_STEP将大于$2π$,这意味着每次迭代中的角度增量都超过了一个圆,这可能会导致覆盖和重叠。

    所以在这个代码框架中,当我们的采样数固定时(我这里是8),我们就可以采取决策让采样点更加均匀的分布。

    因此理论上,这里NUM_RINGS直接设置为1就可以了。

    上面是一则视频,若您是PDF版本则需要前往网站查看。

    当采样点分布均匀的情况下,效果还不错:

    如果采样非常不均匀,比如NUM_RINGS等于NUM_SAMPLES的情况,就会出现比较脏的画面:

    得到这些采样点之后,我们还可以对采样点进行权重分配处理。比如在202的课程上闫老师提到可以根据原始像素的距离设置不同的权重,更远的采样点可能会被赋予较低的权重,但项目中不涉及这部分的代码。

    PCSS

    首先找到Shadow Map中任意一处uv坐标的AVG Blocker Depth。

    float findBlocker(sampler2D shadowMap,vec2 uv,float z_shadingPoint){
      float count=0., depth_sum=0., depthOnShadowMap, is_block;
      vec2 nCoords;
      for(int i=0;i<BLOCKER_SEARCH_NUM_SAMPLES;i++){
        nCoords=uv+BLOKER_SIZE*poissonDisk[i];
    
        depthOnShadowMap=unpack(texture2D(shadowMap,nCoords));
        if(abs(depthOnShadowMap) < EPS)depthOnShadowMap=1.;
        // step函数用于比较两个值。
        is_block=step(depthOnShadowMap,z_shadingPoint-EPS);
        count+=is_block;
        depth_sum+=is_block*depthOnShadowMap;
      }
      if(count<EPS)
        return z_shadingPoint;
      return depth_sum/count;
    }

    三步走,这里不再赘述,跟着理论公式走都不太难。

    image-20230731142003749
    float PCSS(sampler2D shadowMap,vec4 shadingPoint){
      poissonDiskSamples(shadingPoint.xy);
      float z_shadingPoint=shadingPoint.z;
      // STEP 1: avgblocker depth
      float avgblockerdep=findBlocker(shadowMap,shadingPoint.xy,z_shadingPoint);
      if(abs(avgblockerdep - z_shadingPoint) <= EPS) // No Blocker
        return 1.;
    
      // STEP 2: penumbra size
      float dBlocker=avgblockerdep,dReceiver=z_shadingPoint-avgblockerdep;
      float wPenumbra=min(LWIDTH*dReceiver/dBlocker,MAX_PENUMBRA);
    
      // STEP 3: filtering
      float _sum=0.,depthOnShadowMap,vis;
      vec2 nCoords;
      for(int i=0;i<NUM_SAMPLES;i++){
        nCoords=shadingPoint.xy+wPenumbra*poissonDisk[i];
    
        depthOnShadowMap=unpack(texture2D(shadowMap,nCoords));
        if(abs(depthOnShadowMap)<1e-5)depthOnShadowMap=1.;
    
        vis=step(z_shadingPoint-EPS,depthOnShadowMap);
        _sum+=vis;
      }
    
      return _sum/float(NUM_SAMPLES);
    }

    框架部分解析

    这一部分属于是在我随便翻阅代码的时候写下的注释,在这里稍微整理了一下。

    loadShader.js

    虽然这个文件中两个函数都是加载glsl文件,但是后者的getShaderString(filename)函数更加简洁高级。这主要体现在前者返回的是Promise对象,后者直接返回文件内容。关于Promise的内容可以看本文的 附录1.3 – JS的Promise简单用法 ,关于async await的内容可以看本文 附录1.4 – async await介绍,关于.then()的用法可以查看 附录1.5 – 关于.then

    专业一点的说法就是,这两个函数提供了不同级别的抽象。前者提供了直接加载文件的原子级别能力,拥有更细粒度的控制,而后者更加简洁与方便。

    添加物体平移效果

    控制器添加到GUI上

    每一帧都要计算阴影的消耗是很大的,这里我手动创建光源控制器,手动调节是否需要每一帧都计算一次阴影。此外,当Light Moveable取消勾选的时候禁止用户改变光源位置:

    勾选上Light Moveable后,出现lightPos选项框:

    具体代码实现:

    // engine.js
    // Add lights
    // light - is open shadow map == true
    let lightPos = [0, 80, 80];
    let focalPoint = [0, 0, 0]; // 定向光聚焦方向(起点是lightPos)
    let lightUp = [0, 1, 0]
    const lightGUI = {// 光源移动控制器,如果不勾选,则不会重新计算阴影。
        LightMoveable: false,
        lightPos: lightPos
    };
    ...
    function createGUI() {
        const gui = new dat.gui.GUI();
        const panelModel = gui.addFolder('Light properties');
        const panelCamera = gui.addFolder("OBJ properties");
        const lightMoveableController = panelModel.add(lightGUI, 'LightMoveable').name("Light Moveable");
        const arrayFolder = panelModel.addFolder('lightPos');
        arrayFolder.add(lightGUI.lightPos, '0').min(-10).max( 10).step(1).name("light Pos X");
        arrayFolder.add(lightGUI.lightPos, '1').min( 70).max( 90).step(1).name("light Pos Y");
        arrayFolder.add(lightGUI.lightPos, '2').min( 70).max( 90).step(1).name("light Pos Z");
        arrayFolder.domElement.style.display = lightGUI.LightMoveable ? '' : 'none';
        lightMoveableController.onChange(function(value) {
            arrayFolder.domElement.style.display = value ? '' : 'none';
        });
    }

    附录1.1

    import numpy as np
    import matplotlib.pyplot as plt
    
    def simulate_poisson_disk_samples(random_seed, num_samples=100, num_rings=2):
        PI2 = 2 * np.pi
        ANGLE_STEP = PI2 * num_rings / num_samples
        INV_NUM_SAMPLES = 1.0 / num_samples
    
        # Initial angle and radius
        angle = random_seed * PI2
        radius = INV_NUM_SAMPLES
        radius_step = radius
    
        x_vals = []
        y_vals = []
    
        for _ in range(num_samples):
            x = np.cos(angle) * pow(radius, 0.1)
            y = np.sin(angle) * pow(radius, 0.1)
    
            x_vals.append(x)
            y_vals.append(y)
    
            radius += radius_step
            angle += ANGLE_STEP
    
        return x_vals, y_vals
    
    plt.figure(figsize=(8, 8))
    
    # Generate and plot the spiral 5 times with different random seeds
    for _ in range(50):
        random_seed = np.random.rand()
        x_vals, y_vals = simulate_poisson_disk_samples(random_seed)
        plt.plot(x_vals, y_vals, '-o', markersize=5, linewidth=2)
    
    plt.title("Poisson Disk Samples")
    plt.axis('on')
    plt.gca().set_aspect('equal', adjustable='box')
    plt.show()

    附录1.2 – 泊松采样点后处理动画代码

    说明:附录1.2 的代码直接基于 附录1.1 修改而成。

    import numpy as np
    import matplotlib.pyplot as plt
    from matplotlib.animation import FuncAnimation
    
    def simulate_poisson_disk_samples_with_exponent(random_seed, exponent, num_samples=100, num_rings=2):
        PI2 = 2 * np.pi
        ANGLE_STEP = PI2 * num_rings / num_samples
        INV_NUM_SAMPLES = 1.0 / num_samples
    
        angle = random_seed * PI2
        radius = INV_NUM_SAMPLES
        radius_step = radius
    
        x_vals = []
        y_vals = []
    
        for _ in range(num_samples):
            x = np.cos(angle) * pow(radius, exponent)
            y = np.sin(angle) * pow(radius, exponent)
            x_vals.append(x)
            y_vals.append(y)
            radius += radius_step
            angle += ANGLE_STEP
    
        return x_vals, y_vals
    
    fig, ax = plt.subplots(figsize=(8, 8))
    ax.axis('on')
    ax.set_xlim(-1, 1)
    ax.set_ylim(-1, 1)
    ax.set_aspect('equal', adjustable='box')
    
    lines = [ax.plot([], [], '-o', markersize=5, linewidth=2)[0] for _ in range(50)]
    exponent = 0.2
    
    def init():
        for line in lines:
            line.set_data([], [])
        return lines
    
    def update(frame):
        global exponent
        exponent += 0.005  # Increment to adjust the exponent
        for line in lines:
            random_seed = np.random.rand()
            x_vals, y_vals = simulate_poisson_disk_samples_with_exponent(random_seed, exponent)
            # plt.title(exponent +"Poisson Disk Samples")
            line.set_data(x_vals, y_vals)
        plt.title(f"{exponent:.3f} Poisson Disk Samples")
        return lines
    
    ani = FuncAnimation(fig, update, frames=180, init_func=init, blit=False)
    
    ani.save('animation.mp4', writer='ffmpeg', fps=12)
    
    # plt.show()

    附录1.3 – JS的Promise简单用法

    关于Promise的用法这里给出一个例子:

    function delay(milliseconds) {
        return new Promise(function(resolve, reject) {
            if (milliseconds < 0) {
                reject('Delay time cannot be negative!');
            } else {
                setTimeout(function() {
                    resolve('Waited for ' + milliseconds + ' milliseconds!');
                }, milliseconds);
            }
        });
    }
    
    // 使用示例
    delay(2000).then(function(message) {
        console.log(message);  // 两秒后输出:"Waited for 2000 milliseconds!"
    }).catch(function(error) {
        console.log('Error: ' + error);
    });
    
    // 错误示例
    delay(-1000).then(function(message) {
        console.log(message);
    }).catch(function(error) {
        console.log('Error: ' + error);  // 立即输出:"Error: Delay time cannot be negative!"
    });

    使用Promise的固定操作是写一个Promise构造函数,这个函数有两个参数(参数也是一个函数):resolve和reject。这样可以构建错误处理的分支,比如在这个案例中,输入的内容不满足需求,则可以调用reject进入拒绝Promise分支。

    比方说现在进入reject分支,reject(XXX)中的XXX就传到了下面then(function(XXX))的XXX中。

    总结一下,Promise是JS中的一个对象,核心价值在于它提供了一种非常优雅统一的方式处理异步操作与链式操作,另外还提供了错误处理的功能。

    1. 通过Promise的.then()方法,你可以确保一个异步操作完成后再执行另一个异步操作。
    2. 通过 .catch() 方法可以处理错误,不需要为每个异步回调设置错误处理。

    附录1.4 – async/await

    async/await是ES8引入的feature,旨在化简使用Promise的步骤。

    直接看例子:

    async function asyncFunction() {
        return "Hello from async function!";
    }
    
    asyncFunction().then(result => console.log(result));  // 输出:Hello from async function!

    函数加上了async之后,会隐式的返回一个Promise对象。

    await 关键字只能在 async 函数内部使用。它会“暂停”函数的执行,直到 Promise 完成(解决或拒绝)。另外你也可以用try/catch捕获reject。

    async function handleAsyncOperation() {
        try {
            const result = await maybeFails();// 
            console.log(result);// 如果 Promise 被解决,这里将输出 "Success!"
        } catch (error) {
            console.error('An error occurred:', error);// 如果 Promise 被拒绝,这里将输出 "An error occurred: Failure!"
        }
    }

    这里的”暂停”指的是暂停了该特定的异步函数,而不是整个应用或JavaScript的事件循环。

    以下是关于await如何工作的简化说明:

    1. 当执行到await关键字时,该异步函数的执行暂停。
    2. 控制权返回给事件循环,允许其他代码(如其他的函数、事件回调等)在当前异步函数之后立即运行。
    3. 一旦await后面的Promise解决(fulfilled)或拒绝(rejected),原先暂停的异步函数继续执行,从暂停的位置恢复,并处理Promise的结果。

    也就是说,虽然你的特定的async函数在逻辑上”暂停”了,JavaScript的主线程并没有被阻塞。其他的事件和函数仍然可以在后台执行。

    举一个例子:

    console.log('Start');
    
    async function demo() {
        console.log('Before await');
        await new Promise(resolve => setTimeout(resolve, 2000));
        console.log('After await');
    }
    
    demo();
    
    console.log('End');

    输出将是:

    Start Before await End (wait for 2 seconds) After await

    希望以上解释可以帮助你理解JS的异步机制。欢迎在评论区讨论,我会尽可能立即回复您。

    附录1.5 关于.then()

    .then() 是在 Promise 对象上定义的,用于处理 Promise 的结果。当你调用 .then(),它不会立即执行,而是在 Promise 解决 (fulfilled) 或拒绝 (rejected) 后执行。

    .then() 的关键点:

    1. 非阻塞:当你调用 .then() 时,代码不会暂停等待 Promise 完成。相反,它会立即返回,并在 Promise 完成时执行 then 里的回调。
    2. 返回新的 Promise:.then() 总是返回一个新的 Promise。这允许你进行链式调用,即一系列的 .then() 调用,每个调用处理前一个 Promise 的结果。
    3. 异步回调:当原始 Promise 解决或拒绝时,.then() 里的回调函数是异步执行的。这意味着它们在事件循环的微任务队列中排队,而不是立即执行。

    举个例子:

    console.log('Start');
    
    const promise = new Promise((resolve, reject) => {
        setTimeout(() => {
            resolve('Promise resolved');
        }, 2000);
    });
    
    promise.then(result => {
        console.log(result);
    });
    
    console.log('End');

    输出会是:

    Start End (wait for 2 seconds) Promise resolved

    附录1.6 – 片段着色器:Uniforms/Textures

    https://webglfundamentals.org/webgl/lessons/zh_cn/webgl-fundamentals.html

    Uniforms 全局变量

    全局变量在一次绘制过程中传递给着色器的值都一样,在下面的一个简单的例子中, 用全局变量给顶点着色器添加了一个偏移量:

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

    现在可以把所有顶点偏移一个固定值,首先在初始化时找到全局变量的地址

    var offsetLoc = gl.getUniformLocation(someProgram, "u_offset");

    然后在绘制前设置全局变量

    gl.uniform4fv(offsetLoc, [1, 0, 0, 0]);  // 向右偏移一半屏幕宽度

    要注意的是全局变量属于单个着色程序,如果多个着色程序有同名全局变量,需要找到每个全局变量并设置自己的值。

    Textures 纹理

    在着色器中获取纹理信息,可以先创建一个sampler2D类型全局变量,然后用GLSL方法texture2D 从纹理中提取信息。

    precision mediump float; 
    uniform sampler2D u_texture; 
    void main() {   
        vec2 texcoord = vec2(0.5, 0.5);  // 获取纹理中心的值   
        gl_FragColor = texture2D(u_texture, texcoord);
    }

    从纹理中获取的数据取决于很多设置。 至少要创建并给纹理填充数据,例如

    var tex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, tex);
    var level = 0;
    var width = 2;
    var height = 1;
    var data = new Uint8Array([
       255, 0, 0, 255,   // 一个红色的像素
       0, 255, 0, 255,   // 一个绿色的像素
    ]);
    gl.texImage2D(gl.TEXTURE_2D, level, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, data);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);

    在初始化时找到全局变量的地址

    var someSamplerLoc = gl.getUniformLocation(someProgram, "u_texture");

    在渲染的时候WebGL要求纹理必须绑定到一个纹理单元上

    var unit = 5;  // 挑选一个纹理单元
    gl.activeTexture(gl.TEXTURE0 + unit);
    gl.bindTexture(gl.TEXTURE_2D, tex);

    然后告诉着色器你要使用的纹理在那个纹理单元

    gl.uniform1i(someSamplerLoc, unit);

    References

    1. GAMES202
    2. Real-Time Rendering 4th Edition
    3. https://webglfundamentals.org/webgl/lessons/webgl-shaders-and-glsl.html
  • C++ Lambda Note

    C++ Lambda Note

    我也是菜鸡QAQ,浅浅分享一下学习C++Lambda的笔记。借此管中窥豹,宛如透过狭缝窥探知乎大佬们遨游于智慧的广阔天地,写错了请大佬们斧正。

    Lambda表达式是C++11标准中引入的,允许在代码中定义匿名函数。本文的每一个章节都会有大量的代码案例帮助理解。 本文部分代码参考了 微软官方文档 | Lambda expressions in C++ | Microsoft Learn

    目录

    基础篇

    • 1. Lambda基本语法
    • 2. 如何使用Lambda表达式
    • 3. 详细讨论捕获列表
    • 4. mutable关键字
    • 5. Lambda返回值推导
    • 6. 嵌套Lambda
    • 7. Lambda、std:function与委托
    • 8. Lambda在异步和并发编程中
    • 9. 泛型Lambda(C++14)
      1. Lambda的作用域
      1. 实践

    中级篇

    • 1. Lambda的底层实现
    • 2. Lambda的类型和decltype与条件编译
    • 3. Lambda在新标准中的进化
    • 4. 状态保持的Lambda
    • 5. 优化与Lambda
    • 6. 与其他编程范式的结合
    • 7. Lambda与异常处理

    进阶篇

    • 1. Lambda与noexcept
    • 2. Lambda中的模板参数(C++20特性)
    • 3. Lambda的反射
    • 4. 跨平台和ABI的问题

    基础篇

    1. Lambda基本语法

    Lambda基本长这样:

    [捕获子句](参数列表) -> 返回类型 {
        // 函数体
    }
    [ capture_clause ] ( parameters ) -> return_type {
        // function_body
    }
    • 捕获子句(capture_clause)决定了外部作用域中的哪些变量将被这个lambda捕获以及如何捕获(通过值、引用或不捕获)。下一章我们详细讨论捕获子句。
    • 参数列表(parameters)和函数体(function_body)跟普通函数一样,没啥区别。
    • 返回类型(return_type)稍微有些不同。如果函数体包含多条语句,且需要返回值,则必须显式指定返回类型,除非所有 return 语句都返回相同类型,那么返回类型也可以被自动推断。

    2. 如何使用Lambda表达式

    语法例子:

    // 一个不捕获任何外部变量、不接受参数、没有返回值的lambda
    auto greet = [] { std::cout << "Hello, World!" << std::endl; };
    // 一个通过引用捕获外部变量、接受一个int参数、返回int类型的lambda
    int x = 42;
    auto add_to_x = [&x](int y) -> int { return x + y; };
    // 一个通过值捕获所有外部变量、接受两个参数、返回类型被自动推断的lambda
    int a = 1, b = 2;
    auto sum = [=](int x, int y) { return a + b + x + y; };
    // 使用初始化捕获创建新变量的lambda(C++14特性)
    auto multiply = [product = a * b](int scalar) { return product * scalar; };

    实际例子:

    1. 作为排序准则
    // 作为排序准则
    #include <algorithm>
    #include <vector>
    #include <iostream>
    int main() {
        std::vector<int> v{4, 1, 3, 5, 2};
        std::sort(v.begin(), v.end(), [](int a, int b) {
            return a < b; // 升序排列
        });
        for (int i : v) {
            std::cout << i << ' ';
        }
        // 输出:1 2 3 4 5
    }
    1. 用于forEach操作
    #include <vector>
    #include <iostream>
    #include <algorithm>
    int main() {
        std::vector<int> v{1, 2, 3, 4, 5};
        std::for_each(v.begin(), v.end(), [](int i) {
            std::cout << i * i << ' '; // 打印每个数字的平方
        });
        // 输出:1 4 9 16 25
    }
    1. 用于累积函数
    #include <iostream>
    #include <vector>
    #include <numeric>
    int main() {
        std::vector<int> v{1, 2, 3, 4, 5};
        int sum = std::accumulate(v.begin(), v.end(), 0, [](int a, int b) {
            return a + b; // 求和
        });
        std::cout << sum << std::endl; // 输出:15
    }
    1. 用于线程构造函数
    #include <thread>
    #include <iostream>
    int main() {
        int x = 10;
        std::thread t([x]() {
            std::cout << "Value in thread: " << x << std::endl;
        });
        t.join(); // 输出:Value in thread: 10
        // 注意:线程中使用的x是在创建线程时按值捕获的
    }

    3. 详细讨论捕获列表(Capture List)

    捕获列表是可选的。指定lambda表达式内部可以访问的外部变量。被引用的外部变量可以在lambda表达式内部被修改,但是按值捕获的外部变量是不可修改的,也就是说,有与号 (&) 前缀的变量通过引用进行访问,没有该前缀的变量通过值进行访问。

    1. 不捕获任何外部变量:

    cpp []{ // }

    这个lambda不捕获任何外部作用域中的变量。

    1. 默认捕获所有外部变量(通过引用):

    cpp [&]{ // }

    这个lambda捕获所有外部作用域中的变量,并通过引用捕获它们。如果捕获的变量在lambda被调用时已经销毁或超出作用域,则会产生未定义行为。

    1. 默认捕获所有外部变量(通过值):

    cpp [=]{ // }

    这个lambda通过值捕获所有外部作用域中的变量,这意味着它使用的是变量的副本。

    1. 显式捕获特定变量(通过值):

    cpp [x]{ // }

    这个lambda通过值捕获外部变量x。

    1. 显式捕获特定变量(通过引用):

    cpp [&x]{ // }

    这个lambda通过引用捕获外部变量x。

    1. 混合捕获(通过值和引用):

    cpp [x, &y]{ // }

    这个lambda通过值捕获变量x,通过引用捕获变量y。

    1. 默认通过值捕获,但某些变量通过引用:

    cpp [=, &x, &y]{ // }

    这个lambda默认通过值捕获所有外部变量,但通过引用捕获变量x和y。

    1. 默认通过引用捕获,但某些变量通过值:

    cpp [&, x, y]{ // }

    这个lambda默认通过引用捕获所有外部变量,但通过值捕获变量x和y。

    1. 捕获this指针:

    cpp [this]{ // }

    这允许lambda表达式捕获类成员函数的this指针,从而可以访问类的成员变量和函数。

    1. 以初始化表达式捕获 (C++14起) – 泛型lambda捕获: cpp [x = 42]{ // } 创建了一个在lambda内部的匿名变量 x ,可以在lambda的函数体中使用这个变量。这个东西还是比较有用的,比如可以直接用移动语义转移 std::unique_ptr ,在下面「引用」中详细讨论。
    2. 捕获星号this(C++17起): cpp [this]{ /…*/ } 这个lambda通过值捕获当前对象(其所在的类的实例)。这样做可以避免在lambda生命周期内this指针变为悬挂指针的风险。在C++17之前,可以通过引用获取this,但是这有一个潜在的内存风险,也就是如果this的生命周期结束了,就会造成内存泄漏。用了星号this,就相当于深度拷贝了一份当前的对象。

    std::unique_ptr 是一个独占所有权的智能指针,其设计初衷就是确保同一时间内只有一个实体可以拥有对对象的所有权。因此,std::unique_ptr 不能被复制,只能被移动。 如果你想通过值来捕获,那编译器会被报错捏。 如果通过引用捕获,编译器不会报错。但是会有潜在的问题,我想到了三种:

    1. std::unique_ptr 的生命比lambda先结束了。这种情况下,lambda内部访问这个已经销毁的 std::unique_ptr 会导致程序崩溃。
    2. std::unique_ptr在捕获后被移动了,那么在lambda的那个引用就是空的,进而导致程序崩溃。
    3. 多线程环境中,上面这两个问题会更加频繁地出现。 为了避免这些问题,可以考虑通过值捕获,即显式使用 std::move 来转移所有权。多线程环境中就加锁。

    代码案例:

    1. 使用Lambda作为回调函数 – 该例子也涉及到 function()
    #include <iostream>
    #include <functional>
    // 假设有一个函数,它在某个操作完成后调用回调函数
    void performOperationAsync(std::function<void(int)> callback) {
        // 异步操作...
        int result = 42; // 假设这是异步操作的结果
        callback(result); // 调用回调函数
    }
    int main() {
        int capture = 100;
        performOperationAsync([capture](int result) {
            std::cout << "Async operation result: " << result
                      << " with captured value: " << capture << std::endl;
        });
    }
    1. 与智能指针一起使用 – 该例子也涉及到 mutable 关键字
    #include <iostream>
    #include <memory>
    void processResource(std::unique_ptr<int> ptr) {
        // 做一些处理
        std::cout << "Processing resource with value " << *ptr << std::endl;
    }
    int main() {
        auto ptr = std::make_unique<int>(10);
        // 使用Lambda延迟资源处理
        auto deferredProcess = [p = std::move(ptr)]() {
            processResource(std::move(p));
        };
        // 做一些其他操作...
        // ...
        deferredProcess(); // 最终处理资源
    }
    1. 在多线程中同步数据访问
    int main() {
        std::vector<int> data;
        std::mutex data_mutex;
        std::vector<std::thread> threadsPool;
        // Lambda用于添加数据到vector,确保线程安全
        auto addData = [&](int value) {
            std::lock_guard<std::mutex> lock(data_mutex);
            data.push_back(value);
            std::cout << "Added " << value << " to the data structure." << std::endl;
        };
        threadsPool.reserve(10);
        for (int i = 0; i < 10; ++i) {
            threadsPool.emplace_back(addData, i);
        }
        // 等待所有线程完成
        for (auto& thread : threadsPool) {
            thread.join();
        }
    }
    1. Lambda在范围查询中的应用
    #include <algorithm>
    int main() {
        std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
        int lower_bound = 3;
        int upper_bound = 7;
        // 使用Lambda找到特定范围内的所有数
        auto range_begin = std::find_if(v.begin(), v.end(), [lower_bound](int x) { return x >= lower_bound; });
        auto range_end = std::find_if(range_begin, v.end(), [upper_bound](int x) { return x > upper_bound; });
        std::cout << "Range: ";
        std::for_each(range_begin, range_end, [](int x) { std::cout << x << ' '; });
        std::cout << std::endl;
    }
    1. 延迟执行
    #include <chrono>
    // 模拟一个可能需要耗时的操作
    void expensiveOperation(int data) {
        // 模拟耗时操作
        std::this_thread::sleep_for(std::chrono::seconds(1));
        std::cout << "Processed data: " << data << std::endl;
    }
    int main() {
        std::vector<std::function<void()>> deferredOperations;
        deferredOperations.reserve(10);
        // 假设这是一个需要执行耗时操作的循环,但我们不想立即执行它们
        for (int i = 0; i < 10; ++i) {
            // 捕获i并延迟执行
            deferredOperations.emplace_back([i] {
                expensiveOperation(i);
            });
        }
        std::cout << "All operations have been scheduled, doing other work now." << std::endl;
        // 假设现在是一个较好的时间点去执行这些耗时的操作
        for (auto& operation : deferredOperations) {
            // 在一个新线程上执行Lambda表达式以避免阻塞主线程
            std::thread(operation).detach();
        }
        // 给线程一些时间来处理操作
        std::this_thread::sleep_for(std::chrono::seconds(2));
        std::cout << "Main thread finished." << std::endl;
    }
    /* 注意:在实际的多线程程序中,通常需要考虑线程同步和资源管理
    ,例如使用 std::async 而不是 std::thread().detach(),
    以及使用适当的同步机制如互斥锁和条件变量来保证线程安全。
    在这个简化的例子中,为了保持清晰和集中在延迟操作上,
    这些细节被省略了。*/
    // 下面演示这个例子更合理的版本
    #include <iostream>
    #include <vector>
    #include <future>
    #include <chrono>
    // 模拟一个可能需要耗时的操作
    int expensiveOperation(int data) {
        // 模拟耗时操作
        std::this_thread::sleep_for(std::chrono::seconds(1));
        return data * data; // 返回一些处理结果
    }
    int main() {
        std::vector<std::future<int>> deferredResults;
        // 启动多个异步任务
        deferredResults.reserve(10);
        for (int i = 0; i < 10; ++i) {
            deferredResults.emplace_back(
                std::async(std::launch::async, expensiveOperation, i)
            );
        }
        std::cout << "All operations have been scheduled, doing other work now." << std::endl;
        // 获取异步任务的结果
        for (auto& future : deferredResults) {
            // get() 会阻塞直到异步操作完成并返回结果
            std::cout << "Processed data: " << future.get() << std::endl;
        }
        std::cout << "Main thread finished." << std::endl;
    }
    /* 备注:std::async 为我们管理了这一切。
    我们也不需要使用互斥锁或其他同步机制,因为每个异步操作都在它自己的线程上运行,
    不会互相干扰,并且返回的 future 对象为我们处理了所有必要的同步。
    std::async 与 std::launch::async 参数一起使用,
        这会保证每个任务都在不同的线程上异步运行。
    如果你没有指定 std::launch::async,C++ 运行时可以决定同步(延迟)执行任务,
    这并不是我们希望看到的。
        future.get() 调用将阻塞主线程,直到相应的任务完成,并返回结果。
    这使得我们可以安全地获取结果,而不会发生竞争条件或者需要使用互斥锁。*/

    4. mutable关键字

    首先回顾一下什么是 mutable 关键字。除了在lambda表达式中使用,一般我们还会在类成员声明中使用。

    当在一个类成员变量前使用 mutable 关键字时,你可以在该类的 const 成员函数中修改这个成员变量。这通常用于那些不影响对象外部状态的成员,例如缓存、调试信息或者可以延迟计算的数据。

    class MyClass {
    public:
        mutable int cache; // 可以在const成员函数中修改
        int data;
        MyClass() : data(0), cache(0) {}
        void setData(int d) const {
            // data = d; // 编译错误:不能在const函数中修改非mutable成员
            cache = d;
        }
    };

    在lambda表达式中,mutable 关键字允许你修改Lambda内捕获的变量的副本。默认情况下,Lambda表达式中的 () 是 const 的,一般来说你不能修改通过值捕获的变量。除非使用 mutable 。

    这里的关键点是mutable允许修改的是闭包自己的成员变量的副本,而不是外部作用域的原始变量。这意味着闭包对外部作用域的 “封闭性”仍然得以保持,因为它并没有改变外部作用域的状态,只是改变了自己内部的状态。

    不合法的例子:

    int x = 0;
    auto f = [x]() {  x++; // 错误:不能修改捕获的变量  };
    f();

    应该这样:

    int x = 0;
    auto f = [x]() mutable { x++; std::cout << x << std::endl; };
    f(); // 正确:输出1

    实际例子:

    1. 捕获变量修改
    #include <iostream>
    #include <vector>
    int main() {
        int count = 0;
        // 创建一个可变lambda表达式,每次调用都递增count
        auto increment = [count]() mutable {
            count++;
            std::cout << count << std::endl;
        };
        increment(); // 输出 1
        increment(); // 输出 2
        increment(); // 输出 3
        // 外部的count仍然是0,因为它是通过值捕获的
        std::cout << "External count: " << count << std::endl; // 输出 External count: 0
    }
    1. 生成唯一的ID
    #include <iostream>
    int main() {
        int lastId = 0;
        auto generateId = [lastId]() mutable -> int {
            return ++lastId; // 递增并返回新的ID
        };
        std::cout << "New ID: " << generateId() << std::endl; // 输出 New ID: 1
        std::cout << "New ID: " << generateId() << std::endl; // 输出 New ID: 2
        std::cout << "New ID: " << generateId() << std::endl; // 输出 New ID: 3
    }
    1. 状态保持
    #include <iostream>
    #include <algorithm>
    #include <vector>
    int main() {
        std::vector<int> numbers = {1, 2, 3, 4, 5};
        // 初始状态
        int accumulator = 0;
        // 创建一个可变lambda表达式来累加值
        auto sum = [accumulator](int value) mutable {
            accumulator += value;
            return accumulator; // 返回当前累加值
        };
        std::vector<int> runningTotals(numbers.size());
        // 对每个元素应用sum,生成运行总和
        std::transform(numbers.begin(), numbers.end(), runningTotals.begin(), sum);
        // 输出运行总和
        for (int total : runningTotals) {
            std::cout << total << " "; // 输出 1 3 6 10 15
        }
        std::cout << std::endl;
    }

    5. Lambda返回值推导

    在C++11中引入了Lambda表达式时,Lambda的返回类型通常需要明确指定。

    从C++14开始,对Lambda返回值的推导进行了改进,引入了自动类型推导。

    C++14中Lambda返回值的推导遵循以下规则:

    1. 如果Lambda的函数体中包含了 return 关键字,且所有 return 语句后面的表达式的类型都相同,那么Lambda的返回类型被推导为该类型。
    2. 如果Lambda的函数体是一个单一的返回语句,或者可以视为一个单一的返回语句(比如一个构造函数或者花括号初始化器),则返回类型被推导为该返回语句表达式的类型。
    3. 如果Lambda不返回任何值(即函数体中没有 return 语句),或者函数体只包含不返回值的 return 语句(即 return;),则推导的返回类型为 void。
    4. C++11的返回值推导例子

    在C++11中,如果Lambda体包含多个返回语句,必须显式指定返回类型。

    auto f = [](int x) -> double { // 显式指定返回类型
        if (x > 0)
            return x * 2.5;
        else
            return x / 2.0;
    };
    • C++14自动推导

    在C++14中,上述Lambda表达式的返回类型可以被自动推导。

    auto f = [](int x) { // 返回类型自动推导为double
        if (x > 0)
            return x * 2.5; // double
        else
            return x / 2.0; // double
    };
    • 错误示范

    如果返回语句的类型不匹配,不能进行自动推导,这会导致编译错误。

    auto g = [](int x) { // 编译错误,因为返回类型不一致
        if (x > 0)
            return x * 2.5; // double
        else
            return x;       // int
    };

    但是在C++17之后,如果返回的类型非常不同,以至于无法直接或通过转换统一为一个共同的类型,可以使用 std::variant 或 std::any ,这样可以包含多种不同的类型:

    #include <variant>
    auto g = [](int x) -> std::variant<int, double> {
        if (x > 0)
            return x * 2.5; // 返回double类型
        else
            return x;       // 返回int类型
    };

    Lambda表达式返回一个 std::variant 类型,也就是返回一个 int 或 double 类型的叠加态,后续调用者然后可以检查这个变量,并相应地处理。这部分的内容不做过多讨论。

    6. 嵌套Lambda

    也可以叫做套娃lambda,在一个lambda内再写一个lambda,是一种高级的函数式编程技巧。

    简单举一个例子:

    #include <iostream>
    #include <vector>
    #include <algorithm>
    int main() {
        std::vector<int> numbers = {1, 2, 3, 4, 5};
        // 外层Lambda用于遍历集合
        std::for_each(numbers.begin(), numbers.end(), [](int x) {
            // 嵌套Lambda用于计算平方
            auto square = [](int y) { return y * y; };
            // 调用嵌套Lambda并打印结果
            std::cout << square(x) << ' ';
        });
        std::cout << std::endl;
        return 0;
    }

    但是我们需要注意很多问题:

    1. 不要写得太复杂了,可读性需要重点考虑。
    2. 注意捕获列表的变量的生命周期,下面的例子也会详细讨论。
    3. 捕获列表应该尽可能的简单,避免错误。
    4. 编译器对嵌套Lambda的优化可能不如顶层函数或类成员函数。

    嵌套Lambda如果捕获外层Lambda的局部变量,需要注意变量的生命周期。如果嵌套Lambda的执行延续到外层Lambda的生命周期之外,那么捕获的局部变量将不再有效,就会报错了。

    #include <iostream>
    #include <functional>
    std::function<int()> createLambda() {
        int localValue = 10; // 外层Lambda的局部变量
        // 返回一个捕获localValue的Lambda
        return [localValue]() mutable {
            return ++localValue; // 试图修改捕获的变量(由于是值捕获,这是合法的)
        };
    }
    int main() {
        auto myLambda = createLambda(); // myLambda现在持有一个捕获了已经销毁的局部变量的副本
        std::cout << myLambda() << std::endl; // 这将输出11,但是依赖于已经销毁的localValue的副本
        std::cout << myLambda() << std::endl; // 再次调用将输出12,继续依赖于那个副本
        return 0;
    }

    解释一下,由于Lambda是以值捕获的方式捕获 localValue 的,所以它持有 localValue 的一个副本,该副本的生命周期与返回的Lambda对象相同。

    当我们在 main 函数中调用 myLambda() 时,它操作的是 localValue 副本的状态,而非原始的 localValue (已经在 createLambda 函数执行完毕后销毁)。这里虽然没有引发未定义行为,但是如果我们使用引用捕获,情况就不一样了:

    std::function<int()> createLambda() {
        int localValue = 10; // 外层Lambda的局部变量
        // 返回一个捕获localValue引用的Lambda
        return [&localValue]() mutable {
            return ++localValue; // 试图修改捕获的变量
        };
    }
    // 此时使用createLambda返回的Lambda将会导致未定义行为

    7. Lambda、std:function与委托

    Lambda表达式、std::function和委托是C++中用于实现函数调用和回调机制的三个不同的概念。接下来我们分别讲解三者。

    • Lambda

    C++11引入的一种定义匿名函数对象的语法。Lambda被用于创建一个可调用的实体,即Lambda闭包,通常用于传递给算法或用作回调函数。Lambda表达式可以捕获作用域内的变量,可以按值捕获(拷贝),也可以按引用捕获。Lambda表达式是定义在函数内部的,它们的类型是唯一的,并且不可显式指定。

    auto lambda = [](int a, int b) { return a + b; };
    auto result = lambda(2, 3); // 调用Lambda表达式
    • std::function

    std::function 是C++11引入的类型擦除包装器,它可以存储调用复制任何可调用实体,如函数指针、成员函数指针、Lambda表达式和函数对象。代价就是开销较大。

    std::function<int(int, int)> func = lambda;
    auto result = func(2, 3); // 使用std::function对象调用Lambda表达式
    • 委托

    委托在C++中不是一个正式的术语。委托通常是一种将函数调用委托给其他对象的机制。在C#中,委托是一种类型安全的函数指针。在C++中,委托的实现一般有几种方式:函数指针、成员函数指针、 std::function 和函数对象。下面是一个委托构造函数的例子。

    class MyClass {
    public:
        MyClass(int value) : MyClass(value, "default") { // 委托给另一个构造函数
            std::cout << "Constructor with single parameter called." << std::endl;
        }
        MyClass(int value, std::string text) {
            std::cout << "Constructor with two parameters called: " << value << ", " << text << std::endl;
        }
    };
    int main() {
        MyClass obj(30); // 这将调用两个构造函数
    }
    • 三者对比

    Lambda表达式是轻量级的,并且非常适合用于定义简单的局部回调和作为算法的参数。

    std::function 更重量级,但灵活性更高。例如,如果你有一个需要存储不同类型的回调函数的场景,std::function是理想的选择,因为它可以存储任意类型的可调用实体。体现其灵活性的例子。

    #include <iostream>
    #include <functional>
    #include <vector>
    // 一个接收int并返回void的函数
    void printNumber(int number) {
        std::cout << "Number: " << number << std::endl;
    }
    // 一个Lambda表达式
    auto printSum = [](int a, int b) {
        std::cout << "Sum: " << (a + b) << std::endl;
    };
    // 一个函数对象
    class PrintMessage {
    public:
        void operator()(const std::string &message) const {
            std::cout << "Message: " << message << std::endl;
        }
    };
    int main() {
        // 创建一个std::function的向量,可以存储任何类型的可调用对象
        std::vector<std::function<void()>> callbacks;
        // 添加一个普通函数的回调
        int number_to_print = 42;
        callbacks.push_back([=]{ printNumber(number_to_print); });
        // 添加一个Lambda表达式的回调
        int a = 10, b = 20;
        callbacks.push_back([=]{ printSum(a, b); });
        // 添加一个函数对象的回调
        std::string message = "Hello World";
        PrintMessage printMessage;
        callbacks.push_back([=]{ printMessage(message); });
        // 执行所有的回调
        for (auto& callback : callbacks) {
            callback();
        }
        return 0;
    }

    委托通常与事件处理相关,在C++中没有内置的事件处理机制,因此std::function和Lambda表达式经常用来实现委托模式。具体来讲就是,你定义一个回调接口,用户可以向这个接口注册自己的函数或Lambda表达式,以便在事件发生时调用。一般步骤如下(顺便举个例子):

    1. 定义可以被调用的类型:你需要确定你的回调函数或Lambda表达式需要接受什么参数,返回什么类型的结果。
    using Callback = std::function<void()>; // 没有参数和返回值的回调
    1. 创建一个类来管理回调:这个类会持有所有回调函数,并允许用户添加或者移除回调。
    class Button {
    private:
        std::vector<Callback> onClickCallbacks; // 存储回调的容器
    public:
        void addClickListener(const Callback& callback) {
            onClickCallbacks.push_back(callback);
        }
        void click() {
            for (auto& callback : onClickCallbacks) {
                callback(); // 执行每一个回调
            }
        }
    };
    1. 提供一个方法来添加回调:这个方法允许用户将他们自己的函数或Lambda表达式注册为回调
    Button button;
    button.addClickListener([]() {
        std::cout << "Button was clicked!" << std::endl;
    });
    1. 提供一个方法来执行回调:当需要的时候,这个方法会调用所有已经注册的回调函数
    button.click(); // 用户点击按钮,触发所有的回调

    是不是非常简单呢,接下来再来一个例子,加深一下理解。

    #include <functional>
    #include <iostream>
    #include <vector>
    class Delegate {
    public:
        using Callback = std::function<void(int)>;  // 定义回调类型,这里的回调接收一个int参数
        // 注册回调函数
        void registerCallback(const Callback& callback) {
            callbacks.push_back(callback);
        }
        // 触发所有回调函数
        void notify(int value) {
            for (const auto& callback : callbacks) {
                callback(value);  // 执行回调
            }
        }
    private:
        std::vector<Callback> callbacks;  // 存储回调的容器
    };
    int main() {
        Delegate del;
        // 用户注册自己的函数
        del.registerCallback([](int n) {
            std::cout << "Lambda 1: " << n << std::endl;
        });
        // 另一个Lambda表达式
        del.registerCallback([](int n) {
            std::cout << "Lambda 2: " << n * n << std::endl;
        });
        // 触发回调
        del.notify(10);  // 这将调用所有注册的Lambda表达式
        return 0;
    }

    8. Lambda在异步和并发编程中

    全因为Lambda有个捕获和存储状态的功能,导致我们在编写现代C++并发编程的时候非常有用。

    • Lambda和线程

    直接在 std::thread 构造函数中使用 Lambda 表达式来定义线程应该执行的代码。

    #include <thread>
    #include <iostream>
    int main() {
        int value = 42;
        // 创建一个新线程,使用 Lambda 表达式作为线程函数
        std::thread worker([value]() {
            std::cout << "Value in thread: " << value << std::endl;
        });
        // 主线程继续执行...
        // 等待工作线程完成
        worker.join();
        return 0;
    }
    • Lambda 与 std::async

    std::async 是一个轻松创建异步的东西,计算完后返回一个 std::future 对象,可以调用 get 但若未执行完会阻塞。关于这个async还有很多有趣的内容,这里就不赘述了。

    #include <future>
    #include <iostream>
    int main() {
        // 启动一个异步任务
        auto future = std::async([]() {
            // 执行一些操作...
            return "Result from async task";
        });
        // 在此期间,主线程可以执行其他任务...
        // 获取异步操作的结果
        std::string result = future.get();
        std::cout << result << std::endl;
        return 0;
    }
    • Lambda和 std::funtion

    这两个也是经常结合在一起使用的,让我们来这个存储可调用的回调的例子吧。

    #include <functional>
    #include <vector>
    #include <iostream>
    #include <thread>
    // 一个存储 std::function 对象的任务队列
    std::vector<std::function<void()>> tasks;
    // 添加任务的函数
    void addTask(const std::function<void()>& task) {
        tasks.push_back(task);
    }
    int main() {
        // 添加一个 Lambda 表达式作为任务
        addTask([]() {
            std::cout << "Task 1 executed" << std::endl;
        });
        // 启动一个新线程来处理任务
        std::thread worker([]() {
            for (auto& task : tasks) {
                task(); // 执行任务
            }
        });
        // 主线程继续执行...
        worker.join();
        return 0;
    }

    9. 泛型Lambda(C++14)

    使用 auto 关键字在参数列表中进行类型推导。

    泛型基本语法:

    auto lambda = [](auto x, auto y) {
        return x + y;
    };

    使用例子:

    #include <numeric>
    int main() {
        std::vector<int> vi = {1, 2, 3, 4};
        std::vector<double> vd = {1.1, 2.2, 3.3, 4.4, 5.5};
        // 使用泛型 Lambda 打印 int 类型的元素
        std::for_each(vi.begin(), vi.end(), [](auto n) {
            std::cout << n << ' ';
        });
        std::cout << '\n';
        // 使用泛型 Lambda 打印 double 类型的元素
        std::for_each(vd.begin(), vd.end(), [](auto n) {
            std::cout << n << ' ';
        });
        std::cout << '\n';
        // 使用泛型 Lambda 计算 int 类型的向量的和
        auto sum_vi = std::accumulate(vi.begin(), vi.end(), 0, [](auto total, auto n) {
            return total + n;
        });
        std::cout << "Sum of vi: " << sum_vi << '\n';
        // 使用泛型 Lambda 计算 double 类型的向量的和
        auto sum_vd = std::accumulate(vd.begin(), vd.end(), 0.0, [](auto total, auto n) {
            return total + n;
        });
        std::cout << "Sum of vd: " << sum_vd << '\n';
        return 0;
    }

    也可以做一个打印任何类型容器的lambda。

    #include <list>
    int main() {
        std::vector<int> vec{1, 2, 3, 4};
        std::list<double> lst{1.1, 2.2, 3.3, 4.4};
        auto print = [](const auto& container) {
            for (const auto& val : container) {
                std::cout << val << ' ';
            }
            std::cout << '\n';
        };
        print(vec); // 打印 vector<int>
        print(lst); // 打印 list<double>
        return 0;
    }

    10. Lambda的作用域

    首先,Lambda可以捕获其定义的作用域内的局部变量,捕获之后,即使原作用域结束,这些变量的副本或引用(取决于捕获方式)仍然可以继续使用。

    需要特别注意的点是,引用捕获一个变量,如果这个变量原先所在的作用域已经销毁,那么这就会导致未定义行为。

    Lambda也可以捕获全局变量,但是此时就不是通过捕获列表实现的了,因为全局变量不论在哪都可以被访问。

    如果有一个 Lambda 嵌套在另一个 Lambda 内部,内部 Lambda 可以捕获外部 Lambda 的捕获列表中的变量。

    当 Lambda 捕获了值,即使原本的值没了、Lambda也走了(返回去别的地方了),所有值捕获的变量也将被复制到 Lambda 对象中。这些变量的生命周期将自动延续,直到 Lambda 对象本身被销毁。下面举一个例子:

    #include <iostream>
    #include <functional>
    std::function<void()> createLambda() {
        int localValue = 100;  // 局部变量
        return [=]() mutable {  // 以值捕获的方式复制localValue
            std::cout << localValue++ << '\n';
        };
    }
    int main() {
        auto myLambda = createLambda();  // Lambda复制了localValue
        myLambda();  // 即使createLambda的作用域已经结束,复制的localValue仍然存在于myLambda中
        myLambda();  // 可以安全地继续访问和修改该副本
    }

    当 Lambda 捕获了引用,就是另外一个 Story 了。聪明的读者应该也能猜到,如果原始变量的作用域结束了,Lambda 依赖的是一个悬空引用,这将导致未定义的行为。

    11. 实践 – 函数计算库

    啰里八嗦这么多,现在需要动手实践一下了。无论做啥,目前我们需要掌握的知识点都是那几个:

    • 捕获
    • 高阶函数
    • 可调用对象
    • lambda存储
    • 可变Lambdas(mutable)
    • 泛型Lambda

    我们本节的目标是创建一个数学库。支持向量运算、矩阵运算以及提供一个函数解析器,它可以接受字符串形式的数学表达式并返回一个可计算的 Lambda,我们马上开始吧。

    这个项目从简单的数学函数计算开始,逐步扩展到复杂的数学表达式解析和计算。项目编写步骤:

    • 基本向量和矩阵运算
    • 函数解析器
    • 更高级的数学函数
    • 复合函数
    • 高阶数学操作
    • 更多拓展…

    基本向量和矩阵运算

    首先定义向量和矩阵的数据结构,实现基本的算术运算(加减)。

    为了简化项目,专注与Lambda的使用,我没有使用模版,因此所有的数据用 std::vector 实现。

    在下面代码中,我已经实现了一个最基本的向量框架。请读者自行完善框架,包括向量的减法、点乘等操作。

    // Vector.h
    #include <vector>
    #include <ostream>
    class Vector {
    private:
        std::vector<double> elements;
    public:
        // 构造函数 - explicit防止隐式转换
        Vector() = default;
        explicit Vector(const std::vector<double> &elems);
        Vector operator+const Vector& rhs) const;
        // 获取向量大小
        [[nodiscard]] size_t size() const { return elements.size(); }
        // 访问元素,返回对象的引用 double&。如果Vector对象是常量,就使用下面的版本
        double& operator[](size_t index) { return elements[index]; }
        const double& operator[](size_t index) const { return elements[index]; }
        // 迭代器支持
        auto begin() { return elements.begin(); }
        auto end() { return elements.end(); }
        auto begin() const { return elements.cbegin(); }
        auto end() const { return elements.cend(); }
        // 让重载的流输出运算符成为友元函数,以便它可以访问私有成员
        friend std::ostream& operator<<(std::ostream& os, const Vector& v);
    };
    /// Vector.cpp
    #include "Vector.h"
    Vector::Vector(const std::vector<double>& elems) : elements(elems){}
    Vector Vector::operator+(const Vector &rhs) const {
        // 首先确保两个向量一致
        if( this->size() != rhs.size() )
            throw std::length_error("向量大小不一致!");
        Vector result;
        result.elements.reserve(this->size()); // 提前分配内存
        // 使用迭代器遍历向量各个元素
        std::transform(this->begin(), this->end(), rhs.begin(), std::back_inserter(result.elements),
                       [](double_t a,double_t b){ return a+b; });
        return result;
    }
    std::ostream& operator<<(std::ostream& os, const Vector& v) {
        os << '[';
        for (size_t i = 0; i < v.elements.size(); ++i) {
            os << v.elements[i];
            if (i < v.elements.size() - 1) {
                os << ", ";
            }
        }
        os << ']';
        return os;
    }

    可以在声明运算操作中使用 [[nodiscard]] 标签,提醒编译器注意检查返回值是否得到使用,然后使用该库的用户就可以在编辑器中得到提醒,例如下面。

    函数解析器

    设计一个函数解析器,它可以将字符串形式的数学表达式转换为 Lambda 表达式。

    创建一个能够解析字符串形式的数学表达式并转换为 Lambda 表达式的函数解析器涉及到解析理论,为了简化例子,我们目前只解析最基本的 + 和 – 。然后将函数解析器打包进一个 ExpressionParser 的工具类里面。

    首先我们先创建一个识别出 + 号和 – 号的解析器:

    // ExpressionParser.h
    #include <functional>
    #include <string>
    using ExprFunction = std::function<double(double, double)>;
    class ExpressionParser {
    public:
        static ExprFunction parse_simple_expr(const std::string& expr);
    };
    // ExpressionParser.cpp
    #include "ExpressionParser.h"
    ExprFunction ExpressionParser::parse_simple_expr
            (const std::string &expr)
    {
        if (expr.find('+') != std::string::npos) {
            return [](double x, double y) { return x + y; };
        }
        else if (expr.find('-') != std::string::npos) {
            return [](double x, double y) { return x - y; };
        }
        // 更多操作...
        return nullptr;
    }

    这一段与Lambda关系不大,可跳过。然后我们可以在这个基础上,改进函数解析器以识别数字。将字符串分割成令牌(数字和操作符),然后根据操作符执行操作。对于更加复杂的表达式,就需要使用比如RPN等算法或者现有的解析库,这里就不弄这么复杂了。

    // ExpressionParser.h
    ...
    #include <sstream>
    ...
    static double parse_and_compute(const std::string& expr);
    ...
    // ExpressionParser.cpp
    ...
    double ExpressionParser::parse_and_compute(const std::string& expr) {
        std::istringstream iss(expr);
        std::vector<std::string> tokens;
        std::string token;
        while (iss >> token) {
            tokens.push_back(token);
        }
        if (tokens.size() != 3) {
            throw std::runtime_error("Invalid expression format.");
        }
        double num1 = std::stod(tokens[0]);
        const std::string& op = tokens[1];
        double num2 = std::stod(tokens[2]);
        if (op == "+") {
            return num1 + num2;
        } else if (op == "-") {
            return num1 - num2;
        } else {
            throw std::runtime_error("Unsupported operator.");
        }
    }

    测试:

    // main.cpp
    #include "ExpressionParser.h"
    ...
    std::string expr = "10 - 25";
    std::cout << expr << " = " << ExpressionParser::parse_and_compute(expr) << std::endl;

    感兴趣的读者也可以尝试解析多个运算符的算法,使用操作符优先级解析算法(如Shunting Yard算法)来转换中缀表达式为逆波兰表示法(RPN)。下面 展示 胡扯 一下数据结构的知识,与Lambda关系不大。

    #include <iostream>
    #include <stack>
    #include <vector>
    #include <sstream>
    #include <map>
    #include <cctype>
    // 确定是否为操作符
    bool is_operator(const std::string& token) {
        return token == "+" || token == "-" || token == "*" || token == "/";
    }
    // 确定操作符优先级
    int precedence(const std::string& token) {
        if (token == "+" || token == "-") return 1;
        if (token == "*" || token == "/") return 2;
        return 0;
    }
    // 将中缀表达式转换为逆波兰表示法
    std::vector<std::string> infix_to_rpn(const std::vector<std::string>& tokens) {
        std::vector<std::string> output;
        std::stack<std::string> operators;
        for (const auto& token : tokens) {
            if (is_operator(token)) {
                while (!operators.empty() && precedence(operators.top()) >= precedence(token)) {
                    output.push_back(operators.top());
                    operators.pop();
                }
                operators.push(token);
            } else if (token == "(") {
                operators.push(token);
            } else if (token == ")") {
                while (!operators.empty() && operators.top() != "(") {
                    output.push_back(operators.top());
                    operators.pop();
                }
                if (!operators.empty()) operators.pop();
            } else {
                output.push_back(token);
            }
        }
        while (!operators.empty()) {
            output.push_back(operators.top());
            operators.pop();
        }
        return output;
    }
    // 计算逆波兰表示法
    double compute_rpn(const std::vector<std::string>& tokens) {
        std::stack<double> operands;
        for (const auto& token : tokens) {
            if (is_operator(token)) {
                double rhs = operands.top(); operands.pop();
                double lhs = operands.top(); operands.pop();
                if (token == "+") operands.push(lhs + rhs);
                else if (token == "-") operands.push(lhs - rhs);
                else if (token == "*") operands.push(lhs * rhs);
                else operands.push(lhs / rhs);
            } else {
                operands.push(std::stod(token));
            }
        }
        return operands.top();
    }
    // 主函数
    int main() {
        std::string input = "3 + 4 * 2 / ( 1 - 5 )";
        std::istringstream iss(input);
        std::vector<std::string> tokens;
        std::string token;
        while (iss >> token) {
            tokens.push_back(token);
        }
        auto rpn = infix_to_rpn(tokens);
        for (const auto& t : rpn) {
            std::cout << t << " ";
        }
        std::cout << std::endl;
        double result = compute_rpn(rpn);
        std::cout << "Result: " << result << std::endl;
        return 0;
    }

    更高级的数学函数

    假设我们的解析器已经能够识别出了更高级的数学操作,如三角函数、对数、指数等,我们就需要为对应的操作提供一个Lambda表达式。

    首先我们修改两种不同签名的 std::function 的别名。

    // ExpressionParser.cpp
    using UnaryFunction = std::function<double(double)>;
    using BinaryFunction = std::function<double(double, double)>;
    ...
    // ExpressionParser.cpp
    UnaryFunction ExpressionParser::parse_complex_expr
            (const std::string& expr)
    {
        using _t = std::unordered_map<std::string, UnaryFunction>;
        static const _t functions = {
                {"sin", [](double x) -> double { return std::sin(x); }},
                {"cos", [](double x) -> double { return std::cos(x); }},
                {"log", [](double x) -> double { return std::log(x); }},
                // ... 添加更多函数
        };
        auto it = functions.find(expr);
        if (it != functions.end()) {
            return it->second;
        } else {
            // 处理错误或返回一个默认的函数
            return [](double) -> double { return 0.0; }; // 示例错误处理
        }
    }

    复合函数

    实现复合数学函数的功能,可以通过组合多个 Lambda 表达式来实现。下面是一个小例子:

    #include <iostream>
    #include <cmath>
    #include <functional>
    int main() {
        // 定义第一个函数 f(x) = sin(x)
        auto f = [](double x) {
            return std::sin(x);
        };
        // 定义第二个函数 g(x) = cos(x)
        auto g = [](double x) {
            return std::cos(x);
        };
        // 创建复合函数 h(x) = g(f(x)) = cos(sin(x))
        auto h = [f, g](double x) {
            return g(f(x));
        };
        // 使用复合函数
        double value = M_PI / 4;  // PI/4
        std::cout << "h(pi/4) = cos(sin(pi/4)) = " << h(value) << std::endl;
        return 0;
    }

    如果想要一个更复杂的复合函数,比如说 $\text{cos}(\text{sin}(\text{exp}(x))$ ,可以这样做:

    auto exp_func = [](double x) {
        return std::exp(x);
    };
    // 创建复合函数 h(x) = cos(sin(exp(x)))
    auto h_complex = [f, g, exp_func](double x) {
        return g(f(exp_func(x)));
    };
    std::cout << "h_complex(1) = cos(sin(exp(1))) = " << h_complex(1) << std::endl;

    使用 Lambda 表达式进行函数组合的优点之一是它们允许你轻松地创建高阶函数,也就是层层套娃的复合函数。

    auto compose = [](auto f, auto g) {
        return [f, g](double x) {
            return g(f(x));
        };
    };
    auto h_composed = compose(f, g);
    std::cout << "h_composed(pi/4) = " << h_composed(M_PI / 4) << std::endl;

    上面这个例子就是高阶函数的核心思想。

    高阶数学操作

    实现微分和积分计算器,这些操作可以使用 Lambda 表达式来近似数学函数的导数和积分。

    这里的微分使用数值微分的前向差分法来近似倒数 $f'(x)$ 。

    积分采用梯形法则的数值积分方法。

    // 微分
    auto derivative = [](auto func, double h = 1e-5) {
        return [func, h](double x) {
            return (func(x + h) - func(x)) / h;
        };
    };
    // 例如,对 sin(x) 的微分
    auto sin_derivative = derivative([](double x) { return std::sin(x); });
    std::cout << "sin'(pi/4) ≈ " << sin_derivative(M_PI / 4) << std::endl;
    
    // 积分 - 积分下限 a,积分上限 b 和分割数量 n 
    auto trapezoidal_integral = [](auto func, double a, double b, int n = 1000) {
        double h = (b - a) / n;
        double sum = 0.5 * (func(a) + func(b));
        for (int i = 1; i < n; i++) {
            sum += func(a + i * h);
        }
        return sum * h;
    };
    // 例如,对 sin(x) 在 0 到 pi/2 上的积分
    auto integral_sin = trapezoidal_integral([](double x) { return std::sin(x); }, 0, M_PI / 2);
    std::cout << "∫sin(x)dx from 0 to pi/2 ≈ " << integral_sin << std::endl;

    数值微分 – 前向差分法

    对函数 $$f(x)$$ 在点 $$x$$ 处的导数的数值近似可以通过前向差分公式给出 :

    这里的 $$h$$ 代表 $$x$$ 值的一个微小增加。当 $$h$$ 趋向于 0 时,这个比率会趋向于导数的真实值。在代码中我们设置了一个比较小的数值 $$10^{-5}$$ 。

    数值积分 – 梯形法则

    定积分 $$\int_a^b f(x) d x$$ 的数值近似可以使用梯形法则来计算 :

    其中 $$n$$ 是区间 $$[ a, b ]$$ 被分成的小区间的数量, $$h$$ 是每个小区间的宽度,计算方法为:

    中级篇

    1. Lambda的底层实现

    从表面上看,lambda表达式似乎只是语法糖,但实际上,编译器会对每个lambda表达式做一些底层转换。

    首先,每个lambda表达式的类型都是独一无二的,编译器会为每个lambda生成一个唯一的类类型,这通常被称为闭包类型

    闭包(closure)这个概念来源于数学中的闭包,它指的是一种结构,这种结构内部的操作是封闭的,不依赖于结构外部的元素。也就是说,任何对集合内元素应用这个操作的结果仍然会在这个集合内。在编程中,这个词被用来描述一个函数与其上下文环境的组合。 一个闭包允许你访问一个外部函数作用域中的变量,即使这个外部函数已经执行结束。函数“封闭”了或“捕获”了其创建时的环境状态。 lambda表达式默认情况下生成的闭包类的operator()是const的,这个情况下开发者不能修改闭包内部的任何数据,即保证了它们不会修改捕获的值,这与闭包的数学和函数式起源相符。

    编译器为每个lambda表达式生成一个闭包类。这个类重载了operator(),使得闭包对象可以像函数一样被调用。这个重载的操作符包含了lambda表达式的代码。

    lambda表达式可以捕获外部变量,这通过闭包类的成员变量实现。捕获可以是值捕获或引用捕获,分别对应于闭包类中值的复制和引用的存储。

    闭包类有一个构造函数,该构造函数用于初始化捕获的外部变量。如果是值捕获,这些值会被复制到闭包对象中。如果是引用捕获,外部变量的引用会被存储。

    当调用lambda表达式时,实际上是调用闭包对象的operator()。

    假设lambda表达式如下:

    [capture](parameters) -> return_type { body }

    一段编译器可能会生成的伪代码:

    // 闭包类的伪代码可能如下所示:
    class UniqueClosureName {
    private:
        // 捕获的变量
        capture_type captured_variable;
    public:
        // 构造函数,用于初始化捕获的变量
        UniqueClosureName(capture_type captured) : captured_variable(captured) {}
        // 重载的函数调用操作符
        return_type operator()(parameter_type parameters) const {
            // lambda表达式的主体
            body
        }
    };
    // 使用闭包类的实例
    UniqueClosureName closure_instance(captured_value);
    auto result = closure_instance(parameters); // 这相当于调用lambda表达式

    2. Lambda的类型和decltype与条件编译constexpr(C++17)

    我们知道,每个lambda表达式都有其独特的类型,这是由编译器自动生成的。即使两个lambda表达式看起来完全相同,它们的类型也是不同的。这些类型无法直接在代码中表示,我们是借助模板和类型推导机制来操作和推断它们。

    获取一个lambda表达式的类型可以使用decltype关键字。下面例子中,decltype(lambda)得到的是lambda表达式的确切类型。这样就可以声明另一个同类型的变量another_lambda,并将原始lambda赋值给它。这种特性一般在模版编程中发挥重要作用。

    看下面厨师做菜的例子。你目前不知道食材 ingredient 的类型,但是可以用 decltype 得到食材的类型。这个的关键点就是,可以明确得到返回值的类型,并且为lambda标记返回类型。

    template <typename T>
    auto cookDish(T ingredient) -> decltype(ingredient.prepare()) {
        return ingredient.prepare();
    }

    进一步的,decltype 在 C++ 中的一个重要用途是在编译时根据不同的类型选择不同的代码路径,也就是条件编译

    #include <type_traits>
    template <typename T>
    void process(T value) {
        if constexpr (std::is_same<decltype(value), int>::value) {
            std::cout << "处理整数: " << value << std::endl;
        } else if constexpr (std::is_same<decltype(value), double>::value) {
            std::cout << "处理浮点数: " << value << std::endl;
        } else {
            std::cout << "处理其他类型: " << value << std::endl;
        }
    }

    下面例子是关于lambda的。

    #include <iostream>
    #include <type_traits>
    // 一个泛型函数,根据传入的 lambda 类型执行不同的操作
    template <typename T>
    void executeLambda(T lambda) {
        if constexpr (std::is_same<decltype(lambda), void(*)()>::value) {
            std::cout << "Lambda is a void function with no parameters." << std::endl;
            lambda();
        } else if constexpr (std::is_same<decltype(lambda), void(*)(int)>::value) {
            std::cout << "Lambda is a void function taking an int." << std::endl;
            lambda(10);
        } else {
            std::cout << "Lambda is of an unknown type." << std::endl;
        }
    }
    int main() {
        // Lambda with no parameters
        auto lambda1 = []() { std::cout << "Hello from lambda1!" << std::endl; };
        // Lambda with one int parameter
        auto lambda2 = [](int x) { std::cout << "Hello from lambda2, x = " << x << std::endl; };
        executeLambda(lambda1);
        executeLambda(lambda2);
        return 0;
    }

    3. Lambda在新标准中的进化

    C++11

    • 引入Lambda表达式: C++11标准首次引入了Lambda表达式,可以便捷地定义匿名函数对象。基本形式是 capture -> return_type { body }。
    • 捕获列表: 支持通过值(=)或引用(&)捕获外部变量。

    C++14

    • 泛型Lambda: 允许在参数列表中使用auto关键字,使Lambda可以像模板函数一样工作。
    • 捕获初始化: 允许在捕获列表中使用初始化表达式,创建Lambda专有的数据成员。

    C++17

    • 默认构造和赋值: Lambda表达式产生的闭包类型在某些条件下可以是默认构造的和可赋值的。
    • 捕获*this指针: 通过*this捕获,可以值拷贝当前对象到Lambda中,避免悬挂指针问题。
    • constexpr Lambda: constexpr Lambda可以用于在编译时进行计算。在模板元编程、编译时数据生成等场景特别有用。

    C++20

    • 模板Lambda: Lambda表达式可以有模板参数列表,类似于模板函数。
    • 更灵活的捕获列表: 允许使用[=, this]和[&, this]形式的捕获列表。
    • 隐式移动捕获: 在适当的情况下,自动采用移动捕获(C++14中仅支持拷贝和引用捕获)。

    4. 状态保持的Lambda

    下面例子,值、引用捕获变量 x 就是让Lambda保持状态的关键。还可以捕获并保持自己的状态。

    #include <iostream>
    int main() {
        int x0 = 10, x1 = 20, count = 0;
        auto addX = [x0, &x1, count](int y) mutable {
            count++;
            return x0 + x1 + y + count;
        };
        std::cout << addX(5) << std::endl;  // 输出 36
        std::cout << addX(5) << std::endl;  // 输出 37
        std::cout << addX(5) << std::endl;  // 输出 38
    }

    5. 优化与Lambda

    Lambda为什么好?

    • 內联优化:Lambda一般比较短小,內联优化减少函数调用开销。
    • 避免非必要的对象创建:引用捕获、移动语义可以减少大型对象转移的开销、复制。
    • 延迟计算:在真正需要结果时才执行计算。

    6. 与其他编程范式的结合

    函数式编程

    class StringBuilder {
    private:
        std::string str;
    public:
        StringBuilder& append(const std::string& text) {
            str += text;
            return *this;
        }
        const std::string& toString() const {
            return str;
        }
    };
    // 使用
    StringBuilder builder;
    builder.append("Hello, ").append("world! ");
    std::cout << builder.toString() << std::endl;  // 输出 "Hello, world! "

    流水线调用

    #include <ranges>
    #include <vector>
    #include <iostream>
    int main() {
        std::vector<int> vec = {1, 2, 3, 4, 5};
        auto pipeline = vec 
                        | std::views::transform([](int x) { return x * 2; })
                        | std::views::filter([](int x) { return x > 5; });
        for (int n : pipeline) std::cout << n << " "; // 输出满足条件的元素
    }

    7. Lambda与异常处理

    auto divide = [](double numerator, double denominator) {
        if (denominator == 0) {
            throw std::runtime_error("Division by zero.");
        }
        return numerator / denominator;
    };
    try {
        auto result = divide(10.0, 0.0);
    } catch (const std::runtime_error& e) {
        std::cerr << "Caught exception: " << e.what() << std::endl;
    }

    虽然Lambda表达式本身不能包含try-catch块(在C++20之前),但可以在Lambda表达式的外部进行异常捕获。即:

    auto riskyTask = []() {
        // 假设这里有可能抛出异常的代码
    };
    try {
        riskyTask();
    } catch (...) {
        // 处理异常
    }

    从C++20开始,Lambda表达式支持异常规范。

    在 C++17 之前,可以在函数声明中使用动态异常规范,例如 throw(Type),来指定函数可能抛出的异常类型。但是,这种做法在 C++17 中被废弃,并在 C++20 中完全移除。取而代之的是 noexcept 关键字,它用来指示一个函数是否会抛出异常。

    auto lambdaNoExcept = []() noexcept {
        // 这里保证不会抛出任何异常
    };

    进阶篇

    1. Lambda与noexcept (C++11)

    noexcept 可用于指明Lambda表达式是否保证不抛出异常。

    auto lambda = []() noexcept {
        // 这里的代码保证不抛出异常
    };

    当编译器知道一个函数不会抛出异常时,它可以生成更优化的代码。

    也可以显式的抛出异常,提高代码可读性。但是和不写是一样的。

    auto lambdaWithException = []() noexcept(false) {
        // 这里的代码可能会抛出异常
    };

    2. Lambda中的模板参数(C++20)

    在C++20中,Lambda表达式得到了一个重要的增强,即支持模板参数,太酷啦。

    auto lambda = []<typename T>(T param) {
        // 使用模板参数T的代码
    };
    auto print = []<typename T>(const T& value) {
        std::cout << value << std::endl;
    };
    print(10);        // 打印一个整数
    print("Hello");   // 打印一个字符串

    3. Lambda的反射

    不知道,晚点再写。

    4. 跨平台和ABI的问题

    不知道,晚点再写。

  • GAMES101.HW7:路径追踪代码实现和微材质模型

    GAMES101.HW7:路径追踪代码实现和微材质模型

    项目代码 https://github.com/Remyuu/GAMES101-Homework

    本文分为两个部分:路径追踪代码实现微材质模型

    我们在 HW.5 构建了Whitted-Style Ray Tracing算法光线追踪项目,在 HW.6 利用BVH加速结构加速了求交过程。这次,我们构建Path Tracing的光线追踪,并且利用多线程加速渲染。最后使用微表面模型为项目提供更具粗糙感的材质。

    另外需要注意,本文关于微表面模型的内容主要来源于 Ref.5 ,主要讲了Cook-Torrance模型的基本理论与代码实现。

    本文基本解说了框架的全部内容,如内容有误恳请指出。本项目是关于渲染一个CornellBox场景,最终的效果大致如下图所示:

    img

    参数1:{SSP:64, res:{784, 784}, 并行: false, RussianRoulette = 0.8}, 渲染时间:{4101 seconds},

    参数2:{SSP:64, res:{784, 784}, 并行: true, RussianRoulette = 0.8, cookTorrance, PDF = GGX}, 渲染时间:{3415 seconds}

    作业七框架下载地址 (自建小水管下载慢请见谅)

    项目流程 – main.cpp

    按照惯例,我们从main函数开始分析。

    这个项目的流程非常简单:设置好场景,然后渲染。接下来我们详细看看。

    首先初始化Scene对象,并且设置场景分辨率。

    // Change the definition here to change resolution
    Scene scene(784, 784);

    创建四种材质——红色、绿色、白色和灯光。这些材质使用了DIFFUSE类型并分别设置了不同的漫反射系数(Kd)。

    Material* red = new Material(DIFFUSE, Vector3f(0.0f));
    red->Kd = Vector3f(0.63f, 0.065f, 0.05f);
    ...
    Material* light = new Material(DIFFUSE, (8.0f * Vector3f(0.747f+0.058f, 0.747f+0.258f, 0.747f) + 15.6f * Vector3f(0.740f+0.287f,0.740f+0.160f,0.740f) + 18.4f *Vector3f(0.737f+0.642f,0.737f+0.159f,0.737f)));
    light->Kd = Vector3f(0.65f);

    创建康奈尔场景的物体,然后添加到场景中。

    MeshTriangle floor("../models/cornellbox/floor.obj", white);
    ...
    MeshTriangle light_("../models/cornellbox/light.obj", light);
    scene.Add(&floor);
    ...
    scene.Add(&light_);

    构建BVH,用于加速光线与场景中物体的碰撞检测。

    scene.buildBVH();

    最后,创建一个渲染器对象 r 渲染场景,并且记录渲染的时间。

    Renderer r;
    auto start = std::chrono::system_clock::now();
    r.Render(scene);
    auto stop = std::chrono::system_clock::now();

    以上就是项目的大致流程。

    物体抽象基类 – Object

    Object类定义了一个物体在光线追踪算法中需要的所有基本行为。它使用了纯虚函数,表明这是一个接口,需要被具体的物体类(MeshTriangle、Sphere和Triangle)所继承并实现这些方法。

    详细的说明请看下面的代码注释:

    Object() {}
    virtual ~Object() {}
    virtual bool intersect(const Ray& ray) = 0;// 用于判断一条射线是否与该物体相交
    virtual bool intersect(const Ray& ray, float &, uint32_t &) const = 0;// 也是用于检测射线与物体是否相交,但此函数还会返回交点的参数化表示和相交点索引
    virtual Intersection getIntersection(Ray _ray) = 0;// 返回射线与该物体的交点信息
    virtual void getSurfaceProperties(const Vector3f &, const Vector3f &, const uint32_t &, const Vector2f &, Vector3f &, Vector2f &) const = 0;// 该函数用于获取物体表面的属性,如表面的法线、纹理坐标等
    virtual Vector3f evalDiffuseColor(const Vector2f &) const =0;// 评估物体在特定纹理坐标下的漫反射颜色
    virtual Bounds3 getBounds()=0;// 返回物体的边界框
    virtual float getArea()=0;// 返回物体的表面积,每一个形状的计算方法都可以不一样
    virtual void Sample(Intersection &pos, float &pdf)=0;// 从物体表面采样一个点,用于光源采样。`pos` 参数是采样点的信息,`pdf` 是该点的概率密度函数值。
    virtual bool hasEmit()=0;// 判断该物体是否发光,也就是是否为光源。

    基于这个类,我们还创建了三个具体的物体类:MeshTriangle、Sphere和Triangle。这三个类都是物体类Object的子类,用于在三维空间中表示不同的几何形状。

    由于我们需要光线追踪渲染画面,所以我们需要实现一个重要的操作intersect,用于检测一个光线是否与物体相交。

    另外,每个类都有一个Material类型的数据成员m,表示物体的材料。材料定义了物体的颜色、纹理、发射光线等属性。

    在Triangle.hpp中, rayTriangleIntersect 使用的是Möller-Trumbore算法,用于确定射线是否与三维空间中的三角形相交,如果相交,它还可以计算出交点的精确位置。详细代码解释请查看我另一篇文章 ,主要的步骤写在代码注释中了。

    bool rayTriangleIntersect(const Vector3f& v0, const Vector3f& v1,
                              const Vector3f& v2, const Vector3f& orig,
                              const Vector3f& dir, float& tnear, float& u, float& v){
        // 首先,计算出三角形两边的向量(edge1和edge2),然后根据射线方向dir和边edge2的向量积(外积)来计算一个新的向量pvec。
        Vector3f edge1 = v1 - v0;
        Vector3f edge2 = v2 - v0;
        Vector3f pvec = crossProduct(dir, edge2);
        float det = dotProduct(edge1, pvec);
        if (det == 0 || det < 0)
            return false;
        // 然后,通过计算pvec与边edge1的点积(内积),得到一个determinant(行列式)值。如果这个值为0或负数,说明射线与三角形平行或射线在三角形的反向,此时应返回false。
        Vector3f tvec = orig - v0;
        u = dotProduct(tvec, pvec);
        if (u < 0 || u > det)
            return false;
        // 之后,计算tvec和edge1的向量积得到qvec,并计算其与dir的点积得到v。如果v小于0或者u+v大于det,返回false。
        Vector3f qvec = crossProduct(tvec, edge1);
        v = dotProduct(dir, qvec);
        if (v < 0 || u + v > det)
            return false;
        float invDet = 1 / det;
        // 最后,如果通过了所有的测试,说明射线与三角形有交点。计算交点的深度tnear,以及在三角形内部的barycentric坐标(u, v)。
        tnear = dotProduct(edge2, qvec) * invDet;
        u *= invDet;
        v *= invDet;
        return true;
    }

    由于篇幅的原因,这三个类我们就只挑一些重点讲解。

    Triangle

    接下来,一个 Triangle 对象表示一个三维空间中的三角形。

    构造函数

    Triangle(Vector3f _v0, Vector3f _v1, Vector3f _v2, Material* _m = nullptr)
        : v0(_v0), v1(_v1), v2(_v2), m(_m)
    {
        e1 = v1 - v0;
        e2 = v2 - v0;
        normal = normalize(crossProduct(e1, e2));
        area = crossProduct(e1, e2).norm()*0.5f;
    }

    每个三角形都有三个顶点(v0、v1和v2),两个边向量(e1和e2),一个法线向量(normal),一个面积(area),以及一个材质指针(m)。在三角形的构造函数中,根据输入的三个顶点,计算了边向量,法线向量,以及面积。其中,面积(area)的计算方法是e1和e2的叉积的模长的一半。

    三角形相关操作

    这里有三个函数(Sample、getArea和hasEmit)被直接重写了。

    ...
    void Sample(Intersection &pos, float &pdf){
        float x = std::sqrt(get_random_float()), y = get_random_float();
        pos.coords = v0 * (1.0f - x) + v1 * (x * (1.0f - y)) + v2 * (x * y);
        pos.normal = this->normal;
        pdf = 1.0f / area;
    }
    float getArea(){
        return area;
    }
    bool hasEmit(){
        return m->hasEmission();
    }
    1. Sample函数在三角形的表面上随机采样一个点,然后返回:
    2. 这个点的信息(包括位置和法线向量)
    3. 采样点的概率密度函数值(pdf)
    4. getArea函数返回三角形的面积。
    5. hasEmit函数检查三角形的材质是否有发光。

    MeshTriangle

    这个MeshTriangle类也是Object类的子类。它表示一个由许多三角形组成的3D模型或网格。也就是说, MeshTriangle 对象内可能包含了许多 Triangle 对象。举个例子说,一个立方体模型,你可以使用12个三角形(每个面2个三角形,共6个面)来表示。

    MeshTriangle类还包括一些额外的功能,如计算模型的AABB边界框、 BVHAccel对象等。

    构造函数

    以下伪代码简洁地描述了MeshTriangle的构造流程:

    MeshTriangle(string filename, Material* mt) {
        // 1. 加载模型文件
        loader.LoadFile(filename);
        // 2. 为每个面创建一个Triangle对象并存储
        for (每个面 in 模型) {
            Triangle tri = 创建三角形(面的顶点, mt);
            triangles.push_back(tri);
        }
        // 3. 计算模型的包围盒和总面积
        bounding_box = 计算包围盒(模型的所有顶点);
        area = 计算总面积(所有的三角形);
        // 4. 创建一个用于快速交集测试的BVH
        bvh = 创建BVH(所有的三角形);
    }

    构造函数接受一个文件名filename和一个材质mt,然后使用objl::Loader来加载3D模型。在加载模型之后,它遍历模型的所有三角形,并创建对应的Triangle对象。与此同时,计算并存储了整个模型的边界框,以及所有三角形的总面积。

    在框架中,我们使用了objl::Loader类读取.obj文件。调用loader.LoadFile(filename)完成加载。然后访问loader.LoadedMeshes获取加载的3D模型数据。

    objl::Loader loader;
    loader.LoadFile(filename);
    ...
    auto mesh = loader.LoadedMeshes[0];

    加载时我们注意到一句断言,这是检查一个模型是否只有唯一的网格,如果有多个网格或没有网格,则触发断言错误。

    assert(loader.LoadedMeshes.size() == 1);

    初始化模型顶点的最小和最大值,用于计算3D模型的轴对齐包围盒。

    Vector3f min_vert = Vector3f{std::numeric_limits<float>::infinity(),
                                 std::numeric_limits<float>::infinity(),
                                 std::numeric_limits<float>::infinity()};
    Vector3f max_vert = Vector3f{-std::numeric_limits<float>::infinity(),
                                 -std::numeric_limits<float>::infinity(),
                                 -std::numeric_limits<float>::infinity()};

    接下来我们需要了解objl库中mesh的数据结构,也就是objl::Loader loader里面存储了什么。

    • MeshName: 储存了网格(mesh)的名字
    • Vertices: 存储模型中所有的顶点数据,包括位置,法线和纹理坐标。
    • Indices: 存储模型中所有的面(通常为三角形)数据,每个面由一组指向Vertices中顶点的索引构成。
    • MeshMaterial: 存储模型中的所有材质数据,包括漫反射颜色、镜面高光颜色、纹理等属性。

    每个三角形的顶点信息会连续的存储在Vertices里,所以我们每三个顶点作为一组构建Triangle。然后设置AABB。

    for (int i = 0; i < mesh.Vertices.size(); i += 3) {
        std::array<Vector3f, 3> face_vertices;
        for (int j = 0; j < 3; j++) {
            auto vert = Vector3f(mesh.Vertices[i + j].Position.X,
                                 mesh.Vertices[i + j].Position.Y,
                                 mesh.Vertices[i + j].Position.Z);
            face_vertices[j] = vert;
            min_vert = Vector3f(std::min(min_vert.x, vert.x),
                                std::min(min_vert.y, vert.y),
                                std::min(min_vert.z, vert.z));
            max_vert = Vector3f(std::max(max_vert.x, vert.x),
                                std::max(max_vert.y, vert.y),
                                std::max(max_vert.z, vert.z));
        }
        triangles.emplace_back(face_vertices[0], face_vertices[1],
                               face_vertices[2], mt);
    }
    bounding_box = Bounds3(min_vert, max_vert);

    最后计算所有三角形的面积,并且构建BVH加速结构。在循环中,代码首先将所有三角形的指针存入ptrs,然后计算所有三角形的面积之和。然后将所有三角形指针都传入到BVH构造函数中。

    std::vector<Object*> ptrs;
    for (auto& tri : triangles){
        ptrs.push_back(&tri);
        area += tri.area;
    }
    bvh = new BVHAccel(ptrs);

    网格三角形相关操作

    1. 面片属性的计算

    首先是面片属性的计算 — getSurfaceProperties,这里需要计算出以下几个属性:

    1. 某三角形的法线向量N:这个好做,直接找三角形两个边做一个叉积。
    2. 纹理坐标st:对三角形顶点的纹理坐标进行插值得到,uv是交点在三角形内部的barycentric坐标,下面详细说说。
    void getSurfaceProperties(const Vector3f& P, const Vector3f& I,
                              const uint32_t& index, const Vector2f& uv,
                              Vector3f& N, Vector2f& st) const{
        const Vector3f& v0 = vertices[vertexIndex[index * 3]];
        const Vector3f& v1 = vertices[vertexIndex[index * 3 + 1]];
        const Vector3f& v2 = vertices[vertexIndex[index * 3 + 2]];
        Vector3f e0 = normalize(v1 - v0);
        Vector3f e1 = normalize(v2 - v1);
        N = normalize(crossProduct(e0, e1));
        const Vector2f& st0 = stCoordinates[vertexIndex[index * 3]];
        const Vector2f& st1 = stCoordinates[vertexIndex[index * 3 + 1]];
        const Vector2f& st2 = stCoordinates[vertexIndex[index * 3 + 2]];
        st = st0 * (1 - uv.x - uv.y) + st1 * uv.x + st2 * uv.y;
    }

    这个函数我们在Triangle也看到了,但是在MeshTriangle中,有所不同。

    正如它们的名称所暗示,Triangle表示一个独立的三角形,而MeshTriangle表示一组相互连接的三角形,也就是一个三角形网格。Triangle的getSurfaceProperties会直接使用储存在Triangle类内的顶点和纹理坐标信息。而我们的MeshTriangle有多个三角形,于是我们通过参数index得知需获取的三角形。

    对于纹理坐标的计算,首先我们知道UV坐标用于将2D纹理映射到3D模型上的过程中。使用uv坐标对三角形顶点的st坐标进行加权求和,以获得交点的st坐标。这被称为插值。这些坐标定义了3D模型的每个顶点在2D纹理上的对应位置。

    在该函数中,

    • st0, st1, 和 st2 是三角形顶点对应的纹理坐标。这些坐标指定了顶点在纹理贴图中的位置。
    • 1 – uv.x – uv.y,uv.x和uv.y 分别对应三角形三个顶点的权重。换句话说,如果你在三角形的一个顶点,那么该顶点的权重为1,其他顶点的权重为0。
    • 另外,stCoordinates是事先又美术人员定义好的,程序员不需要关心。

    根据Möller Trumbore算法,决定了st0对应(1 – uv.x – uv.y), st1对应uv.x, st2对应uv.y。

    总结一下该函数的作用:用每个顶点的纹理坐标 st 乘以它对应的权重,然后把它们加起来。这是一种插值方法,可以用来找出三角形内任意点的纹理坐标。

    2. uv坐标在特定材质上的漫反射颜色

    evalDiffuseColor这个函数是用来计算一个给定二维纹理坐标在特定材质上的漫反射颜色的。

    光线在撞击物体表面后会按照一定的规则反射,这个规则受到物体表面材质的影响。漫反射颜色就是描述这个反射效果的一种方式,它代表了物体表面对光线的反射能力。

    当pattern项分别设置为0,默认和1时的效果图:

    img
    Vector3f evalDiffuseColor(const Vector2f& st) const
    {
        float scale = 5;
        float pattern =
            (fmodf(st.x * scale, 1) > 0.5) ^ (fmodf(st.y * scale, 1) > 0.5);
        return lerp(Vector3f(0.815, 0.235, 0.031),
                    Vector3f(0.937, 0.937, 0.231), pattern);
    }

    碰撞点信息类 – Intersection

    碰撞信息结构体

    这个类用来保存光线与物体交点的信息。关于每一项的作用我写在了下面的注释中供大家查阅。

    struct Intersection
    {
        Intersection(){
            happened=false;
            coords=Vector3f();
            normal=Vector3f();
            distance= std::numeric_limits<double>::max();
            obj =nullptr;
            m=nullptr;
        }
    // happened 表示是否真的发生了交点。
    //如果光线并没有碰到任何物体,那么happened就会是false。
        bool happened;
    // coords 表示交点的坐标。
    //如果happened为true,那么coords就会包含光线与物体相交的准确位置。
        Vector3f coords;
    // coords 表示交点的纹理坐标。
    //它用于获取物体表面在交点位置的纹理信息。
        Vector3f tcoords;
    // normal 表示交点处的法向量。
    //法向量是垂直于物体表面的向量,用于确定物体的朝向,它在光照计算中起着关键作用。
        Vector3f normal;
    // emit表示交点处的光源发射值。
    //如果交点所在的物体是光源,这个向量就是非零的。
        Vector3f emit;
    // 表示光线的原点到交点的距离。
        double distance;
    // 指向光线所碰撞的物体。
        Object* obj;
    // 指向交点处物体的材质,包含物体的颜色、光滑度、反射率等。
        Material* m;
    };

    获取碰撞信息

    这个函数其实是在Triangle和Sphere以及BVHAccel里面的,但是该函数离不开Intersection结构体,同时为了排版,所以干脆放在这一章了。

    Intersection in Triangle class

    首先,直接贴出源代码:

    inline Intersection Triangle::getIntersection(Ray ray)
    {
        Intersection inter;
        if (dotProduct(ray.direction, normal) > 0)
            return inter;
        double u, v, t_tmp = 0;
        Vector3f pvec = crossProduct(ray.direction, e2);
        double det = dotProduct(e1, pvec);
        if (fabs(det) < EPSILON)
            return inter;
        double det_inv = 1. / det;
        Vector3f tvec = ray.origin - v0;
        u = dotProduct(tvec, pvec) * det_inv;
        if (u < 0 || u > 1)
            return inter;
        Vector3f qvec = crossProduct(tvec, e1);
        v = dotProduct(ray.direction, qvec) * det_inv;
        if (v < 0 || u + v > 1)
            return inter;
        t_tmp = dotProduct(e2, qvec) * det_inv;
        if (t_tmp < 0)
        {
            return inter;
        }
        inter.distance = t_tmp;
        inter.coords = ray(t_tmp);
        inter.happened = true;
        inter.m = m;
        inter.normal = normal;
        inter.obj = this;
        return inter;
    }

    渲染器类 – Renderer

    Renderer的运作流程非常简单:循环为屏幕的每一个像素生成图像。

    以下是一些简单的说明文字:

    • spp(samples per pixel):每个像素的采样数量,表示光线追踪算法将在一个像素中投射多少条光线。
    • framebuffer:一个一维数组,其大小为width*height,它用于存储场景中每个像素的颜色。
    • scene.castRay(Ray(eye_pos, dir), 0)函数:调用光线进行追踪算法。这个函数将射出一条光线,计算出这条光线在场景中所碰到的物体的颜色。这个颜色值然后被加到framebuffer中对应像素的颜色上。

    所以渲染Renderer类的重点就是这一行castRay。而castRay在Scene类中,也是本文的重点。

    关于intersect的一些说明

    我们在项目中看到大量的intersect函数,让人眼花缭乱。比如初次看到intersect(),在三角形类中直接返回了true。但是实际上我们会疑问,难道不应该包含判断逻辑?而不是直接返回0或1。还有BVHAccel里面的Intersect(),Bounds3里面的IntersectP(),具体对象的getIntersection之间的关系等等。

    关于这点,特此简单说明流程。

    按照流程,首先在castRay()函数中,我们调用的是BVHAccel的Intersect(),然后BVHAccel的Intersect()会在BVH数据结构中找到叶子节点的AABB。然后会调用Bounds3的IntersectP判断结点的包围盒与光线是否相交,

    • 如果不相交:返回Intersection类的默认构造(空的碰撞数据结构);
    • 如果当前节点是叶节点:直接调用对象(object)的getIntersection方法计算光线与物体的交点。

    接下来getIntersection方法会返回对应于他们物体类型的Intersection数据结构,下面分别是Sphere、MeshTriangle和Triangle的getIntersection方法。

    Intersection getIntersection(Ray ray){
        Intersection result;
        ...
        return result;
    }
    Intersection getIntersection(Ray ray){
        Intersection intersec;
        if (bvh) intersec = bvh->Intersect(ray);
        return intersec;
    }

    其中,Triangle的getIntersection方法标记了override,意思是在类定义内部的是函数声明,而在类定义外部的是函数定义。因此真正执行的部分是inline:

    inline Intersection Triangle::getIntersection(Ray ray){
        ...
    }

    场景类 – Scene

    再讲castRay之前,我们先简单浏览一下Scene的大致形态。

    这个类包含了场景中所有的物体和灯光,还包含了一些渲染参数,如场景的宽度、高度、视场、背景色等。接下来对这个类的主要部分做一些说明:

    成员变量:

    • width 和 height 是场景的像素宽度和高度,fov 是摄像机的视场角backgroundColor 是场景的背景色。
    • objects 和 lights 分别存储场景中的物体和光源。objects 是一个指向 Object 类型的指针的向量,lights 是一个包含 智能指针\ 类型的向量。
    • bvh 是一个指向 BVHAccel 的指针,用于存储场景的边界体层次(Bounding Volume Hierarchy,BVH)结构,以加速光线与物体的相交计算。
    • maxDepth 和 RussianRoulette 用于控制路径追踪算法的细节(我们一会再讲)。

    成员函数:

    • Add 函数用于向场景中添加物体或光源。
    • HandleAreaLight 函数用于处理面光源的光线追踪。
    • reflect 和 refract 函数分别用于计算光线的反射和折射方向。refract 函数实现了斯涅尔定律,用于计算光线在进入不同介质时的折射方向。需要特别处理两种情况:光线从物体内部射出,和光线从物体外部射入。
    • fresnel 函数用于计算菲涅尔方程,得到反射光和透射光的比例。也就是光线在经过两种不同介质的界面时,反射和透射的比例。
    • intersect、buildBVH、castRay、sampleLight 和 trace 函数的功能在后文详细展开。

    至此,这个 Scene 类就封装了一个光线追踪渲染场景的所有数据和操作。接下来详细说说这个类的一些细节。

    在 Scene.cpp 中,首先有 buildBVH() 函数,该函数创建一个边界体层次结构(BVH)。

    然后,intersect(const Ray &ray) const 函数用于查找由 ray 定义的光线与场景中的任何物体的交点。它通过使用先前构建的BVH来提高效率。

    sampleLight(Intersection &pos, float &pdf) const 函数用于对场景中的发光物体的随机采样。这个函数首先计算出所有发光物体的面积总和,然后在这个面积中随机选择一个位置作为采样点。

    void Scene::sampleLight(Intersection &pos, float &pdf) const{
        float emit_area_sum = 0;
        for (uint32_t k = 0; k < objects.size(); ++k) {
            if (objects[k]->hasEmit()){
                emit_area_sum += objects[k]->getArea();
            }
        }
        float p = get_random_float() * emit_area_sum;
        emit_area_sum = 0;
        for (uint32_t k = 0; k < objects.size(); ++k) {
            if (objects[k]->hasEmit()){
                emit_area_sum += objects[k]->getArea();
                if (p <= emit_area_sum){
                    objects[k]->Sample(pos, pdf);
                    break;
                }
            }
        }
    }

    输入: Intersection 引用 pos,浮点数引用 pdf。

    这两个参数都是输出参数,也就是说,这个方法会改变它们的值。Intersection 类型的对象用于存储光线与物体的交点信息,pdf 表示选取这个交点的概率密度。

    输出:这个方法没有返回值,它的结果通过改变 pos 和 pdf 的值返回。

    具体流程

    1. 首先,这个方法通过第一个循环计算出所有发光物体的面积之和 emit_area_sum。对于每一个物体,它先调用 hasEmit 方法检查这个物体是否能发光,如果能发光,就调用 getArea 方法得到这个物体的面积,并加到 emit_area_sum 中。
    2. 接着,它生成一个随机数 p,范围在 0 到 emit_area_sum 之间。这个随机数 p 用于随机选择一个发光物体。
    3. 然后,它通过第二个循环来选择发光物体。在这个循环中,它先检查每一个物体是否能发光,如果能发光,就加上这个物体的面积,然后检查 p 是否小于或等于当前的 emit_area_sum。如果是,那么这个物体就是被选中的物体。然后,它会调用这个物体的 Sample 方法,在这个物体上随机选取一个点,更新 pos 和 pdf 的值。最后,它会跳出循环,结束这个方法。

    路径追踪实现 – castRay()

    终于讲到castRay了,我们要使用路径追踪(Path Tracing)算法实现光线追踪函数。

    // Implementation of Path Tracing
    Vector3f Scene::castRay(const Ray &ray, int depth) const
    {
        // TO DO Implement Path Tracing Algorithm here
    }

    接下来,介绍一下关键过程:光源采样和间接光照计算。

    也就是说,我们可以将PT算法大致分为两部分:直接光照(Direct Illumination)和间接光照(Indirect Illumination)。

    先给出一些基本定义:

    • p: 碰撞点,是光线与场景中物体的交点。
    • wo: outgoing direction,是从交点反射到相机的方向。
    • wi: incoming direction,是光线从光源反射到交点的方向。

    以下是伪代码,具体了上述过程:

    shade(p, wo)
        // 先计算直接光源
        sampleLight(inter, pdf_light)
        Get x, ws, NN, emit from inter
        Shoot a ray from p to x
        If the ray is not blocked in the middle
            L_dir = emit * eval(wo, ws, N) * dot(ws, N) * dot(ws, NN) / |x-p|^2 / pdf_light
        // 再计算间接光源
        L_indir = 0.0
        Test Russian Roulette with probability RussianRoulette wi = sample(wo, N)
        Trace a ray r(p, wi)
        If ray r hit a non-emitting object at q
            L_indir = shade(q, wi) * eval(wo, wi, N) * dot(wi, N) / pdf(wo, wi, N) / RussianRoulette
        Return L_dir + L_indir

    确保已经清晰地理解 Path Tracing 的实现方式,接下来开始写代码。

    Vector3f Scene::castRay(const Ray &ray, int depth) const
    {
        const float EPLISON = 0.0001f;
        Intersection p_inter = intersect(ray);
        if (!p_inter.happened)
            return Vector3f();// 默认构造零向量-黑色
        if (p_inter.m->hasEmission())// 是否是自发光
            return p_inter.m->getEmission();// 直接返回发光颜色
        // Get the intersection between ray and object plane
        Intersection x_inter;
        float pdf_light = 0.0f;
        // Sample light source at intersection point
        sampleLight(x_inter, pdf_light);
        // Get x, ws, N, NN, emit from inter
        Vector3f p = p_inter.coords;// 物体交点的坐标
        Vector3f x = x_inter.coords;// 光源交点的坐标
        Vector3f ws_dir = (x - p).normalized();// 物体到光源向量
        float ws_distance = (x - p).norm();// 物体到光源距离
        Vector3f N = p_inter.normal.normalized();// 物体交点的法向
        Vector3f NN = x_inter.normal.normalized();// 光源交点的法向
        Vector3f emit = x_inter.emit;// 光源交点的颜色向量
        // Shoot a ray from p to x
        Vector3f l_dir(0.0f), l_indir(0.0f);// 详见下说明
        Ray ws_ray(p, ws_dir);// 做一条从p到光点的Ray
        Intersection ws_ray_inter = intersect(ws_ray);// 然后求交
        // If the ray is not blocked in the middle
        // 即检查从物体p到光源x的直线路径是否被其他物体阻挡
        if(ws_ray_inter.distance - ws_distance > -EPLISON) {// 详见下说明
            l_dir = emit * p_inter.m->eval(ray.direction, ws_ray.direction, N)
                    * dotProduct(ws_ray.direction, N)
                    * dotProduct(-ws_ray.direction, NN)
                    / (ws_distance * ws_distance)
                    / pdf_light;
        }
        // Test Russian Roulette with probability RussianRoulette
        if(get_random_float() <= RussianRoulette) {// 详见下说明
            Vector3f wi_dir = p_inter.m->sample(ray.direction, N).normalized();
            Ray wi_ray(p_inter.coords, wi_dir);
            // If ray r hit a non-emitting object at q
            Intersection wi_inter = intersect(wi_ray);
            // 有检测到碰撞 且 碰撞点不发光,则开始计算间接光照
            if (wi_inter.happened && (!wi_inter.m->hasEmission())) {
                // 详见下说明
                l_indir = castRay(wi_ray, depth + 1) * p_inter.m->eval(ray.direction, wi_ray.direction, N)
                          * dotProduct(wi_ray.direction, N)
                          / p_inter.m->pdf(ray.direction, wi_ray.direction, N)
                          / RussianRoulette;
            }
        }
        return l_dir + l_indir;
    }

    需要说明几点:

    1. ray 或 wo_ray: 这是我们正在处理的光线,称为出射光线 (outgoing ray),表示从某点出发向某个方向传播的光线。在castRay()函数中,ray是作为参数传递的光线。
    2. intersect:会调用位于bvh的重载方法,具体如下:

    c++ Intersection BVHAccel::Intersect(const Ray& ray) const { Intersection isect; if (!root) return isect; isect = BVHAccel::getIntersection(root, ray); return isect; }

    1. p_inter:这是光线与物体表面的交点信息。具体来说,p_inter是一个Intersection类型的对象。
    2. x_inter:这是光线与光源的交点信息。与p_inter类似,x_inter也是一个Intersection类型的对象。
    3. l_dir(direct illumination): 这是由光源直接照射到表面的光照贡献。在路径追踪中,这通常是通过从表面点采样光源并计算直接照明来得到的。
    4. l_indir(indirect illumination): 这是由环境反射到表面的间接光照贡献。在路径追踪中,这通常是通过采样表面的BRDF并递归追踪反射光线来计算的。
    5. ws_ray:这是从点p到点x的光线。在计算直接光照时,需要向光源发射一条新的光线,以检查物体是否直接可以看到光源(即中间没有其他物体阻挡)。这条光线就是ws_ray。

    这里重点说一下直接光照的BRDF公式。此处的公式考虑了几何项和光源采样的pdf,所以公式和我之前文章 中给出的公式有所不同。

    直接光照的数学公式如下: 直接照明下的辐射度光源发出的辐射度入射光线和表面法线之间的角度的余弦值出射光线和光源的法线向量之间的角度的余弦值衰减函数选择光源方向的概率密度函数Ldir=Li⋅fr(wi,wo,N)⋅cos⁡(θ)⋅cos⁡(θ′)r2⋅p(wi)where,Ldir:直接照明下的辐射度Li:光源发出的辐射度fr(wi,wo,N):BRDFcos(θ):入射光线wi和表面法线N之间的角度的余弦值cos(θ′):出射光线−wsdir和光源的法线向量NN之间的角度的余弦值r2:衰减函数p(wi):选择光源方向的概率密度函数(PDF) 转换为代码就是:

    l_dir = emit * p_inter.m->eval(ray.direction, ws_ray.direction, N)
            * dotProduct(ws_ray.direction, N)
            * dotProduct(-ws_ray.direction, NN)
            / (ws_distance * ws_distance)
            / pdf_light;

    其中,eval函数如下,它描述了光照强度如何随着入射光和出射光方向的改变而变化。这个函数的输入是光线的入射方向 ray.direction,反射方向 ws_ray.direction,和表面的法线 N,输出是一个衡量反射光照强度的值。在代码中,漫反射BRDF = ρ/π。

    ...
    float cosalpha = dotProduct(N, wo);
    if (cosalpha > 0.0f) {
        Vector3f diffuse = Kd / M_PI;
        return diffuse;
    }
    else
        return Vector3f(0.0f);
    ...

    计算完直接光照(direct illumination)之后,我们开始利用俄罗斯轮盘赌计算间接光照(indirect illumination)。流程大致如下:

    在Scene.hpp中我们定义了RussianRoulette的数值,只有当取得的随机数小于RussianRoulette,我们才会计算间接光照。也就是说,有可能某个像素一次间接光照都没有被计算到。

    假如我们现在需要计算一次间接光照,我们就为其生成一个新的光线wi_ray,这个光线的方向是基于当前交点的表面材质和原始光线方向进行采样得到的。这部分实现了基于材质的重要性采样。在代码中,我们是在半球上做了均匀采样。具体的计算方法看下面代码:

    ...
    // uniform sample on the hemisphere
    float x_1 = get_random_float(), x_2 = get_random_float();
    float z = std::fabs(1.0f - 2.0f * x_1);
    // r - 半球上点到原点的距离; phi - 极坐标系下的角度
    float r = std::sqrt(1.0f - z * z), phi = 2 * M_PI * x_2;
    Vector3f localRay(r*std::cos(phi), r*std::sin(phi), z);
    return toWorld(localRay, N);
    ...

    最后开始说间接光照l_indir的计算。间接光照的计算公式: Lo=shade(q,−wi)⋅fr⋅cosine⋅1pdf(wi) 但是这里

    l_indir = castRay(wi_ray, depth + 1) * p_inter.m->eval(ray.direction, wi_ray.direction, N)
              * dotProduct(wi_ray.direction, N)
              / p_inter.m->pdf(ray.direction, wi_ray.direction, N)
              / RussianRoulette;

    castRay(wi_ray, depth + 1):这是一个递归调用,代表的是从当前交点发射新的光线,并获取该光线在所有物体上的反射后产生的光照贡献。

    • 最后为什么要除以RussianRoulette?

    因为在使用Russian Roulette的时候,我们随机生成一个数,如果这个数大于一个阈值(这里是RussianRoulette变量),我们就终止光线追踪。当我们终止某条光线路径的追踪时,我们实际上是在放弃了所有这条光线路径可能的后续反射,这些放弃的反射可能会对最终的光线信息有所贡献。

    因此,为了补偿终止追踪的影响,我们把保留下来的光线强度进行放大,放大的倍数就是1 / RussianRoulette。这样可以确保所有保留的光线路径强度的期望值等于它们实际的强度,从而保证了光线追踪算法的无偏性。关于这一点我在我上一篇文章 中也有提及。

    至此,我们就基本把整个项目解释完毕了。除了BVH的构建,那一个部分在我之前的文章 有涉及。

    最终,我们实现了如下渲染:

    img

    接下来我再来讲讲如何用多线程计算加速渲染。

    多线程加速

    c++多线程快速介绍

    对于不太熟悉c++多线程的读者,这里给出一个例子,方便大家快速入门。

    #include <iostream>
    #include <thread>
    // 这是我们将在两个线程中运行的函数
    void printMessage(std::string message) {
        std::cout << message << std::endl;
    }
    int main() {
        // 创建并运行两个线程
        std::thread thread1(printMessage, "Hello from thread 1");
        std::thread thread2(printMessage, "Hello from thread 2");
        // 等待两个线程都结束
        thread1.join();
        thread2.join();
        return 0;
    }

    在我们的主函数结束前,我们需要等待所有的线程都结束,所以我们必须调用join()函数。如果线程没有结束,但是主程序提前结束了,这可能会导致意外。

    部署多线程

    一个比较简单的方法是使用OpenMP,这里不再赘述。

    另一种就是最直接的方法,手动分块+mutex,代码如下:

    // change the spp value to change sample ammount
    int spp = 4;
    int thread_num = 16;
    int thread_height = scene.height / thread_num;
    std::vector<std::thread> threads(thread_num);
    std::cout << "SPP: " << spp << "\n";
    std::mutex mtx;
    float process=0;
    float Reciprocal_Scene_height=1.f/ (float)scene.height;
    auto castRay = [&](int thread_index)
    {
        int height = thread_height * (thread_index + 1);
        for (uint32_t j = height - thread_height; j < height; j++)
        {
            for (uint32_t i = 0; i < scene.width; ++i) {
                // generate primary ray direction
                float x = (2 * (i + 0.5) / (float)scene.width - 1) *
                          imageAspectRatio * scale;
                float y = (1 - 2 * (j + 0.5) / (float)scene.height) * scale;
                Vector3f dir = normalize(Vector3f(-x, y, 1));
                for (int k = 0; k < spp; k++)
                    framebuffer[j*scene.width+i] += scene.castRay(Ray(eye_pos, dir), 0) / spp;
            }
            mtx.lock();
            process = process + Reciprocal_Scene_height;
            UpdateProgress(process);
            mtx.unlock();
        }
    };
    for (int k = 0; k < thread_num; k++){
        threads[k] = std::thread(castRay,k);
        std::cout << "Thread[" << k << "] Started:" << threads[k].get_id() << "\n";
    }
    for (int k = 0; k < thread_num; k++){
        threads[k].join();
    }
    UpdateProgress(1.f);

    但是比较出乎意料的是,在本人的macOS上,单线程与多线程(8t)的速度竟然是相同的。本人配置如下:

    • Model Name: MacBook Pro – 14-inch, 2021
    • Chip: Apple M1 Pro
    • Total Number of Cores: 8 (6 performance and 2 efficiency)
    • Memory: 16G
    • System Version: macOS 13.2.1 (22D68)
    img

    16.threads vs 1.thread

    优化进度显示

    让每个线程跟踪它自己的进度,发现的的确确是各个线程都在同步计算对应的区域。

    auto castRay = [&](int thread_index)
    {
        // Create a local progress variable for this thread
        float thread_progress = 0.f;
        int height = thread_height * (thread_index + 1);
        for (uint32_t j = height - thread_height; j < height; j++)
        {
            for (uint32_t i = 0; i < scene.width; ++i) {
                // ... Rest of your code ...
            }
            // Update this thread's progress
            thread_progress += Reciprocal_Scene_height;
            std::cout << "Thread[" << thread_index << "] Progress: " << thread_progress << "\n";
        }
    };
    img

    经过测试,在我的设备上使用2线程速度是最优的,约加速了70%。在我的任务管理器中也印证了这个说法(程序设置线程数为 2 ):

    img

    猜测M1pro芯片的调度策略是当遇到大量并行计算时,会优先只启动两个大核心协同合作,而不像windows那样全部核心启动(一般来说)。

    微表面模型 – Microfacet Models

    • 微表面理论的提出

    早在1967年物理学家Torrance和Sparrow就已经在论文《Theory for Off-Specular Reflection From Roughened Surfaces》中提出了微表面模型(Microfacet Models)的理论。

    在计算机图形学中的应用则要归功于Robert L. Cook和Kenneth E. Torrance的工作。他们在1982年的论文《A Reflectance Model for Computer Graphics》中将微表面模型引入到了计算机图形学领域,为模拟现实世界中的各种材质提供了一种物理基础的方法。

    • 微表面模型是什么

    微表面模型是一种在计算机图形学中用于模拟光线从粗糙表面反射和折射的理论模型。这种模型的基本假设是,一个复杂的微观表面可以被一个简化的宏观表面替代,而宏观表面的散射函数(BSDF)则能匹配微观表面的总体方向散射行为。换句话说,微表面模型试图通过统计方法来模拟光线如何在粗糙表面上散射,而不是试图精确模拟每一个微观表面的细节。

    微表面模型为BRDF提供了一个具体的、基于物理的形式,而BRDF是光照模型的核心组件。

    • 为什么提出微表面模型

    用论文《A Reflectance Model for Computer Graphics》的原话来说就是:

    why images rendered with previous models often look plastic 为什么以前的渲染模型看起来有“塑料感”

    也就是说,模型的提出的其中一个目的是为了解决“塑料感”。

    定义

    在以前的着色模型中,我们有总反射率r的公式: r=ka+∑i=0nlc⋅(rd+rs)With,rd=kd⋅(n→⋅l→)rs=ks⋅(h→⋅n→)p $r_d, r_s$分别表示漫反射率和镜面反射率。

    Cook-Torrance提供了一个该方程的变体: r=ka+∑i=0nlc⋅(n→⋅l→)⋅(d⋅rd+s⋅rs) 这个方程引入了两个新的概念:

    1. 漫反射项$r_d$和镜面反射项$r_s$分别通过变量d和s控制
    2. 法向量和光源向量的点积从漫反射率中分离出来,使它成为求和的一部分。这样可以使漫反射率成为一个常量,后续如果需要也可以修改这个定义。

    另外,镜面反射率$r_s$中包含一个$(n⋅l)$的除数,与求和中的$(n⋅l)$相消,这会让读者十分困惑。接下来我解释一下原因。

    • 关于$s$和$d$

    由于$s$和$d$是用于控制漫反射项与高光项的平衡的,所以两者之间存在以下关系: s+d=1 因此我们一般会忽略$d$,化简Cook-Torrance公式: r=ka+∑i=0nlc⋅(n→⋅l→)⋅((1−s)⋅rd+s⋅rs) 不难理解$s$与$d$之间的关系:根据能量守恒,一种材质反射的总光能(包括漫反射和镜面反射部分)不能超过它吸收的光能。因此,如果一个材质镜面反射的光能很多,那么它漫反射的光能必须较少,反之亦然。例如,一个非常光滑的表面(如镜子或金属)会有很高的镜面反射,但几乎没有漫反射。而一个非常粗糙的表面(如砖墙或布料)则会有很高的漫反射,但几乎没有镜面反射。

    • 关于$r_s$

    rs=D∗G∗F4∗(n→⋅l→)∗(n→⋅v→)

    需要注意的是,文献和其他地方有时会在公式的分母中使用$π$代替$4$。这是在 Cook-Torrance 的论文中出现的错误,因此在许多地方都被重复引用了。这个公式正确的推导应该是使用$4$。然而,虽然这是一个常数因子的小差别,所以如果没有完全准确地做到也并不是特别重要。

    在 Cook-Torrance 的镜面反射率(r_s)公式中,D、G、F 是三个可以选择不同形式的函数,它们分别代表着分布函数(Distribution)、几何衰减函数(Geometry)和菲涅耳反射函数(Fresnel)。

    微表面

    微表面模型(Microfacet Model)将物体表面视为由无数微小面元组成的,每个面元都可以有自己的法线。这样的模型能够更好地模拟物体表面的细节,包括粗糙和光滑等特性。

    img

    其中,表面的法线和每个微面元的法线可能并不相同。对于非常光滑的表面,比如完美的镜子,所有的微面元都面向相同的方向,也就是表面的法线 $n$。然而,对于哑光或粗糙的表面,如哑光漆或石头,微面元面向的方向是随机的。又或者,在粗糙的表面上,这些微面元可能会遮挡其他的面元,给面元投下阴影。

    Cook-Torrance 模型

    Cook-Torrance 模型试图解释上面这三种现象:

    1. 微面元的分布(通过分布函数 D,Normal Distribution Function):不同的微面元会朝向不同的方向,这取决于表面的粗糙度。这个分布描述了对于观察者而言,反射光线的微面元的比例。
    2. 微面元之间的相互遮挡和阴影效应(通过几何衰减函数 G,Geometric Attenuation Function):这个函数描述了微面元之间的相互遮挡和阴影效应。在粗糙的表面上,一些微面元可能会被其他面元遮挡,或者给其他面元投下阴影。
    3. 光线和微面元的交互(通过菲涅耳反射函数 F,Fresnel Function):这决定了光线在接触到微面元后,有多少光线会被反射,有多少会被吸收或穿透。

    虽然 G 和 F 函数对于最终的渲染结果的贡献可能较小,但它们仍然是模型中重要的组成部分。在物理基础的 BRDF 模型中,分布函数 D 是一个非常关键的部分,因为它决定了光线如何从物体表面的各个部分反射回来,从而影响了物体的视觉效果。常用的分布函数包括贝克曼分布、GGX分布等,选择不同的分布函数可以模拟出不同类型材质的视觉效果。

    Fresnel项

    这一部分是Cook-Torrance模型用于解释菲涅耳效应的。

    在图形学中,一般使用Schlick近似: F=F0+(1−F0)∗(1−(v→⋅h→))5 其中,

    • $F_0$是光线垂直入射时的反射率,通过物体的折射率 n 计算出来
    • v 和 h 分别是视线方向和半程向量(入射光线和反射光线的平均方向)

    这个公式假设反射率随着入射角的变化而线性变化,但是在边缘区域(入射角接近90度)的反射率增加的更快,因此加入了一个5次方的项,这样就可以更好地模拟这个现象。

    最后,这个公式可以用 C++ 实现如下:

    double Fresnel_Schlick(double n, const Vector3d &v, const Vector3d &h) {
        double F0 = ((n - 1) * (n - 1)) / ((n + 1) * (n + 1));
        double cosTheta = std::max(dot(v, h), 0.0);
        return F0 + (1.0 - F0) * std::pow(1.0 - cosTheta, 5);
    }

    几何衰减项

    在 Cook-Torrance 模型中,几何衰减通常被定义为两个衰减因子的最小值,分别对应于这两种效应。这两个衰减因子可以通过一些简单的公式计算出来,称为Torrance-Sparrow 几何遮蔽模型。 G=min(1,2(h→⋅n→)(n→⋅v→)(v→⋅h→),2(h→⋅n→)(n→⋅l→)(v→⋅h→)) 其中,

    • V 是视线方向
    • H 是半向量(光线方向和视线方向的平均)
    • N 是表面法线
    • n_dot_v 和 n_dot_l 分别是法线和视线方向、光线方向的点积。

    这种模型比较适合当表面粗糙度较低(也就是表面更接近完全镜面反射)时,可以用c++如下表示:

    double v_dot_h = dotProduct(V, H);
    double n_dot_h = dotProduct(N, H);
    double G1 = 2 * n_dot_h * n_dot_v / v_dot_h;
    double G2 = 2 * n_dot_h * n_dot_l / v_dot_h;
    double G = clamp(0, 1, std::min(G1, G2));// 注意,框架中重写了clamp方法,此处是没有错误的~

    D函数

    最后,我们来说最关键、贡献最大的一项,高光反射的微观表面项。

    Blinn-Phong模型的高光反射部分就是一个可能的D函数选择,它会使得高光部分具有类似Blinn-Phong模型的特性,但这并不是唯一的选择。实际上,有很多可能的NDF,每个都有不同的表面粗糙度和反射特性。

    最常用的NDF可能是GGX(Generalized Trowbridge-Reitz)模型,它能很好地处理各种从光滑到粗糙的表面。另一个常用的选择是Beckmann模型,它通常对中等到高粗糙度的表面表现得更好。

    这里我们使用各向同性的Beckmann模型: D=e−tan2⁡αm2πm2cos4⁡α 其中,

    • $m$ 代表表面粗糙度的参数
    • $\alpha$ 是半向量$H$和表面法线$N$之间的角度,用$acos(\text{n_dot_h})$来计算。
    double m = (type == MaterialType::MICROFACET_DIFFUSE) ? 0.6 : 0.2;
    double alpha = acos(n_dot_h);
    double D = exp(-pow(tan(alpha)/m, 2)) / (M_PI*m*m*pow(cos(alpha), 4));

    当表面变得非常粗糙时,这些微平面的朝向会变得越来越随机,导致反射光也变得更加分散。

    Cook and Torrance推荐使用的就是Beckmann分布。但是现在更多的使用GGX distribution,一会深入学习再说了。

    代码具体实现

    我们需要实现的Cook-Torrance公式: r=ka+∑i=0nlc⋅(n→⋅l→)⋅((1−s)⋅rd+s⋅rs) 方才我们已经给出了DGF三项的具体代码,我们可以直接写出Cook-Torrance公式中的$r_s$项:

    Vector3f Material::cookTorrance(const Vector3f &wi, const Vector3f &wo, const Vector3f &N) {
        auto V = wo;
        auto L = wi;
        auto H = normalize(V + L);
        auto type = m_type;
        double n_dot_v = dotProduct(N, V);
        double n_dot_l = dotProduct(N, L);
        if(!(n_dot_v > 0 && n_dot_l > 0)) return 0;
        double n_air = 1, n_diff = 1.2, n_glos = 1.2;
        double n2 = (type == MaterialType::MICROFACET_DIFFUSE) ? n_diff : n_glos;
        double r0 = (n_air-n2)/(n_air+n2); r0*=r0;
        double F = r0+(1-r0)*pow(1 - n_dot_v, 5);
        double v_dot_h = dotProduct(V, H);
        double n_dot_h = dotProduct(N, H);
        double G1 = 2 * n_dot_h * n_dot_v / v_dot_h;
        double G2 = 2 * n_dot_h * n_dot_l / v_dot_h;
        double G = clamp(0, 1, std::min(G1, G2));
        double m = (type == MaterialType::MICROFACET_DIFFUSE) ? 0.6 : 0.2;
        double alpha = acos(n_dot_h);
        double D = exp(-pow(tan(alpha)/m, 2)) / (M_PI*m*m*pow(cos(alpha), 4));
        auto ans = F * G * D / (n_dot_l * n_dot_v * 4);
        return ans;
    }

    $r_d $直接用$\frac{1}{\pi}$,这个假设基于Lambertian反射的性质,Lambertian反射是一种理想的、完全漫反射的表面。

    然后$k_a$表示环境光(Ambient)的反射系数。环境光被用来模拟场景中的全局照明。这里直接忽略了环境光,即令$k_a=0$。

    case MICROFACET_DIFFUSE:
    {
        float cosalpha = dotProduct(N, wo);
        if (cosalpha > 0.0f) {
            auto ans = Ks * cookTorrance(wi, wo, N) + Kd * eval_diffuse(wi, wo, N);
            return ans;
        }
        else
            return Vector3f(0.0f);
        break;
    }

    另外,材质的两个系数设置如下:

    Material *white_m = new Material(MICROFACET_DIFFUSE, Vector3f(0.0f));
    white_m->Kd = Vector3f(0.725f, 0.71f, 0.68f);
    white_m->Ks = Vector3f(1,1,1) - white_m->Kd;

    以下就是最终渲染的结果,球特别使用了微表面模型MICROFACET_DIFFUSE,SSP=64,分辨率784, 784:

    Reference

    1. Fundamentals of Computer Graphics 4th
    2. GAMES101 Lingqi Yan
    3. 《A Reflectance Model for Computer Graphics》- 357290.357293
    4. https://zhuanlan.zhihu.com/p/152226698
    5. https://graphicscompendium.com/gamedev/15-pbr
    6. 《Theory for Off-Specualr Reflection From Roughened Surfaces》
    7. https://hanecci.hatenadiary.org/entry/20130511/p
  • 音乐与Python

    音乐与Python

    写于2020年9月。写着玩的,代码下一篇再优化了,很不专业QAQ。

    本篇文章从律学开始,从十二平均律出发,介绍一些基础必要的乐理知识,然后编写python文件,输出和弦音频文件。

    乐理知识部分:

    一、律学简述(temperament)

    1、概论

    律学,又称“音律学”,是研究律制构成与应用的科学。律学须对音乐所用的音律进行研究。音乐所用的音绝大多数是确定的,律制则是以某特定音程为基础,用数学方法规定的一系列乐音的体系。体系中的每个单位称为“律”;音阶是按照音程关系的一定规格从律制中选择若干律而构成的音列,其中的每个单位称为“音”。“音”与“律”合称“音律”时,除指律制外,兼指作精确规定的所有乐音。

    简而言之,律就是用数学的方法规定各个音高(不止)的振动频率。“律”是构成律制的基本单位。“律”和“音”的概念相近但略有不同,律制中每个单位称为“律”,而音阶中每个单位称为“音”,律制与音阶的关系十分密切。

    2、律的计算

    音律计算法即音程的计算法,使用频率比或音程值(interval value)来表示和计算音程的大小。

    从古至今,律法不断更替。不同的律制由不同的生律法决定。

    音程值(interval value)有四种,分别为对数值、八度值、音分值和平均音程值。

    3、五度相生律(circle-of-fifths system)

    五度相生律,规定构成纯五度音程的两个音的频率规定为2:3。这种每隔五度产生一律,继续相生而得各律的做法,称为“五度相生法”。

    其中,由于最大音差的存在,使五度相生律无法在十二律上循环构成各调音阶,即从主音出发,生律十二次(或更多次)并纳入同一八度后,无法回到主音,这对五度相生律的使用造成了一定障碍。

    4、纯律(just intonation)

    其中音阶中音符的频率是由小整数的比率得出的。在这个系统中,音符之间的音程是基于简单的数字比例,例如八度音程为2:1,完美五度音程为3:2,完美四度音程为4:3。这些比例创造出和谐纯净的音程,据说比现代西方音乐中使用的等温调音系统产生的音色更自然、更悦耳。

    5、十二平均律(twelve-tone equal temperament)

    十二平均律是把一个八度均分为频率比相等的十二个半音的律制,又称为“十二等比律”。

    接下来我们所有代码都是采用十二平均律的。原因是,钢琴就是基于十二平均律设计的。

    百度百科写道:十二平均律最早是由我国明朝科学家朱载堉发现(1584年)。后通过丝绸之路传至西方。

    1605年荷兰数学家西蒙·斯特芬在一篇未完成的手稿“Van de Spiegheling der singconst”提出用

    计算十二平均律,但因计算精度不够,他算出的弦长数字,有些偏离正确数字一至二单位之多。

    一个八度的频率比为2:1,则十二平均律各律之间的频率比应为:

    在音乐实践中,当时的音乐家已深知十二平均律的便利之处,各国的作曲家、演奏家都开始使用十二平均律,同时也致力于十二平均律的开发。例如德国的巴赫(J.S.Bach),作有《十二平均律钢琴曲集》二卷,此二卷虽并非只使用了十二平均律(还使用了一些不规则律),但被认为是充分发挥十二平均律的效能,可以自由转调的典范作品。

    6、三种律制的比较

    三种律制各有其优缺点。十二平均律解决了五度相生律和纯律中存在的一些矛盾,例如不断增加律数仍无法回到出发律的矛盾,但十二平均律又会影响音程的和谐性。总体而言,十二平均律将五度相生律和纯律加以调和与折衷,介于两者之间而又更接近五度相生律。十二平均律是目前使用最为广泛的一种律制。

    二、基础乐理—-p1—-乐音体系及分组

    1、音乐体系、音列

    钢琴有88个键。这些乐音加起来的总和叫做–音乐体系

    从低到高排起来叫做–音列

    2、音名

    在乐音体系中,每个乐音都有其固定名称,即:音名 。通常用字母C、D、E、F、G、A、B来表示。

    这七个音级也称为基本音级,在钢琴键盘上的位置是固定不变的。

    3、音的分组

    钢琴划分不同的区域。

    钢琴键盘上中央C开始的这一音组称为小字一组,也是“轴心组”。小字一组往右方依次称为小字二组、小字三组、小字四组、小字五组;小字一组往左方依次称为小字组、大字组、大字一组、大字二组

    三、基础乐理—-p2—-五线谱&音符

    1、谱表

    谱表由五线四间构成,用于记录音符高低。由于谱表有五条等距离平行横线,因此称为五线谱。

    image

    2、谱号

    这里简单认识一下高音谱号就好了。除此,还有低音谱号、中音谱号。

    image

    3、音符

    音符由三部分组成:符头(空心或实心的椭圆形符号)、符干(短竖线)和符尾(符干右侧的小弧线)。

    4、休止符

    休止符是用来表示音乐休止、间断的符号。

    在音乐进行中,休止符虽然表示短暂的无声,但此时有着特殊的意义,而且音乐并没有中断,因此休止符是音乐作品中的重要组成部分之一。

    image

    5、拍号

    分子的数字代码一个小节有多少拍,分母的数字代表一几分音符为一拍。读法注意不能读作几分之几拍!

    此外,拍号还明确了旋律的强弱变化规律。

    image

    四、基础乐理—-p3—-音程

    1、音程

    两个音之间音高距离就叫做音程。

    音程分为两种:旋律音程、和声音程。

    简而言之,旋律音程是两个音先后发出声响。而和声音程指的是同时发出。

    2、旋律音程

    按照发声先后读。

    3、和声音程

    下方的音称为根音,上方的音称为冠音。

    4、协和音程与不协和和音程

    协和音程的音响效果听起来悦耳动听、声音融合,可分为完全协和音程和不完全协和音程。

    完全协和音程包括纯一度、纯四度、纯五度、纯八度;不完全协和音程包括小三度、大三度、小六度、大六度。

    纯八度的音响听起来非常融合,像一个音的声音,也因为过于融合声音听起来会比较空洞。

    纯一、纯四度、纯五度音程比起纯八度的音响效果略显饱满一些,但也显空洞。

    因此,所有纯音程(纯一度、纯四度、纯五度、纯八度)都是完全协和音程。

    不协和音程的音响比较刺耳,听起来紧张、不稳定,如大小二度、大小七度、增四度、减五度等音程。

    不协和音程虽然音响效果尖锐,但是它在音乐作品中也是构成乐曲的重要元素。

    五、基础乐理—-p4—-调式

    1、调式

    若干高低不同的乐音,围绕某一具有稳定感的中心音(主音),按照一定关系组织起来所构成的体系,称为调式。现在世界上应用最广泛的调式是大小调式。

    其中,巴赫的《十二平均律》更是大小调式的经典中的经典。

    2、主音

    在调式中,主音是处于核心地位的中心音,其稳定感最强,其他的音都倾向于它。在歌(乐)曲中,主音常出现在强拍、音较长或终止处。

    3、大调式

    大调式简称为“大调”,由七个音级构成。它的主音与Ⅲ级音之间为大三度音程关系,这个大三度也是大调式的特征所在。其中Ⅰ、Ⅲ、Ⅴ级音构成大三和弦,因此大调式的色彩是明亮、辉煌的。

    大调式共有三种类型:自然大调、和声大调和旋律大调。

    1、自然大调

    自然大调是大调中用得最多的一种,全部由自然音级构成。

    自然大调音阶由五个全音和两个半音组成,

    其音阶结构是:全音-全音-半音-全音-全音-全音-半音,即由大二度、大二度、小二度、大二度、大二度、大二度、小二度音程组成。

    C和D之间隔了一个音(黑键),所以CD叫全音。

    E和F之间没有间隔,所以叫半音。

    为了便于熟记,自然大调编成口诀为:全、全、半、全、全、全、半。

    即我们平时说的:de re mi fa sol la si

    C自然大调如下图所示:

    image

    2、和声大调

    在自然大调音阶的基础上,将第Ⅵ级音降低半音,即为和声大调。

    其特征是降Ⅵ级音与Ⅶ级音之间所形成的增二度音程。

    这个增二度即为判断和声大调的标志,也是和声大调的特征所在。

    C和声大调音阶如下图所示:

    image

    3、旋律大调

    在自然大调音阶的基础上,将第Ⅵ级音降低半音,即为旋律大调。

    其特征是降Ⅵ级音与Ⅶ级音之间所形成的增二度音程。

    这个增二度即为判断和声大调的标志,也是和声大调的特征所在。

    C旋律大调音阶如下图所示:

    image

    4、小调式

    小调式简称为“小调”,也是由七个音级构成。

    它的主音与Ⅲ级音之间为小三度音程关系,这个小三度也是小调式的特征所在。其中Ⅰ、Ⅲ、Ⅴ级音构成小三和弦,因此小调式的色彩是柔和、暗淡的。

    小调也有三种类型:自然小调、和声小调和旋律小调。

    1、自然小调

    自然小调音阶也由五个全音和两个半音组成,其音阶结构是全音-半音-全音-全音-半音-全音-全音,即由大二度、小二度、大二度、大二度、小二度、大二度、大二度音程组成。

    a自然小调如下图所示:

    为了便于熟记,编成口诀为:全、半、全、全、半、全、全。

    2、和声小调

    在自然小调音阶的基础上,将第Ⅶ级音升高半音,即为和声小调。

    其特征是Ⅵ级音与升Ⅶ级音之间所形成的增二度音程,并且Ⅶ级音在升高半音之后具有了导音倾向于主音的功能,和自然小调相比紧张度更大。

    a和声小调如下图所示:

    3、旋律小调

    在自然小调音阶的基础上,将自然小调上行音阶中的第Ⅵ级和第Ⅶ级升高半音,下行音阶中再将这两个音还原,即为旋律小调。

    a旋律小调如下图所示:

    六、基础乐理—-p5—-调号

    1、调号

    调号作为表示一首歌(乐)曲的调高(即主音高度)的符号,位于每行谱表起首处(谱号之后)或乐曲进行中出现新调的地方。

    调号是用升、降记号来记写的。

    python编曲部分:

    一、认识MIDO库

    1、导入mido

    from mido import Message,MidiFile,MidiTrack

    2、mido基本框架

    创建两个mido库的对象:MidiFile() , MidiTrack()

    前者用于编辑、生成、输出Midi文件,后者用于midi文件轨道编辑。

    mid = MidiFile()
    track = MidiTrack()
    mid.tracks.append(track)

    在轨道对象中添加信息,’program_change’意为切换轨道。

    直观理解就是:编辑某条轨道前需要先把轨道选好。(此为官方固定套路,看不懂也无所谓,照着写即可)

    track.append(Message('program_change', program=0, time=0))

    3、编写音符

    选好轨道之后,直接在轨道上添加音符即可。注意,此处的时间单位是毫秒。

    track.append(Message('note_on', note=60, velocity=64, time=0))

    添加完后再添加一条结束标记,此处才算真正完成一个音符的书写。

    track.append(Message('note_off', note=60, velocity=64, time=2000))

    添加完以后,就可以直接生成一个midi文件了

    mid.save('MyFirstDamnSong.mid')

    4、代码

    代码如下:

    二、结合乐理

    1、midi文件频率编号表格:

    由上表可知,中央C的midi编号是60 。

    2、编写工具库

    1、任务分析

    我们首先编写一个工具库Notes_Toolbox.py,定义一些常用的,根基的东西。

    定义好基础的东西:音程、音名、调式、和弦、节拍、基础音等等。

    然后编写一些简单的方法供调用,比如返回一组和弦、自动转调、通过五线谱得到MIDI频率编号等方法。

    2、定义变量

    编号变量名Is static?Default value作用
    1bpmNo125节拍
    2timePerBeatNo60 / bpm * 100每拍持续时间(毫秒)
    3base_notestatic60中央C对应MIDI编号
    4note_name[]static[ ‘C’,’D’,’E’,’F’,’G’,’A’,’B’]音名
    5major_notes[]static[0, 2, 2, 1, 2, 2, 2, 1]自然音阶
    6Cmajor_notes[]static
    7Eflatmajor_notes[]static
    8Cmajor{}static{‘C’: 60, ‘D’: 62, ‘E’: 64, ‘F’: 65, ‘G’: 67, ‘A’: 69, ‘B’: 71}C大调字典
    9Eflatmajor{}static{‘C’: 63, ‘D’: 65, ‘E’: 67, ‘F’: 68, ‘G’: 70, ‘A’: 72, ‘B’: 74}E小调字典

    3、定义函数

    编号方法名返回传入参数作用
    1get_noteMIDI编号note,group=0,**kw输入音名与音符区域,返回对应的MIDI编号。(需要改进,没有黑键)
    2get_chord和弦数组name,**kw输入和弦名称,返回和弦数组
    3originToEflatMajor新E小调MIDI编号数组list,**kw输入C大调,返回E小调。(需要改进,支持指定什么调到什么调)
    bpm = 125 #why 125:
        #bpm = 1 * 1000 / 8 
    timePerBeat = 60 / bpm * 1000
    base_note = 60 # C4
    note_name =[
        'C','D','E','F','G','A','B'
    ]
    major_notes = [0, 2, 2, 1, 2, 2, 2, 1]
    Cmajor_notes = []
    Eflatmajor_notes = []
    for num in range(12):
        Cmajor_notes.append(base_note+sum(major_notes[0:num+1]))
        Eflatmajor_notes.append(base_note+3+sum(major_notes[0:num+1]))
    #这里只有一个区
    Cmajor = dict(zip(note_name,Cmajor_notes))
    # Cmajor = {'C':60,'D':62,'E':64,'F':65,'G':67,'A':69,'B':71}
    Eflatmajor = dict(zip(note_name,Eflatmajor_notes))
    # Eflatmajor = {'C': 63, 'D': 65, 'E': 67, 'F': 68, 'G': 70, 'A': 72, 'B': 74}
    def get_note(note,group=0,**kw):#Group = 0 means 4Group
        global base_note,major_notes
        return base_note + group*12 + sum(major_notes[0,note])
    def originToEflatMajor(list,**kw):
        Ef=[]
        for x in list:
            Ef.append(x+3)
        return Ef
    #get_note(1,group=0) return 60
    #get_note(2,group=0) return 62
    def get_chord(name):
        chord = {
            "Major3":[0,4,7,12],#大三和弦
            "Minor3":[0,3,7,12],#小三和弦
            "Augmented3":[0,4,8,12],#增三和弦
            "Diminished3":[0,3,6,12],#减三和弦
            "M7":[0,4,7,11],#大七和弦
            "Mm7":[0,4,7,10],#属七和弦
            "m7":[0,3,7,10],#小七和弦
            "mM7":[],
            #...
        }
        return chord[name]
    #get_chord(“Major”) return [0,4,7,12]

    3、写一段分解和弦

    上面已经写完了工具类模块,接下来就可以专注于和弦的部分了。

    1、编写输出和弦函数

    输出分解和弦到midi文件

    使用此方法时,需要传入:

    编号参数名:传入:示例:
    1trackmido库的输出音频接口MidiTrack()
    2root音符的名称‘C’,’D’,’E’,’F’,’G’,’A’,’B’
    3name和弦的名称,在Notes_Toolbox中定义‘Major3’…
    4format输出分解和弦的方式[0,1,2] [1,3,2,3]…
    5length音符持续的时长4

    直接放上源代码:

    def add_broken_chord(root, name, format, length, track, tone_name='Cmajor', root_base=0, channel=0):
        #默认是c大调
        root_num = Notes_Toolbox.Cmajor
        if tone_name == 'Eflat':
            root_num = {'C': 63, 'D': 65, 'E': 67, 'F': 68, 'G': 70, 'A': 72, 'B': 74}
        root_note = root_num[root] + root_base*12  # 分解和弦的根音
        time = (length * 480) / len(format)  # 此处为官方文档写法,我也不懂,time指的是音符持续时长
        for broken_chord in format:  # 通过for循环,逐个输出和弦的音符
            note = root_note + Notes_Toolbox.get_chord(name)[broken_chord]
            track.append(Message('note_on', note=note,
                         velocity=60, time=0, channel=channel))
            track.append(Message('note_on', note=note, velocity=60,
                         time=round(time), channel=channel))

    2、调用参考:

    format = [0, 1, 2, 3]
    add_broken_chord('C', 'Major3', format, 4, track)
    add_broken_chord('C', 'Minor3', format, 4, track)
    add_broken_chord('C', 'Augmented3', format, 4, track)
    add_broken_chord('C', 'Diminished3', format, 4, track)
    add_broken_chord('C', 'Diminished3', format, 4, track)

    最后调用保存midi文件即可。



    三、规范化代码

    为了方便调用,我们对函数的参数顺序做出调整。

    另外,重载play_note方法,使其可以接收int类型的note(也就是直接输入MIDI编号)。

    同理,所有的有关note的输入都可以进行重载。

    def play_note(note,  track, length=1, tone_name='Cmajor', root_base=0, delay=0, velocity=1.0, channel=0):
        ...
    def play_note(note:int,  track, length=1, tone_name='Cmajor', root_base=0, delay=0, velocity=1.0, channel=0):
        ...
    def play_broken_chord(root, name, format, track,length=1, tone_name='Cmajor', delay=0, velocity=1.0,root_base=0, channel=0):
        ...

    四、总结、mido之旅未完待续…

    经过上述学习与实践,浅显地了解了音乐与数学的联系,与一些基础乐理知识。通过代码,实现了各种和弦的输出。

    结合乐理知识,接下来,将进行圈式和弦的书写。


    参考文献

    . 《音乐理论基础》,李重光编著,人民音乐出版社;

zh_CNCN