分类: 技術博客

  • Compute Shader学习笔记(三)之 粒子效果与群集行为模拟

    Compute Shader学习笔记(三)之 粒子效果与群集行为模拟

    img

    紧接着上一篇文章

    remoooo:Compute Shader学习笔记(二)之 后处理效果

    L4 粒子效果与群集行为模拟

    本章节使用Compute Shader生成粒子。学习如何使用DrawProcedural和DrawMeshInstancedIndirect,也就是GPU Instancing。

    知识点总结:

    • Compute Shader、Material、C#脚本和Shader共同协作
    • Graphics.DrawProcedural
    • material.SetBuffer()
    • xorshift 随机算法
    • 集群行为模拟
    • Graphics.DrawMeshInstancedIndirect
    • 旋转平移缩放矩阵,齐次坐标
    • Surface Shader
    • ComputeBufferType.Default
    • #pragma instancing_options procedural:setup
    • unity_InstanceID
    • Skinned Mesh Renderer
    • 数据对齐

    1. 介绍与准备工作

    Compute Shader除了可以同时处理大量的数据,还有一个关键的优势,就是Buffer存储在GPU中。因此可以将Compute Shader处理好的数据直接传递给与Material关联的Shader中,即Vertex/Fragment Shader。这里的关键就是,material也可以像Compute Shader一样SetBuffer(),直接从GPU的Buffer中访问数据!

    img

    使用Compute Shader来制作粒子系统可以充分体现Compute Shader的强大并行能力。

    在渲染过程中,Vertex Shader会从Compute Buffer中读取每个粒子的位置和其他属性,并将它们转换为屏幕上的顶点。Fragment Shader则负责根据这些顶点的信息(如位置和颜色)来生成像素。通过Graphics.DrawProcedural方法,Unity可以直接渲染这些由Shader处理的顶点,无需预先定义的网格结构,也不依赖Mesh Renderer,这对于渲染大量粒子特别有效。

    2. 粒子你好

    步骤也是非常简单,在 C# 中定义好粒子的信息(位置、速度与生命周期),初始化将数据传给Buffer,绑定Buffer到Compute Shader和Material。渲染阶段在OnRenderObject()里调用Graphics.DrawProceduralNow实现高效地渲染粒子。

    img

    新建一个场景,制作一个效果:百万粒子跟随鼠标绽放生命的粒子,如下:

    img

    写到这里,不禁让我思绪万千。粒子的生命周期很短暂,如同星火一般瞬间点燃,又如同流星一闪即逝。纵有千百磨难,我亦不过是亿万尘埃中的一粒,平凡且渺小。这些粒子,虽或许会在空间中随机漂浮(使用”Xorshift”算法计算粒子生成的位置),或许会拥有独一无二的色彩,但它们终究逃不出被程式预设的命运。这难道不正是我的人生写照吗?按部就班地上演着自己的角色,无法逃脱那无形的束缚。

    “上帝已死!而我们这些杀死他的人,又怎能不感到最大的痛苦呢?” – 弗里德里希·尼采

    尼采不仅宣告了宗教信仰的消逝,更指出了现代人面临的虚无感,即没有了传统的道德和宗教支柱,人们感到了前所未有的孤独和方向感的缺失。粒子在C#脚本中被定义、创造,按照特定规则运动和消亡,这与尼采所描述的现代人在宇宙中的状态颇有相似之处。虽然每个人都试图寻找自己的意义,但最终仍受限于更广泛的社会和宇宙规则。

    生活中充满了各种不可避免的痛苦,反映了人类存在的固有虚无和孤独感。失恋、生离死别、工作失意以及即将编写的粒子死亡逻辑等等,都印证了尼采所表达的,生活中没有什么是永恒不变的。同一个Buffer中的粒子必然在未来某个时刻消失,这体现了尼采所描述的现代人的孤独感,个体可能会感受到前所未有的孤立无援,因此每个人都是孤独的战士,必须学会独自面对内心的龙卷风和外部世界的冷漠。

    但是没关系,「夏天会周而复始,该相逢的人会再次相逢」。本文的粒子也会在结束后再次生成,以最好的状态拥抱属于它的Buffer。

    Summer will come around again. People who meet will meet again.

    img

    当前版本代码,可以自己拷下来跑跑(都有注释):

    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L4_First_Particle/Assets/Shaders/ParticleFun.compute
    • CPU:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L4_First_Particle/Assets/Scripts/ParticleFun.cs
    • Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L4_First_Particle/Assets/Shaders/Particle.shader

    废话就说到这,先看看 C# 脚本是咋写的。

    img

    老样子,先定义粒子的Buffer(结构体),并且初始化一下子,然后传给GPU,关键在于最后三行将Buffer绑定给shader的操作。下面省略号的代码没什么好讲的,都是常规操作,用注释一笔带过了。

    struct Particle{
        public Vector3 position; // 粒子位置
        public Vector3 velocity; // 粒子速度
        public float life;       // 粒子生命周期
    }
    ComputeBuffer particleBuffer; // GPU 的 Buffer
    ...
    // Init() 中
        // 初始化粒子数组
        Particle[] particleArray = new Particle[particleCount];
        for (int i = 0; i < particleCount; i++){
            // 生成随机位置和归一化
            ...
            // 设置粒子的初始位置和速度
            ... 
            // 设置粒子的生命周期
            particleArray[i].life = Random.value * 5.0f + 1.0f;
        }
        // 创建并设置Compute Buffer
        ...
        // 查找Compute Shader中的kernel ID
        ...
        // 绑定Compute Buffer到shader
        shader.SetBuffer(kernelID, "particleBuffer", particleBuffer);
        material.SetBuffer("particleBuffer", particleBuffer);
        material.SetInt("_PointSize", pointSize);

    关键的渲染阶段来了 OnRenderObject() 。material.SetPass 用于设置渲染材质通道。DrawProceduralNow 方法在不使用传统网格的情况下绘制几何体。MeshTopology.Points 指定了渲染的拓扑类型为点,GPU会把每个顶点作为一个点来处理,不会进行顶点之间的连线或面的形成。第二个参数 1 表示从第一个顶点开始绘制。particleCount 指定了要渲染的顶点数,这里是粒子的数量,即告诉GPU总共需要渲染多少个点。

    void OnRenderObject()
    {
        material.SetPass(0);
        Graphics.DrawProceduralNow(MeshTopology.Points, 1, particleCount);
    }

    获取当前鼠标位置方法。OnGUI()这个方法每一帧可能调用多次。z值设为摄像机的近裁剪面加上一个偏移量,这里加14是为了得到一个更合适视觉深度的世界坐标(也可以自行调整)。

    void OnGUI()
    {
        Vector3 p = new Vector3();
        Camera c = Camera.main;
        Event e = Event.current;
        Vector2 mousePos = new Vector2();
        // Get the mouse position from Event.
        // Note that the y position from Event is inverted.
        mousePos.x = e.mousePosition.x;
        mousePos.y = c.pixelHeight - e.mousePosition.y;
        p = c.ScreenToWorldPoint(new Vector3(mousePos.x, mousePos.y, c.nearClipPlane + 14));
        cursorPos.x = p.x;
        cursorPos.y = p.y;
    }

    上面已经将 ComputeBuffer particleBuffer; 传到了Compute Shader和Shader中。

    先看看Compute Shader的数据结构。没什么特别的。

    // 定义粒子数据结构
    struct Particle
    {
        float3 position;  // 粒子的位置
        float3 velocity;  // 粒子的速度
        float life;       // 粒子的剩余生命时间
    };
    // 用于存储和更新粒子数据的结构化缓冲区,可从GPU读写
    RWStructuredBuffer<Particle> particleBuffer;
    // 从CPU设置的变量
    float deltaTime;       // 从上一帧到当前帧的时间差
    float2 mousePosition;  // 当前鼠标位置
    img

    这里简单讲讲一个特别好用的随机数序列生成方法 xorshift 算法。一会将用来随机粒子的运动方向如上图,粒子会随机朝着三维的方向运动。

    • 详细参考:https://en.wikipedia.org/wiki/Xorshift
    • 原论文链接:https://www.jstatsoft.org/article/view/v008i14

    这个算法03年由George Marsaglia提出,优点在于运算速度极快,并且非常节约空间。即使是最简单的Xorshift实现,其伪随机数周期也是相当长的。

    基本操作是位移(shift)和异或(xor)。算法的名字也由此而来。它的核心是维护一个非零的状态变量,通过对这个状态变量进行一系列的位移和异或操作来生成随机数。

    // 用于生成随机数的状态变量
    uint rng_state;
    uint rand_xorshift() {
        // Xorshift algorithm from George Marsaglia's paper
        rng_state ^= (rng_state << 13);  // 将状态变量左移13位,然后与原状态进行异或
        rng_state ^= (rng_state >> 17);  // 将更新后的状态变量右移17位,再次进行异或
        rng_state ^= (rng_state << 5);   // 最后,将状态变量左移5位,进行最后一次异或
        return rng_state;                // 返回更新后的状态变量作为生成的随机数
    }

    基本Xorshift 算法的核心已在前面的解释中提到,不过不同的位移组合可以创建多种变体。原论文还提到了Xorshift128变体。使用128位的状态变量,通过四次不同的位移和异或操作更新状态。代码如下:

    img
    // c language Ver
    uint32_t xorshift128(void) {
        static uint32_t x = 123456789;
        static uint32_t y = 362436069;
        static uint32_t z = 521288629;
        static uint32_t w = 88675123; 
        uint32_t t = x ^ (x << 11);
        x = y; y = z; z = w;
        w = w ^ (w >> 19) ^ (t ^ (t >> 8));
        return w;
    }

    可以产生更长的周期和更好的统计性能。这个变体的周期接近 ,非常厉害。

    总的来说,这个算法用在游戏开发完全足够了,只是不适合用在密码学等领域。

    在Compute Shader中使用这个算法时,需要注意Xorshift算法生成的随机数范围时uint32的的范围,需要再做一个映射( [0, 2^32-1] 映射到 [0, 1]):

    float tmp = (1.0 / 4294967296.0);  // 转换因子
    rand_xorshift()) * tmp

    而粒子运动方向是有符号的,因此只要在这个基础上减去0.5就好了。三个方向的随机运动:

    float f0 = float(rand_xorshift()) * tmp - 0.5;
    float f1 = float(rand_xorshift()) * tmp - 0.5;
    float f2 = float(rand_xorshift()) * tmp - 0.5;
    float3 normalF3 = normalize(float3(f0, f1, f2)) * 0.8f; // 缩放了运动方向

    每一个Kernel需要完成的内容如下:

    • 先得到Buffer中上一帧的粒子信息
    • 维护粒子Buffer(计算粒子速度,更新位置、生命值),写回Buffer
    • 若生命值小于0,重新生成一个粒子

    生成粒子,初始位置利用刚刚Xorshift得到的随机数,定义粒子的生命值,重置速度。

    // 设置粒子的新位置和生命值
    particleBuffer[id].position = float3(normalF3.x + mousePosition.x, normalF3.y + mousePosition.y, normalF3.z + 3.0);
    particleBuffer[id].life = 4;  // 重置生命值
    particleBuffer[id].velocity = float3(0,0,0);  // 重置速度

    最后是Shader的基本数据结构:

    struct Particle{
        float3 position;
        float3 velocity;
        float life;
    };
    struct v2f{
        float4 position : SV_POSITION;
        float4 color : COLOR;
        float life : LIFE;
        float size: PSIZE;
    };
    // particles' data
    StructuredBuffer<Particle> particleBuffer;

    然后在顶点着色器计算粒子的顶点色、顶点的Clip位置以及传输一个顶点大小的信息。

    v2f vert(uint vertex_id : SV_VertexID, uint instance_id : SV_InstanceID){
        v2f o = (v2f)0;
        // Color
        float life = particleBuffer[instance_id].life;
        float lerpVal = life * 0.25f;
        o.color = fixed4(1.0f - lerpVal+0.1, lerpVal+0.1, 1.0f, lerpVal);
        // Position
        o.position = UnityObjectToClipPos(float4(particleBuffer[instance_id].position, 1.0f));
        o.size = _PointSize;
        return o;
    }

    片元着色器计算插值颜色。

    float4 frag(v2f i) : COLOR{
        return i.color;
    }

    至此,就可以得到上面的效果。

    img

    3. Quad粒子

    上一节每一个粒子都只有一个点,没什么意思。现在把一个点变成一个Quad。在Unity中,没有Quad,只有两个三角形组成的假Quad。

    开干,基于上面的代码。在 C# 中定义顶点,一个Quad的尺寸。

    // struct
    struct Vertex
    {
        public Vector3 position;
        public Vector2 uv;
        public float life;
    }
    const int SIZE_VERTEX = 6 * sizeof(float);
    public float quadSize = 0.1f; // Quad的尺寸
    img

    每一个粒子的的基础上,设置六个顶点的uv坐标,给顶点着色器用。并且按照Unity规定的顺序绘制。

    index = i*6;
        //Triangle 1 - bottom-left, top-left, top-right
        vertexArray[index].uv.Set(0,0);
        vertexArray[index+1].uv.Set(0,1);
        vertexArray[index+2].uv.Set(1,1);
        //Triangle 2 - bottom-left, top-right, bottom-right
        vertexArray[index+3].uv.Set(0,0);
        vertexArray[index+4].uv.Set(1,1);
        vertexArray[index+5].uv.Set(1,0);

    最后传递给Buffer。这里的 halfSize 目的是传给Compute Shader计算Quad的各个顶点位置用的。

    vertexBuffer = new ComputeBuffer(numVertices, SIZE_VERTEX);
    vertexBuffer.SetData(vertexArray);
    shader.SetBuffer(kernelID, "vertexBuffer", vertexBuffer);
    shader.SetFloat("halfSize", quadSize*0.5f);
    material.SetBuffer("vertexBuffer", vertexBuffer);

    渲染阶段把点改为三角形,有六个点。

    void OnRenderObject()
    {
        material.SetPass(0);
        Graphics.DrawProceduralNow(MeshTopology.Triangles, 6, numParticles);
    }

    在Shader中改一下设置,接收顶点数据。并且接收一张贴图用于显示。需要做alpha剔除。

    _MainTex("Texture", 2D) = "white" {}     
    ...
    Tags{ "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" }
    LOD 200
    Blend SrcAlpha OneMinusSrcAlpha
    ZWrite Off
    ...
        struct Vertex{
            float3 position;
            float2 uv;
            float life;
        };
        StructuredBuffer<Vertex> vertexBuffer;
        sampler2D _MainTex;
        v2f vert(uint vertex_id : SV_VertexID, uint instance_id : SV_InstanceID)
        {
            v2f o = (v2f)0;
            int index = instance_id*6 + vertex_id;
            float lerpVal = vertexBuffer[index].life * 0.25f;
            o.color = fixed4(1.0f - lerpVal+0.1, lerpVal+0.1, 1.0f, lerpVal);
            o.position = UnityWorldToClipPos(float4(vertexBuffer[index].position, 1.0f));
            o.uv = vertexBuffer[index].uv;
            return o;
        }
        float4 frag(v2f i) : COLOR
        {
            fixed4 color = tex2D( _MainTex, i.uv ) * i.color;
            return color;
        }

    在Compute Shader中,增加接收顶点数据,还有halfSize。

    struct Vertex
    {
        float3 position;
        float2 uv;
        float life;
    };
    RWStructuredBuffer<Vertex> vertexBuffer;
    float halfSize;

    计算每个Quad六个顶点的位置。

    img
    //Set the vertex buffer //
        int index = id.x * 6;
        //Triangle 1 - bottom-left, top-left, top-right   
        vertexBuffer[index].position.x = p.position.x-halfSize;
        vertexBuffer[index].position.y = p.position.y-halfSize;
        vertexBuffer[index].position.z = p.position.z;
        vertexBuffer[index].life = p.life;
        vertexBuffer[index+1].position.x = p.position.x-halfSize;
        vertexBuffer[index+1].position.y = p.position.y+halfSize;
        vertexBuffer[index+1].position.z = p.position.z;
        vertexBuffer[index+1].life = p.life;
        vertexBuffer[index+2].position.x = p.position.x+halfSize;
        vertexBuffer[index+2].position.y = p.position.y+halfSize;
        vertexBuffer[index+2].position.z = p.position.z;
        vertexBuffer[index+2].life = p.life;
        //Triangle 2 - bottom-left, top-right, bottom-right  // // 
        vertexBuffer[index+3].position.x = p.position.x-halfSize;
        vertexBuffer[index+3].position.y = p.position.y-halfSize;
        vertexBuffer[index+3].position.z = p.position.z;
        vertexBuffer[index+3].life = p.life;
        vertexBuffer[index+4].position.x = p.position.x+halfSize;
        vertexBuffer[index+4].position.y = p.position.y+halfSize;
        vertexBuffer[index+4].position.z = p.position.z;
        vertexBuffer[index+4].life = p.life;
        vertexBuffer[index+5].position.x = p.position.x+halfSize;
        vertexBuffer[index+5].position.y = p.position.y-halfSize;
        vertexBuffer[index+5].position.z = p.position.z;
        vertexBuffer[index+5].life = p.life;

    大功告成。

    img

    当前版本代码:

    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L4_Quad/Assets/Shaders/QuadParticles.compute
    • CPU:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L4_Quad/Assets/Scripts/QuadParticles.cs
    • Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L4_Quad/Assets/Shaders/QuadParticle.shader

    下一节,将Mesh升级为预制体,并且尝试模拟鸟类飞行时的集群行为。

    4. Flocking(集群行为)模拟

    img

    Flocking 是一种模拟自然界中鸟群、鱼群等动物集体运动行为的算法。核心是基于三个基本的行为规则,由Craig Reynolds在Sig 87提出,通常被称为“Boids”算法:

    • 分离(Separation) 粒子与粒子之间不能太靠近,要有边界感。具体是计算周边一定半径的粒子然后计算一个避免碰撞的方向。
    • 对齐(Alignment) 个体的速度趋于群体的平均速度,要有归属感。具体是计算视觉范围内粒子的平均速度(速度大小 方向)。这个视觉范围要根据鸟类实际的生物特性决定,下一节会提及。
    • 聚合(Cohesion) 个体的位置趋于平均位置(群体的中心),要有安全感。具体是,每个粒子找出周围邻居的几何中心,计算一个移动向量(最终结果是平均位置)。
    img
    img

    思考一下,上面三个规则,哪一个最难实现?

    答:Separation。众所周知,计算物体间的碰撞是非常难以实现的。因为每个个体都需要与其他所有个体进行距离比较,这会导致算法的时间复杂度接近O(n^2),其中n是粒子的数量。例如,如果有1000个粒子,那么在每次迭代中可能需要进行将近500,000次的距离计算。在当年原论文作者在没有经过优化的原始算法(时间复杂度O(N^2))中渲染一帧(80只鸟)所需时间是95秒,渲染一个300帧的动画使用了将近9个小时。

    一般来说,使用四叉树或者是格点哈希(Spatial Hashing)等空间划分方法可以优化计算。也可以维护一个近邻列表存储每个个体周边一定距离的个体。当然了,还可以使用Compute Shader硬算。

    img

    废话不多说,开干。

    首先下载好预备的工程文件(如果没有事先准备):

    • 鸟的Prefab:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/main/Assets/Prefabs/Boid.prefab
    • 脚本:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/main/Assets/Scripts/SimpleFlocking.cs
    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/main/Assets/Shaders/SimpleFlocking.compute

    然后添加到一个空GO中。

    img

    启动项目就可以看到一堆鸟。

    img

    下面是关于群体行为模拟的一些参数。

    // 定义群体行为模拟的参数。
        public float rotationSpeed = 1f; // 旋转速度。
        public float boidSpeed = 1f; // Boid速度。
        public float neighbourDistance = 1f; // 邻近距离。
        public float boidSpeedVariation = 1f; // 速度变化。
        public GameObject boidPrefab; // Boid对象的预制体。
        public int boidsCount; // Boid的数量。
        public float spawnRadius; // Boid生成的半径。
        public Transform target; // 群体的移动目标。

    除了Boid预制体boidPrefab和生成半径spawnRadius之外,其他都需要传给GPU。

    为了方便,这一节先犯个蠢,只在GPU计算鸟的位置和方向,然后传回给CPU,做如下处理:

    ...
    boidsBuffer.GetData(boidsArray);
    // 更新每个鸟的位置与朝向
    for (int i = 0; i < boidsArray.Length; i++){
        boids[i].transform.localPosition = boidsArray[i].position;
        if (!boidsArray[i].direction.Equals(Vector3.zero)){
            boids[i].transform.rotation = Quaternion.LookRotation(boidsArray[i].direction);
        }
    }

    Quaternion.LookRotation() 方法用于创建一个旋转,使对象面向指定的方向。

    在Compute Shader中计算每个鸟的位置。

    #pragma kernel CSMain
    #define GROUP_SIZE 256    
    struct Boid{
        float3 position;
        float3 direction;
    };
    RWStructuredBuffer<Boid> boidsBuffer;
    float time;
    float deltaTime;
    float rotationSpeed;
    float boidSpeed;
    float boidSpeedVariation;
    float3 flockPosition;
    float neighbourDistance;
    int boidsCount;
    

    [numthreads(GROUP_SIZE,1,1)]

    void CSMain (uint3 id : SV_DispatchThreadID){ …// 接下文 }

    先写对齐和聚合的逻辑,最终输出实际位置、方向给Buffer。

    Boid boid = boidsBuffer[id.x];
        float3 separation = 0; // 分离
        float3 alignment = 0; // 对齐 - 方向
        float3 cohesion = flockPosition; // 聚合 - 位置
        uint nearbyCount = 1; // 自身算作周边的个体。
        for (int i=0; i<boidsCount; i++)
        {
            if(i!=(int)id.x) // 把自己排除 
            {
                Boid temp = boidsBuffer[i];
                // 计算周围范围内的个体
                if(distance(boid.position, temp.position)< neighbourDistance){
                    alignment += temp.direction;
                    cohesion += temp.position;
                    nearbyCount++;
                }
            }
        }
        float avg = 1.0 / nearbyCount;
        alignment *= avg;
        cohesion *= avg;
        cohesion = normalize(cohesion-boid.position);
        // 综合一个移动方向
        float3 direction = alignment + separation + cohesion;
        // 平滑转向和位置更新
        boid.direction = lerp(direction, normalize(boid.direction), 0.94);
        // deltaTime确保移动速度不会因帧率变化而改变。
        boid.position += boid.direction * boidSpeed * deltaTime;
        boidsBuffer[id.x] = boid;

    这就是没有边界感(分离项)的下场,所有的个体都表现出相当亲密的关系,都重叠在一起了。

    img

    添加下面的代码。

    if(distance(boid.position, temp.position)< neighbourDistance)
    {
        float3 offset = boid.position - temp.position;
        float dist = length(offset);
        if(dist < neighbourDistance)
        {
            dist = max(dist, 0.000001);
            separation += offset * (1.0/dist - 1.0/neighbourDistance);
        }
        ...

    1.0/dist 当Boid越靠近时,这个值越大,表示分离力度应当越大。1.0/neighbourDistance 是一个常数,基于定义的邻近距离。两者的差值表示实际的分离力应对距离的反应程度。如果两个Boid的距离正好是 neighbourDistance,这个值为零(没有分离力)。如果两个Boid距离小于 neighbourDistance,这个值为正,且距离越小,值越大。

    img

    当前代码:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L4_Flocking/Assets/Shaders/SimpleFlocking.compute

    下一节将采用Instanced Mesh,提高性能。

    5. GPU Instancing优化

    首先回顾一下本章节的内容。「粒子你好」与「Quad粒子」的两个例子中,我们都运用了Instanced技术(Graphics.DrawProceduralNow()),将Compute Shader的计算好的粒子位置直接传递给VertexFrag着色器。

    img

    本节使用的DrawMeshInstancedIndirect 用于绘制大量几何体实例,实例都是相似的,只是位置、旋转或其他参数略有不同。相对于每帧都重新生成几何体并渲染的 DrawProceduralNow,DrawMeshInstancedIndirect 只需要一次性设置好实例的信息,然后 GPU 就可以根据这些信息一次性渲染所有实例。渲染草地、群体动物就用这个函数。

    img

    这个函数有很多参数,只用其中的一部分。

    img
    Graphics.DrawMeshInstancedIndirect(boidMesh, 0, boidMaterial, bounds, argsBuffer);
    1. boidMesh:把鸟Mesh丢进去。
    2. subMeshIndex:绘制的子网格索引。如果网格只有一个子网格,通常为0。
    3. boidMaterial:应用到实例化对象的材质。
    4. bounds:包围盒指定了绘制的范围。实例化对象只有在这个包围盒内的区域才会被渲染。优化性能之用。
    5. argsBuffer:参数的 ComputeBuffer,参数包括每个实例的几何体的索引数量和实例化的数量。

    这个 argsBuffer 是啥?这个参数用来告诉Unity,我们现在要渲染哪个Mesh、要渲染多少个!可以用一种特殊的Buffer作为参数给进去。

    在初始化shader时候,创建一种特殊Buffer,其标注为 ComputeBufferType.IndirectArguments 。这种类型的缓冲区专门用于传递给 GPU,以便在 GPU 上执行间接绘制命令。这里的new ComputeBuffer 第一个参数是 1 ,表示一个args数组(一个数组有5个uint),不要理解错了。

    ComputeBuffer argsBuffer;
    ...
    argsBuffer = new ComputeBuffer(1, 5 * sizeof(uint), ComputeBufferType.IndirectArguments);
    if (boidMesh != null)
    {
        args[0] = (uint)boidMesh.GetIndexCount(0);
        args[1] = (uint)numOfBoids;
    }
    argsBuffer.SetData(args);
    ...
    Graphics.DrawMeshInstancedIndirect(boidMesh, 0, boidMaterial, bounds, argsBuffer);

    在上一章的基础上,个体的数据结构增加一个offset,在Compute Shader用于方向上的偏移。另外初始状态的方向用Slerp插值,70%保持原来的方向,30%随机。Slerp插值的结果是四元数,需要用四元数方法转换到欧拉角再传入构造函数。

    public float noise_offset;
    ...
    Quaternion rot = Quaternion.Slerp(transform.rotation, Random.rotation, 0.3f);
    boidsArray[i] = new Boid(pos, rot.eulerAngles, offset);

    将这个新的属性noise_offset传到Compute Shader后,计算范围是 [-1, 1] 的噪声值,应用到鸟的速度上。

    float noise = clamp(noise1(time / 100.0 + boid.noise_offset), -1, 1) * 2.0 - 1.0;
    float velocity = boidSpeed * (1.0 + noise * boidSpeedVariation);

    然后稍微优化了一下算法。Compute Shader大体是没有区别的。

    if (distance(boid_pos, boidsBuffer[i].position) < neighbourDistance)
    {
        float3 tempBoid_position = boidsBuffer[i].position;
        float3 offset = boid.position - tempBoid_position;
        float dist = length(offset);
        if (dist<neighbourDistance){
            dist = max(dist, 0.000001);//Avoid division by zero
            separation += offset * (1.0/dist - 1.0/neighbourDistance);
        }
        alignment += boidsBuffer[i].direction;
        cohesion += tempBoid_position;
        nearbyCount += 1;
    }

    最大的不同在于Shader上。本节使用Surface Shader取代Frag。这个东西其实就是一个包装好的vertex and fragment shader。Unity已经完成了光照、阴影等一系列繁琐的工作。你依旧可以指定一个Vert。

    写Shader制作材质的时候,需要对Instanced的物体做特别处理。因为普通的渲染对象,他们的位置、旋转和其他属性在Unity中是静态的。而对于当前要构建的实例化对象,其位置、旋转等参数时刻在变化,因此,在渲染管线中需要通过特殊的机制来动态设置每个实例化对象的位置和参数。当前的方法基于程序的实例化技术,可以一次性渲染所有的实例化对象,而不需要逐个绘制。也就是一次性批量渲染。

    着色器应用instanced技术方法。实例化阶段是在vert之前执行。这样每个实例化的对象都有单独的旋转、位移和缩放等矩阵。

    现在需要为每个实例化对象创建属于他们的旋转矩阵。从Buffer中我们拿到了Compute Shader计算后的鸟的基本信息(上一节中,该数据传回了CPU,这里直接传给Shader做实例化):

    img

    Shader里将Buffer传来的数据结构、相关操作用下面的宏包裹起来。

    // .shader
    #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
    struct Boid
    {
        float3 position;
        float3 direction;
        float noise_offset;
    };
    StructuredBuffer<Boid> boidsBuffer; 
    #endif

    由于我只在 C# 的 DrawMeshInstancedIndirect 的args[1]指定了需要实例化的数量(鸟的数量,也是Buffer的大小),因此直接使用unity_InstanceID索引访问Buffer就好了。

    #pragma instancing_options procedural:setup
    void setup()
    {
        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
            _BoidPosition = boidsBuffer[unity_InstanceID].position;
            _Matrix = create_matrix(boidsBuffer[unity_InstanceID].position, boidsBuffer[unity_InstanceID].direction, float3(0.0, 1.0, 0.0));
        #endif
    }

    这里的空间变换矩阵的计算涉及到Homogeneous Coordinates,可以去复习一下GAMES101的课程。点是(x,y,z,1),坐标是(x,y,z,0)。

    如果使用仿射变换(Affine Transformations),代码是这样的:

    void setup()
    {
        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
        _BoidPosition = boidsBuffer[unity_InstanceID].position;
        _LookAtMatrix = look_at_matrix(boidsBuffer[unity_InstanceID].direction, float3(0.0, 1.0, 0.0));
        #endif
    }
     void vert(inout appdata_full v, out Input data)
    {
        UNITY_INITIALIZE_OUTPUT(Input, data);
        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
        v.vertex = mul(_LookAtMatrix, v.vertex);
        v.vertex.xyz += _BoidPosition;
        #endif
    }

    不够优雅,我们直接使用一个齐次坐标(Homogeneous Coordinates)。一个矩阵搞掂旋转平移缩放!

    void setup()
    {
        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
        _BoidPosition = boidsBuffer[unity_InstanceID].position;
        _Matrix = create_matrix(boidsBuffer[unity_InstanceID].position, boidsBuffer[unity_InstanceID].direction, float3(0.0, 1.0, 0.0));
        #endif
    }
     void vert(inout appdata_full v, out Input data)
    {
        UNITY_INITIALIZE_OUTPUT(Input, data);
        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
        v.vertex = mul(_Matrix, v.vertex);
        #endif
    }

    至此,就大功告成了!当前的帧率比上一节提升了将近一倍。

    img
    img

    当前版本代码:

    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L4_Instanced/Assets/Shaders/InstancedFlocking.compute
    • CPU:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L4_Instanced/Assets/Scripts/InstancedFlocking.cs
    • Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L4_Instanced/Assets/Shaders/InstancedFlocking.shader

    6. 应用蒙皮动画

    img

    本节要做的是,使用Animator组件,在实例化物体之前,将各个关键帧的Mesh抓取到Buffer当中。通过选取不同索引,得到不同姿势的Mesh。具体的骨骼动画制作不在本文讨论范围。

    只需要在上一章的基础上修改代码,添加Animator等逻辑。我已经在下面写了注释,可以看看。

    并且个体的数据结构有所更新:

    struct Boid{
        float3 position;
        float3 direction;
        float noise_offset;
        float speed; // 暂时没啥用
        float frame; // 表示动画中的当前帧索引
        float3 padding; // 确保数据对齐
    };

    详细说说这里的对齐。一个数据结构中,数据的大小最好是16字节的整数倍。

    • float3 position; (12字节)
    • float3 direction; (12字节)
    • float noise_offset; (4字节)
    • float speed; (4字节)
    • float frame; (4字节)
    • float3 padding; (12字节)

    如果没有Padding,大小是36字节,不是常见的对齐大小。加上Padding,对齐到48字节,完美!

    private SkinnedMeshRenderer boidSMR; // 用于引用包含蒙皮网格的SkinnedMeshRenderer组件。
    private Animator animator;
    public AnimationClip animationClip; // 具体的动画剪辑,通常用于计算动画相关的参数。
    private int numOfFrames; // 动画中的帧数,用于确定在GPU缓冲区中存储多少帧数据。
    public float boidFrameSpeed = 10f; // 控制动画播放的速度。
    MaterialPropertyBlock props; // 在不创建新材料实例的情况下传递参数给着色器。这意味着可以改变实例的材质属性(如颜色、光照系数等),而不会影响到使用相同材料的其他对象。
    Mesh boidMesh; // 存储从SkinnedMeshRenderer烘焙出的网格数据。
    ...
    void Start(){ // 这里首先初始化Boid数据,然后调用GenerateSkinnedAnimationForGPUBuffer来准备动画数据,最后调用InitShader来设置渲染所需的Shader参数。
        ...
        // This property block is used only for avoiding an instancing bug.
        props = new MaterialPropertyBlock();
        props.SetFloat("_UniqueID", Random.value);
        ...
        InitBoids();
        GenerateSkinnedAnimationForGPUBuffer();
        InitShader();
    }
    void InitShader(){ // 此方法配置Shader和材料属性,确保动画播放可以根据实例的不同阶段正确显示。frameInterpolation的启用或禁用决定了是否在动画帧之间进行插值,以获得更平滑的动画效果。
        ...
        if (boidMesh)//Set by the GenerateSkinnedAnimationForGPUBuffer
        ...
        shader.SetFloat("boidFrameSpeed", boidFrameSpeed);
        shader.SetInt("numOfFrames", numOfFrames);
        boidMaterial.SetInt("numOfFrames", numOfFrames);
        if (frameInterpolation && !boidMaterial.IsKeywordEnabled("FRAME_INTERPOLATION"))
        boidMaterial.EnableKeyword("FRAME_INTERPOLATION");
        if (!frameInterpolation && boidMaterial.IsKeywordEnabled("FRAME_INTERPOLATION"))
        boidMaterial.DisableKeyword("FRAME_INTERPOLATION");
    }
    void Update(){
        ...
        // 后面两个参数:
            // 1. 0: 参数缓冲区的偏移量,用于指定从哪里开始读取参数。
            // 2. props: 前面创建的 MaterialPropertyBlock,包含所有实例共享的属性。
        Graphics.DrawMeshInstancedIndirect( boidMesh, 0, boidMaterial, bounds, argsBuffer, 0, props);
    }
    void OnDestroy(){ 
        ...
        if (vertexAnimationBuffer != null) vertexAnimationBuffer.Release();
    }
    private void GenerateSkinnedAnimationForGPUBuffer()
    {
        ... // 接下文
    }

    为了给Shader在不同的时间提供不同姿势的Mesh,因此在 GenerateSkinnedAnimationForGPUBuffer() 函数中,从 Animator 和 SkinnedMeshRenderer 中提取每一帧的网格顶点数据,然后将这些数据存储到GPU的 ComputeBuffer 中,以便在实例化渲染时使用。

    通过GetCurrentAnimatorStateInfo获取当前动画层的状态信息,用于后续控制动画的精确播放。

    numOfFrames 使用最接近动画长度和帧率乘积的二次幂来确定,可以优化GPU的内存访问。

    然后创建一个ComputeBuffer来存储所有帧的所有顶点数据。vertexAnimationBuffer

    在for循环中,烘焙所有动画帧。具体做法是,在每个sampleTime时间点播放并立即更新,然后烘焙当前动画帧的网格到bakedMesh中。并且提取刚刚烘焙好的Mesh顶点,更新到数组 vertexAnimationData 中,最后上传至GPU,结束。

    // ...接上文
    boidSMR = boidObject.GetComponentInChildren<SkinnedMeshRenderer>();
    boidMesh = boidSMR.sharedMesh;
    animator = boidObject.GetComponentInChildren<Animator>();
    int iLayer = 0;
    AnimatorStateInfo aniStateInfo = animator.GetCurrentAnimatorStateInfo(iLayer);
    Mesh bakedMesh = new Mesh();
    float sampleTime = 0;
    float perFrameTime = 0;
    numOfFrames = Mathf.ClosestPowerOfTwo((int)(animationClip.frameRate * animationClip.length));
    perFrameTime = animationClip.length / numOfFrames;
    var vertexCount = boidSMR.sharedMesh.vertexCount;
    vertexAnimationBuffer = new ComputeBuffer(vertexCount * numOfFrames, 16);
    Vector4[] vertexAnimationData = new Vector4[vertexCount * numOfFrames];
    for (int i = 0; i < numOfFrames; i++)
    {
        animator.Play(aniStateInfo.shortNameHash, iLayer, sampleTime);
        animator.Update(0f);
        boidSMR.BakeMesh(bakedMesh);
        for(int j = 0; j < vertexCount; j++)
        {
            Vector4 vertex = bakedMesh.vertices[j];
            vertex.w = 1;
            vertexAnimationData[(j * numOfFrames) +  i] = vertex;
        }
        sampleTime += perFrameTime;
    }
    vertexAnimationBuffer.SetData(vertexAnimationData);
    boidMaterial.SetBuffer("vertexAnimation", vertexAnimationBuffer);
    boidObject.SetActive(false);

    在Compute Shader中,维护每一个个体数据结构中储存的帧变量。

    boid.frame = boid.frame + velocity * deltaTime * boidFrameSpeed;
    if (boid.frame >= numOfFrames) boid.frame -= numOfFrames;

    在Shader中lerp不同帧的动画。左边是没有帧插值的,右边是插值后的,效果非常显著。

    视频封面

    好的标题可以获得更多的推荐及关注者

    void vert(inout appdata_custom v)
    {
        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
            #ifdef FRAME_INTERPOLATION
                v.vertex = lerp(vertexAnimation[v.id * numOfFrames + _CurrentFrame], vertexAnimation[v.id * numOfFrames + _NextFrame], _FrameInterpolation);
            #else
                v.vertex = vertexAnimation[v.id * numOfFrames + _CurrentFrame];
            #endif
            v.vertex = mul(_Matrix, v.vertex);
        #endif
    }
    void setup()
    {
        #ifdef UNITY_PROCEDURAL_INSTANCING_ENABLED
            _Matrix = create_matrix(boidsBuffer[unity_InstanceID].position, boidsBuffer[unity_InstanceID].direction, float3(0.0, 1.0, 0.0));
            _CurrentFrame = boidsBuffer[unity_InstanceID].frame;
            #ifdef FRAME_INTERPOLATION
                _NextFrame = _CurrentFrame + 1;
                if (_NextFrame >= numOfFrames) _NextFrame = 0;
                _FrameInterpolation = frac(boidsBuffer[unity_InstanceID].frame);
            #endif
        #endif
    }

    非常不容易,终于完整了。

    img

    完整工程链接:https://github.com/Remyuu/Unity-Compute-Shader-Learn/tree/L4_Skinned/Assets/Scripts

    8. 总结/小测试

    When rendering points which gives the best answer?

    img

    What are the three key steps in flocking?

    img

    When creating an arguments buffer for DrawMeshInstancedIndirect, how many uints are required?

    img

    We created the wing flapping by using a skinned mesh shader. True or False.

    img

    In a shader used by DrawMeshInstancedIndirect, which variable name gives the correct index for the instance?

    img

    References

    1. https://en.wikipedia.org/wiki/Boids
    2. Flocks, Herds, and Schools: A Distributed Behavioral Model
  • Compute Shader学习笔记(二)之 后处理效果

    Compute Shader学习笔记(二)之 后处理效果

    img

    前言

    初步认识了Compute Shader,实现一些简单的效果。所有的代码都在:

    https://github.com/Remyuu/Unity-Compute-Shader-Learngithub.com/Remyuu/Unity-Compute-Shader-Learn

    main分支是初始代码,可以下载完整的工程跟着我敲一遍。PS:每一个版本的代码我都单独开了分支。

    img

    这一篇文章学习如何使用Compute Shader制作:

    • 后处理效果
    • 粒子系统

    上一篇文章没有提及GPU的架构,是因为我觉得一上来就解释一大堆名词根本听不懂QAQ。有了实际编写Compute Shader的经验,就可以将抽象的概念和实际的代码联系起来。

    CUDA在GPU上的执行程序可以用三层架构来说明:

    • Grid – 对应一个Kernel
    • |-Block – 一个Grid有多个Block,执行相同的程序
    • | |-Thread – GPU上最基本的运算单元
    img

    Thread是GPU最基础的单元,不同Thread中自然就会有信息交换。为了有效地支持大量并行线程的运行,并解决这些线程之间的数据交换需求,内存被设计成多个层次。因此存储角度也可以分为三层:

    • Per-Thread memory – 一个Thread内,传输周期是一个时钟周期(小于1纳秒),速度可以比全局内存快几百倍。
    • Shared memory – 一个Block之间,速度比全局快很多。
    • Global memory – 所有线程之间,但速度最慢,通常是GPU的瓶颈。Volta架构使用了HBM2作为设备的全局内存,Turing则是用了GDDR6。

    如果超过内存大小限制,则会被推到容量更大但是更慢的存储空间上。

    Shared Memory和L1 cache共享同一个物理空间,但是功能上有区别:前者需要手动管理,后者由硬件自动管理。我的理解是,Shared Memory 功能上类似于一个可编程的L1缓存。

    img

    在NVIDIA的CUDA架构中,流式多处理器(Streaming Multiprocessor, SM)是GPU上的一个处理单元,负责执行分配给它的线程块(Blocks)中的线程。流处理器(Stream Processors),也称为“CUDA核心”,是SM内的处理元件,每个流处理器可以并行处理多个线程。总的来说:

    • GPU -> Multi-Processors (SMs) -> Stream Processors

    即,GPU包含多个SM(也就是多处理器),每个SM包含多个流处理器。每个流处理器负责执行一个或多个线程(Thread)的计算指令。

    在GPU中,Thread(线程)是执行计算的最小单元,Warp(纬度)是CUDA中的基本执行单位。

    在NVIDIA的CUDA架构中,每个Warp通常包含32个线程(AMD有64个)。Block(块)是一个线程组,包含多个线程。在CUDA中,一个Block可以包含多个WarpKernel(内核)是在GPU上执行的一个函数,你可以将其视为一段特定的代码,这段代码被所有激活的线程并行执行。总的来说:

    • Kernel -> Grid -> Blocks -> Warps -> Threads

    但在日常开发中,通常需要同时执行的线程(Threads)远超过32个。

    为了解决软件需求与硬件架构之间的数量不匹配问题,GPU采用了一种策略:将属于同一个块(Block)的线程分组。这种分组被称为“Warp”,每个Warp包含固定数量的线程。当需要执行的线程数量超过一个Warp所能包含的数量时,GPU会调度额外的Warp。这样做的原则是确保没有任何线程被遗漏,即便这意味着需要启动更多的Warp。

    举个例子,如果一个块(Block)有128个线程(Thread),并且我的显卡身穿皮夹克(Nvidia每个Warp有32个Thread),那么一个块(Block)就会有 128/32=4 个Warp。举一个极端的例子,如果有129个线程,那么就会开5个Warp。有31个线程位置将直接空闲!因此我们在写Compute Shader时,[numthreads(a,b,c)] 中的 abc 最好是32的倍数,减少CUDA核心的浪费。

    读到这里,想必你一定会很混乱。我按照个人的理解画了个图。若有错误请指出。

    img

    L3 后处理效果

    当前构建基于BIRP管线,SRP管线只需要修改几处代码。

    这一章关键在于构建一个抽象基类管理Compute Shader所需的资源(第一节)。然后基于这个抽象基类,编写一些简单的后处理效果,比如高斯模糊、灰阶效果、低分辨率像素效果以及夜视仪效果等等。这一章的知识点的小总结:

    • 获取和处理Camera的渲染贴图
    • ExecuteInEditMode 关键词
    • SystemInfo.supportsComputeShaders 检查系统是否支持
    • Graphics.Blit() 函数的使用(全程是Bit Block Transfer)
    • 用 smoothstep() 制作各种效果
    • 多个Kernel之间传输数据 Shared 关键词

    1. 介绍与准备工作

    后处理效果需要准备两张贴图,一个只读,另一个可读写。至于贴图从哪来,都说是后处理了,那肯定从相机身上获取贴图,也就是Camera组件上的Target Texture。

    • Source:只读
    • Destination:可读写,用于最终输出
    img

    由于后续会实现多种后处理效果,因此抽象出一个基类,减少后期工作量。

    在基类中封装以下特性:

    • 初始化资源(创建贴图、Buffer等)
    • 管理资源(比方说屏幕分辨率改变后,重新创建Buffer等等)
    • 硬件检查(检查当前设备是否支持Compute Shader)

    抽象类完整代码链接:https://pastebin.com/9pYvHHsh

    首先,当脚本实例被激活或者附加到活着的GO的时候,调用 OnEnable() 。在里面写初始化的操作。检查硬件是否支持、检查Compute Shader是否在Inspector上绑定、获取指定的Kernel、获取当前GO的Camera组件、创建纹理以及设置初始化状态为真。

    if (!SystemInfo.supportsComputeShaders)
        ...
    if (!shader)
        ...
    kernelHandle = shader.FindKernel(kernelName);
    thisCamera = GetComponent<Camera>();
    if (!thisCamera)
        ...
    CreateTextures();
    init = true;

    创建两个纹理 CreateTextures() ,一个Source一个Destination,尺寸为摄像机分辨率。

    texSize.x = thisCamera.pixelWidth;
    texSize.y = thisCamera.pixelHeight;
    if (shader)
    {
        uint x, y;
        shader.GetKernelThreadGroupSizes(kernelHandle, out x, out y, out _);
        groupSize.x = Mathf.CeilToInt((float)texSize.x / (float)x);
        groupSize.y = Mathf.CeilToInt((float)texSize.y / (float)y);
    }
    CreateTexture(ref output);
    CreateTexture(ref renderedSource);
    shader.SetTexture(kernelHandle, "source", renderedSource);
    shader.SetTexture(kernelHandle, "outputrt", output);

    具体纹理的创建:

    protected void CreateTexture(ref RenderTexture textureToMake, int divide=1)
    {
        textureToMake = new RenderTexture(texSize.x/divide, texSize.y/divide, 0);
        textureToMake.enableRandomWrite = true;
        textureToMake.Create();
    }

    这样就完成初始化了,当摄像机完成场景渲染并准备显示到屏幕上时,Unity会调用 OnRenderImage() ,这个时候就开始调用Compute Shader开始计算了。若当前没初始化好或者没shader,就Blit一下,把source直接拷给destination,即啥也不干。 CheckResolution(out _) 这个方法检查渲染纹理的分辨率是否需要更新,如果要,就重新生成一下Texture。完事之后,就到了老生常谈的Dispatch阶段啦。这里就需要将source贴图通过Buffer传给GPU,计算完毕后,传回给destination。

    protected virtual void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        if (!init || shader == null)
        {
            Graphics.Blit(source, destination);
        }
        else
        {
            CheckResolution(out _);
            DispatchWithSource(ref source, ref destination);
        }
    }

    注意看,这里我们没有用什么 SetData() 或者是 GetData() 之类的操作。因为现在所有数据都在GPU上,我们直接命令GPU自产自销就好了,CPU不要趟这滩浑水。如果将纹理取回内存,再传给GPU,性能就相当糟糕。

    protected virtual void DispatchWithSource(ref RenderTexture source, ref RenderTexture destination)
    {
        Graphics.Blit(source, renderedSource);
        shader.Dispatch(kernelHandle, groupSize.x, groupSize.y, 1);
        Graphics.Blit(output, destination);
    }

    我不信邪,非得传回CPU再传回GPU,测试结果相当震惊,性能竟然差了4倍以上。因此我们需要减少CPU和GPU之间的通信,这是使用Compute Shader时非常需要关心的。

    // 笨蛋方法
    protected virtual void DispatchWithSource(ref RenderTexture source, ref RenderTexture destination)
    {
        // 将源贴图Blit到用于处理的贴图
        Graphics.Blit(source, renderedSource);
        // 使用计算着色器处理贴图
        shader.Dispatch(kernelHandle, groupSize.x, groupSize.y, 1);
        // 将输出贴图复制到一个Texture2D对象中,以便读取数据到CPU
        Texture2D tempTexture = new Texture2D(renderedSource.width, renderedSource.height, TextureFormat.RGBA32, false);
        RenderTexture.active = output;
        tempTexture.ReadPixels(new Rect(0, 0, output.width, output.height), 0, 0);
        tempTexture.Apply();
        RenderTexture.active = null;
        // 将Texture2D数据传回GPU到一个新的RenderTexture
        RenderTexture tempRenderTexture = RenderTexture.GetTemporary(output.width, output.height);
        Graphics.Blit(tempTexture, tempRenderTexture);
        // 最终将处理后的贴图Blit到目标贴图
        Graphics.Blit(tempRenderTexture, destination);
        // 清理资源
        RenderTexture.ReleaseTemporary(tempRenderTexture);
        Destroy(tempTexture);
    }
    img

    接下来开始编写第一个后处理效果。

    小插曲:奇怪的BUG

    另外插播一个奇怪bug。

    在Compute Shader中,如果最终输出的贴图结果名字是output,那么在某些API比如Metal中,就会出问题。解决方法是,改个名字。

    RWTexture2D<float4> outputrt;
    img

    添加图片注释,不超过 140 字(可选)

    2. RingHighlight效果

    img

    创建RingHighlight类,继承自刚刚编写的基类。

    img

    重载初始化方法,指定Kernel。

    protected override void Init()
    {
        center = new Vector4();
        kernelName = "Highlight";
        base.Init();
    }

    重载渲染方法。想要实现聚焦某个角色的效果,则需要给Compute Shader传入角色的屏幕空间的坐标 center 。并且,如果在Dispatch之前,屏幕分辨率发生改变,那么重新初始化。

    protected void SetProperties()
    {
        float rad = (radius / 100.0f) * texSize.y;
        shader.SetFloat("radius", rad);
        shader.SetFloat("edgeWidth", rad * softenEdge / 100.0f);
        shader.SetFloat("shade", shade);
    }
    protected override void OnRenderImage(RenderTexture source, RenderTexture destination)
    {
        if (!init || shader == null)
        {
            Graphics.Blit(source, destination);
        }
        else
        {
            if (trackedObject && thisCamera)
            {
                Vector3 pos = thisCamera.WorldToScreenPoint(trackedObject.position);
                center.x = pos.x;
                center.y = pos.y;
                shader.SetVector("center", center);
            }
            bool resChange = false;
            CheckResolution(out resChange);
            if (resChange) SetProperties();
            DispatchWithSource(ref source, ref destination);
        }
    }

    并且改变Inspector面板的时候可以实时看到参数变化效果,添加 OnValidate() 方法。

    private void OnValidate()
    {
        if(!init)
            Init();
        SetProperties();
    }

    GPU中,该怎么制作一个圆内没有阴影,圆的边缘平滑过渡,过渡层外是阴影的效果呢?基于上一篇文章判断一个点是否在圆内的方法,我们用 smoothstep() ,处理过渡层即可。

    #pragma kernel Highlight
    
    Texture2D<float4> source;
    RWTexture2D<float4> outputrt;
    float radius;
    float edgeWidth;
    float shade;
    float4 center;
    
    float inCircle( float2 pt, float2 center, float radius, float edgeWidth ){
        float len = length(pt - center);
        return 1.0 - smoothstep(radius-edgeWidth, radius, len);
    }
    
    [numthreads(8, 8, 1)]
    void Highlight(uint3 id : SV_DispatchThreadID)
    {
        float4 srcColor = source[id.xy];
        float4 shadedSrcColor = srcColor * shade;
        float highlight = inCircle( (float2)id.xy, center.xy, radius, edgeWidth);
        float4 color = lerp( shadedSrcColor, srcColor, highlight );
    
        outputrt[id.xy] = color;
    
    }

    img

    当前版本代码:

    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L3_RingHighlight/Assets/Shaders/RingHighlight.compute
    • CPU:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L3_RingHighlight/Assets/Scripts/RingHighlight.cs

    3. 模糊效果

    img

    模糊效果原理很简单,每一个像素采样周边的 n*n 个像素加权平均就可以得到最终效果。

    但是有效率问题。众所周知,减少对纹理的采样次数对优化非常重要。如果每个像素都需要采样20*20个周边像素,那么渲染一个像素就需要采样400次,显然是无法接受的。并且,对于单个像素而言,采集周边一整个矩形像素的操作在Compute Shader中很难处理。怎么解决呢?

    通常做法是,横着采样一遍,再竖着采样一遍。什么意思呢?对于每一个像素,只在x方向上采样20个像素,y方向上采样20个像素,总共采样20+20个像素,再加权平均。这种方法不仅减少了采样次数,还更符合Compute Shader的逻辑。横着采样,设置一个Kernel;竖着采样,设置另一个Kernel。

    #pragma kernel HorzPass
    #pragma kernel Highlight

    由于Dispatch是顺序执行的,因此我们计算完水平的模糊后,利用计算好的结果再垂直采样一遍。

    shader.Dispatch(kernelHorzPassID, groupSize.x, groupSize.y, 1);
    shader.Dispatch(kernelHandle, groupSize.x, groupSize.y, 1);

    做完模糊操作之后,再结合上一节的RingHighlight,完工!

    有一点不同的是,再计算完水平模糊后,怎么将结果传给下一个Kernel呢?答案呼之欲出了,直接使用 shared 关键词。具体步骤如下。

    CPU中声明存储水平模糊纹理的引用,制作水平纹理的kernel,并绑定。

    RenderTexture horzOutput = null;
    int kernelHorzPassID;
    protected override void Init()
    {
        ...
        kernelHorzPassID = shader.FindKernel("HorzPass");
        ...
    }

    还需要额外在GPU中开辟空间,用来存储第一个kernel的结果。

    protected override void CreateTextures()
    {
        base.CreateTextures();
        shader.SetTexture(kernelHorzPassID, "source", renderedSource);
        CreateTexture(ref horzOutput);
        shader.SetTexture(kernelHorzPassID, "horzOutput", horzOutput);
        shader.SetTexture(kernelHandle, "horzOutput", horzOutput);
    }

    GPU上这样设置:

    shared Texture2D<float4> source;
    shared RWTexture2D<float4> horzOutput;
    RWTexture2D<float4> outputrt;

    另外有个疑问, shared 这个关键词好像加不加都一样,实际测试不同的kernel都可以访问到。那请问shared还有什么意义呢?

    在Unity中,变量前加shared表示这个资源不是每次调用都重新初始化,而是保持其状态,供不同的shader或dispatch调用使用。这有助于在不同的shader调用之间共享数据。标记了 shared 可以帮助编译器优化出更高性能的代码。

    img

    在计算边界的像素时,会遇到可用像素数量不足的情况。要么就是左边剩下的像素不足 blurRadius ,要么右边剩余像素不足。因此先算出安全的左索引,然后再计算从左到右最大可以取多少。

    [numthreads(8, 8, 1)]
    void HorzPass(uint3 id : SV_DispatchThreadID)
    {
        int left = max(0, (int)id.x-blurRadius);
        int count = min(blurRadius, (int)id.x) + min(blurRadius, source.Length.x - (int)id.x);
        float4 color = 0;
        uint2 index = uint2((uint)left, id.y);
        [unroll(100)]
        for(int x=0; x<count; x++){
            color += source[index];
            index.x++;
        }
        color /= (float)count;
        horzOutput[id.xy] = color;
    }
    [numthreads(8, 8, 1)]
    void Highlight(uint3 id : SV_DispatchThreadID)
    {
        //Vert blur
        int top = max(0, (int)id.y-blurRadius);
        int count = min(blurRadius, (int)id.y) + min(blurRadius, source.Length.y - (int)id.y);
        float4 blurColor = 0;
        uint2 index = uint2(id.x, (uint)top);
        [unroll(100)]
        for(int y=0; y<count; y++){
            blurColor += horzOutput[index];
            index.y++;
        }
        blurColor /= (float)count;
        float4 srcColor = source[id.xy];
        float4 shadedBlurColor = blurColor * shade;
        float highlight = inCircle( (float2)id.xy, center.xy, radius, edgeWidth);
        float4 color = lerp( shadedBlurColor, srcColor, highlight );
        outputrt[id.xy] = color;
    }

    当前版本代码:

    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L3_BlurEffect/Assets/Shaders/BlurHighlight.compute
    • CPU:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L3_BlurEffect/Assets/Scripts/BlurHighlight.cs

    4. 高斯模糊

    和上面不同的是,采样之后不再是取平均值,而是用一个高斯函数加权求得。

    其中, 是标准差,控制宽度。

    有关更多Blur的内容:https://www.gamedeveloper.com/programming/four-tricks-for-fast-blurring-in-software-and-hardware#close-modal

    由于这个计算量还有不小的,如果每一个像素都去计算一次这个式子就非常耗。我们用预计算的方式,将计算结果通过Buffer的方式传到GPU上。由于两个kernel都需要使用,在Buffer声明的时候加一个shared。

    float[] SetWeightsArray(int radius, float sigma)
    {
        int total = radius * 2 + 1;
        float[] weights = new float[total];
        float sum = 0.0f;
        for (int n=0; n<radius; n++)
        {
            float weight = 0.39894f * Mathf.Exp(-0.5f * n * n / (sigma * sigma)) / sigma;
            weights[radius + n] = weight;
            weights[radius - n] = weight;
            if (n != 0)
                sum += weight * 2.0f;
            else
                sum += weight;
        }
        // normalize kernels
        for (int i=0; i<total; i++) weights[i] /= sum;
        return weights;
    }
    private void UpdateWeightsBuffer()
    {
        if (weightsBuffer != null)
            weightsBuffer.Dispose();
        float sigma = (float)blurRadius / 1.5f;
        weightsBuffer = new ComputeBuffer(blurRadius * 2 + 1, sizeof(float));
        float[] blurWeights = SetWeightsArray(blurRadius, sigma);
        weightsBuffer.SetData(blurWeights);
        shader.SetBuffer(kernelHorzPassID, "weights", weightsBuffer);
        shader.SetBuffer(kernelHandle, "weights", weightsBuffer);
    }
    img

    完整代码:

    • https://pastebin.com/0qWtUKgy
    • https://pastebin.com/A6mDKyJE

    5. 低分辨率效果

    GPU:真是酣畅淋漓的计算啊。

    img

    让一张高清的纹理边模糊,同时不修改分辨率。实现方法很简单,每 n*n 个像素,都只取左下角的像素颜色即可。利用整数的特性,id.x索引先除n,再乘上n就可以了。

    uint2 index = (uint2(id.x, id.y)/3) * 3;
    float3 srcColor = source[index].rgb;
    float3 finalColor = srcColor;

    效果已经放在上面了。但是这个效果太锐利了,通过添加噪声,柔化锯齿。

    uint2 index = (uint2(id.x, id.y)/3) * 3;
    float noise = random(id.xy, time);
    float3 srcColor = lerp(source[id.xy].rgb, source[index],noise);
    float3 finalColor = srcColor;
    img

    每 n*n 个格子的像素不在只取左下角的颜色,而是取原本颜色和左下角颜色的随机插值结果。效果一下子就精细了不少。当n比较大的时候,还能看到下面这样的效果。只能说不太好看,但是在一些故障风格道路中还是可以继续探索。

    img

    如果想要得到噪声感的画面,可以尝试lerp的两端添加系数,比如:

    float3 srcColor = lerp(source[id.xy].rgb * 2, source[index],noise);
    img

    6. 灰阶效果与染色

    Grayscale Effect & Tinted

    将彩色图像转换为灰阶图像的过程涉及将每个像素的RGB值转换为一个单一的颜色值。这个颜色值是RGB值的加权平均值。这里有两种方法,一种是简单平均,一种是符合人眼感知的加权平均。

    1. 平均值法(简单但不准确):

    这种方法对所有颜色通道给予相同的权重。 2. 加权平均法(更准确, 反映人眼感知):

    这种方法根据人眼对绿色更敏感、对红色次之、对蓝色最不敏感的特点, 给予不同颜色通道不同的权重。(下面的截图效果不太好,我也没看出来lol)

    img

    加权后,再简单地颜色混合(乘法),最后lerp得到可控的染色强度结果。

    uint2 index = (uint2(id.x, id.y)/6) * 6;
    float noise = random(id.xy, time);
    float3 srcColor = lerp(source[id.xy].rgb, source[index],noise);
    // float3 finalColor = srcColor;
    float3 grayScale = (srcColor.r+srcColor.g+srcColor.b)/3.0;
    // float3 grayScale = srcColor.r*0.299f+srcColor.g*0.587f+srcColor.b*0.114f;
    float3 tinted = grayScale * tintColor.rgb;
    float3 finalColor = lerp(srcColor, tinted, tintStrength);
    outputrt[id.xy] = float4(finalColor, 1);

    染一个废土颜色:

    img

    7. 屏幕扫描线效果

    首先 uvY 将坐标归一化到 [0,1] 。

    lines 是控制扫描线数量的一个参数。

    然后增加一个时间偏移,系数控制偏移速度。可以开放一个参数控制线条偏移的速度。

    float uvY = (float)id.y/(float)source.Length.y;
    float scanline = saturate(frac(uvY * lines + time * 3));
    img

    这个“线”看起来不太够“线”,减个肥。

    float uvY = (float)id.y/(float)source.Length.y;
    float scanline = saturate(smoothstep(0.1,0.2,frac(uvY * lines + time * 3)));
    img

    然后lerp上颜色。

    float uvY = (float)id.y/(float)source.Length.y;
    float scanline = saturate(smoothstep(0.1, 0.2, frac(uvY * lines + time*3)) + 0.3);
    finalColor = lerp(source[id.xy].rgb*0.5, finalColor, scanline);
    img

    “减肥”前后,各取所需吧!

    img

    8. 夜视仪效果

    这一节总结上面所有内容,实现一个夜视仪的效果。先做一个单眼效果。

    float2 pt = (float2)id.xy;
    float2 center = (float2)(source.Length >> 1);
    float inVision = inCircle(pt, center, radius, edgeWidth);
    float3 blackColor = float3(0,0,0);
    finalColor = lerp(blackColor, finalColor, inVision);
    img

    双眼效果不同点在于有两个圆心,计算得到的两个遮罩vision用 max() 或者是 saturate() 合并即可。

    float2 pt = (float2)id.xy;
    float2 centerLeft = float2(source.Length.x / 3.0, source.Length.y /2);
    float2 centerRight = float2(source.Length.x / 3.0 * 2.0, source.Length.y /2);
    float inVisionLeft = inCircle(pt, centerLeft, radius, edgeWidth);
    float inVisionRight = inCircle(pt, centerRight, radius, edgeWidth);
    float3 blackColor = float3(0,0,0);
    // float inVision = max(inVisionLeft, inVisionRight);
    float inVision = saturate(inVisionLeft + inVisionRight);
    finalColor = lerp(blackColor, finalColor, inVision);
    img

    当前版本代码:

    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L3_NightVision/Assets/Shaders/NightVision.compute
    • CPU:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L3_NightVision/Assets/Scripts/NightVision.cs

    9. 平缓过渡线条

    思考一下,我们应该怎么在屏幕上画一条平滑过渡的直线。

    img

    smoothstep() 函数可以完成这个操作,熟悉这个函数的读者可以略过这一段。这个函数用来创建平滑的渐变。smoothstep(edge0, edge1, x) 函数在x在 edge0 和 edge1 之间时,输出值从0渐变到1。如果 x < edge0 ,返回0;如果 x > edge1 ,返回1。其输出值是根据Hermite插值计算的:

    img
    float onLine(float position, float center, float lineWidth, float edgeWidth) {
        float halfWidth = lineWidth / 2.0;
        float edge0 = center - halfWidth - edgeWidth;
        float edge1 = center - halfWidth;
        float edge2 = center + halfWidth;
        float edge3 = center + halfWidth + edgeWidth;
        return smoothstep(edge0, edge1, position) - smoothstep(edge2, edge3, position);
    }

    上面代码中,传入的参数都已经归一化 [0,1]。position 是考察的点的位置,center 是线的中心位置,lineWidth 是线的实际宽度,edgeWidth 是边缘的宽度,用于平滑过渡。我实在对我的表达能力感到不悦!至于怎么算的,我给大家画个图理解吧!

    大概就是:, ,。

    img

    思考一下,怎么画一个平滑过渡的圆。

    对于每个点,先计算与圆心的距离向量,结果返回给 position ,并且计算其长度返回给 len 。

    模仿上面两个 smoothstep 做差的方法,通过减去外边缘插值结果来生成一个环形的线条效果。

    float circle(float2 position, float2 center, float radius, float lineWidth, float edgeWidth){
        position -= center;
        float len = length(position);
        //Change true to false to soften the edge
        float result = smoothstep(radius - lineWidth / 2.0 - edgeWidth, radius - lineWidth / 2.0, len) - smoothstep(radius + lineWidth / 2.0, radius + lineWidth / 2.0 + edgeWidth, len);
        return result;
    }
    img

    10. 扫描线效果

    然后一条横线、一条竖线,套娃几个圆,做一个雷达扫描的效果。

    float3 color = float3(0.0f,0.0f,0.0f);
    color += onLine(uv.y, center.y, 0.002, 0.001) * axisColor.rgb;//xAxis
    color += onLine(uv.x, center.x, 0.002, 0.001) * axisColor.rgb;//yAxis
    color += circle(uv, center, 0.2f, 0.002, 0.001) * axisColor.rgb;
    color += circle(uv, center, 0.3f, 0.002, 0.001) * axisColor.rgb;
    color += circle(uv, center, 0.4f, 0.002, 0.001) * axisColor.rgb;

    再画一个扫描线,并且带有轨迹。

    float sweep(float2 position, float2 center, float radius, float lineWidth, float edgeWidth) {
        float2 direction = position - center;
        float theta = time + 6.3;
        float2 circlePoint = float2(cos(theta), -sin(theta)) * radius;
        float projection = clamp(dot(direction, circlePoint) / dot(circlePoint, circlePoint), 0.0, 1.0);
        float lineDistance = length(direction - circlePoint * projection);
        float gradient = 0.0;
        const float maxGradientAngle = PI * 0.5;
        if (length(direction) < radius) {
            float angle = fmod(theta + atan2(direction.y, direction.x), PI2);
            gradient = clamp(maxGradientAngle - angle, 0.0, maxGradientAngle) / maxGradientAngle * 0.5;
        }
        return gradient + 1.0 - smoothstep(lineWidth, lineWidth + edgeWidth, lineDistance);
    }

    添加到颜色中。

    ...
    color += sweep(uv, center, 0.45f, 0.003, 0.001) * sweepColor.rgb;
    ...
    img

    当前版本代码:

    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L3_HUDOverlay/Assets/Shaders/HUDOverlay.compute
    • CPU:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L3_HUDOverlay/Assets/Scripts/HUDOverlay.cs

    11. 渐变背景阴影效果

    这个效果可以用在字幕或者是一些说明性文字之下。虽然可以直接在UI Canvas中加一张贴图,但是使用Compute Shader可以实现更加灵活的效果以及资源的优化。

    img

    字幕、对话文字背景一般都在屏幕下方,上方不作处理。同时需要较高的对比度,因此对原有画面做一个灰度处理、并且指定一个阴影。

    if (id.y<(uint)tintHeight){
        float3 grayScale = (srcColor.r + srcColor.g + srcColor.b) * 0.33 * tintColor.rgb;
        float3 shaded = lerp(srcColor.rgb, grayScale, tintStrength) * shade;
        ... // 接下文
    }else{
        color = srcColor;
    }
    img

    渐变效果。

    ...// 接上文
        float srcAmount = smoothstep(tintHeight-edgeWidth, (float)tintHeight, (float)id.y);
        ...// 接下文
    img

    最后再lerp起来。

    ...// 接上文
        color = lerp(float4(shaded, 1), srcColor, srcAmount);
    img

    12. 总结/小测试

    If id.xy = [ 100, 30 ]. What would be the return value of inCircle((float2)id.xy, float2(130, 40), 40, 0.1)

    img

    When creating a blur effect which answer describes our approach best?

    img

    Which answer would create a blocky low resolution version of the source image?

    img

    What is smoothstep(5, 10, 6); ?

    img

    If an and b are both vectors. Which answer best describes dot(a,b)/dot(b,b); ?

    img

    What is _MainTex_TexelSize.x? If _MainTex is 512 x 256 pixel resolution.

    img

    13. 利用Blit结合Material做后处理

    除了使用Compute Shader制作后处理,还有一种简单的方法。

    // .cs
    Graphics.Blit(source, dest, material, passIndex);
    // .shader
    Pass{
        CGPROGRAM
        #pragma vertex vert_img
        #pragma fragment frag
        fixed4 frag(v2f_img input) : SV_Target{
            return tex2D(_MainTex, input.uv);
        }
        ENDCG
    }

    通过结合Shader来处理图像数据。

    那么问题来了,两者有什么区别?而且传进来的不是一张纹理吗,哪来的顶点?

    答:

    第一个问题。这种方法称为“屏幕空间着色”,完全集成在Unity的图形管线中,性能其实比Compute Shader更高。而Compute Shader提供了对GPU资源的更细粒度控制。它不受图形管线的限制,可以直接访问和修改纹理、缓冲区等资源。

    第二个问题。注意看 vert_img 。在UnityCG中可以找到如下定义:

    img
    img

    Unity会自动将传进来的纹理自动转换为两个三角形(一个充满屏幕的矩形),我们用材质的方法编写后处理时直接在frag上写就好了。

    下一章将会学习如何将Material、Shader、Compute Shader还有C#联系起来。

  • Compute Shader学习笔记(一)之 入门

    Compute Shader学习笔记(一)之 入门

    标签 :入门/Shader/计算着色器/GPU优化

    img

    前言

    Compute Shader比较复杂,需要具备一定的编程知识、图形学知识以及GPU相关的硬件知识才能较好的掌握。学习笔记分为四个部分:

    • 初步认识Compute Shader,实现一些简单的效果
    • 画圆、星球轨道、噪声图、操控Mesh等等
    • 后处理、粒子系统
    • 物理模拟、绘制草地
    • 流体模拟

    主要参考资料如下:

    • https://www.udemy.com/course/compute-shaders/?couponCode=LEADERSALE24A
    • https://catlikecoding.com/unity/tutorials/basics/compute-shaders/
    • https://medium.com/ericzhan-publication/shader筆記-初探compute-shader-9efeebd579c1
    • https://docs.unity3d.com/Manual/class-ComputeShader.html
    • https://docs.unity3d.com/ScriptReference/ComputeShader.html
    • https://learn.microsoft.com/en-us/windows/win32/api/D3D11/nf-d3d11-id3d11devicecontext-dispatch
    • lygyue:Compute Shader(很有意思)
    • https://medium.com/@sengallery/unity-compute-shader-基礎認識-5a99df53cea1
    • https://kylehalladay.com/blog/tutorial/2014/06/27/Compute-Shaders-Are-Nifty.html(太老,已经过时)
    • http://www.sunshine2k.de/coding/java/Bresenham/RasterisingLinesCircles.pdf
    • 王江荣:【Unity】Compute Shader的基础介绍与使用
    • …未完待续

    L1 介绍Compute Shader

    1. 初识Compute Shader

    简单的说,可以通过Compute Shader,计算出一个材质,然后通过Renderer显示出来。需要注意,Compute Shader不仅仅可以做这些。

    img
    img

    可以把下面两份代码拷下来测试一下。

    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    
    public class AssignTexture : MonoBehaviour
    {
        // ComputeShader 用于在 GPU 上执行计算任务
        public ComputeShader shader;
    
        // 纹理分辨率
        public int texResolution = 256;
    
        // 渲染器组件
        private Renderer rend;
        // 渲染纹理
        private RenderTexture outputTexture;
        // 计算着色器内核句柄
        private int kernelHandle;
    
        // Start 在脚本启用时被调用一次
        void Start()
        {
            // 创建一个新的渲染纹理,指定宽度、高度和位深度(此处位深度为0)
            outputTexture = new RenderTexture(texResolution, texResolution, 0);
            // 允许随机写入
            outputTexture.enableRandomWrite = true;
            // 创建渲染纹理实例
            outputTexture.Create();
    
            // 获取当前对象的渲染器组件
            rend = GetComponent<Renderer>();
            // 启用渲染器
            rend.enabled = true;
    
            InitShader();
        }
    
        private void InitShader()
        {
            // 查找计算着色器内核 "CSMain" 的句柄
            kernelHandle = shader.FindKernel("CSMain");
    
            // 设置计算着色器中使用的纹理
            shader.SetTexture(kernelHandle, "Result", outputTexture);
    
            // 将渲染纹理设置为材质的主纹理
            rend.material.SetTexture("_MainTex", outputTexture);
    
            // 调度计算着色器的执行,传入计算组的大小
            // 这里假设每个工作组是 16x16
            // 简单的说就是,要分配多少个组,才能完成计算,目前只分了xy的各一半,因此只渲染了1/4的画面。
            DispatchShader(texResolution / 16, texResolution / 16);
        }
    
        private void DispatchShader(int x, int y)
        {
            // 调度计算着色器的执行
            // x 和 y 表示计算组的数量,1 表示 z 方向上的计算组数量(这里只有一个)
            shader.Dispatch(kernelHandle, x, y, 1);
        }
    
        void Update()
        {
            // 每帧检查是否有键盘输入(按键 U 被松开)
            if (Input.GetKeyUp(KeyCode.U))
            {
                // 如果按键 U 被松开,则重新调度计算着色器
                DispatchShader(texResolution / 8, texResolution / 8);
            }
        }
    }

    Unity默认的Compute Shader:

    // Each #kernel tells which function to compile; you can have many kernels
    #pragma kernel CSMain
    
    // Create a RenderTexture with enableRandomWrite flag and set it
    // with cs.SetTexture
    RWTexture2D<float4> Result;
    
    [numthreads(8,8,1)]
    void CSMain (uint3 id : SV_DispatchThreadID) { 
      // TODO: insert actual code here! Result[id.xy] = float4(id.x & id.y, (id.x & 15)/15.0, (id.y & 15)/15.0, 0.0); 
    }

    在这个示例中,我们可以看到左下角四分之一的区域绘制上了一种名为Sierpinski网的分形结构,这个无关紧要,Unity官方觉得这个图形很有代表性,就把它当作默认代码了。

    具体讲一下Compute Shader的代码, C# 的代码看注释即可。

    #pragma kernel CSMain 这行代码指示了Compute Shader的入口。CSMain名字随便改。

    RWTexture2D Result 这行代码是一个可读写的二维纹理。R代表Read,W代表Write。

    着重看这一行代码:

    [numthreads(8,8,1)]

    在Compute Shader文件中,这行代码规定了一个线程组的大小,比如这个8 * 8 * 1的线程组中,一共有64个线程。每一个线程计算一个单位的像素(RWTexture)。

    而在上面的 C# 文件中,我们用 shader.Dispatch 指定线程组的数量。

    img
    img
    img

    接下来提一个问题,如果当前线程组指定为 881 ,那么我们需要多少个线程组才能渲染完 res*res 大小的RWTexture呢?

    答案是:res/8 个。而我们代码目前只调用了 res/16 个,因此只渲染了左下角的1/4的区域。

    除此之外,入口函数传入的参数也值得一说。uint3 id : SV_DispatchThreadID 这个id表示当前线程的唯一标识符。

    2. 四分图案

    学会走之前,先学会爬。首先在 C# 中指定需要执行的任务(Kernel)。

    img

    目前我们写死了,现在我们暴露一个参数,表示可以执行渲染不同的任务。

    public string kernelName = "CSMain";
    ...
    kernelHandle = shader.FindKernel(kernelName);

    这样,就可以在Inspector中随意修改了。

    img

    但是,光上盘子可不行,得上菜啊。我们在Compute Shader中做菜。

    先设置几个菜单。

    #pragma kernel CSMain // 刚刚我们已经声明好了
    #pragma kernel SolidRed // 定义一个新的菜,并且在下面写出来就好了
    ... // 可以写很多
    [numthreads(8,8,1)]
    void CSMain (uint3 id : SV_DispatchThreadID){ ... }
    [numthreads(8,8,1)]
    void SolidRed (uint3 id : SV_DispatchThreadID){
     Result[id.xy] = float4(1,0,0,0); 
    }

    在Inspector中修改对应的名字,就可以启用不同的Kernel。

    img

    如果我想传数据给Compute Shader咋办?比方说,给Compute Shader传一个材质的分辨率。

    shader.SetInt("texResolution", texResolution);
    img
    img

    并且在Compute Shader里,也要声明好。

    img

    思考一个问题,怎么实现下面的效果?

    img
    [numthreads(8,8,1)]
    void SplitScreen (uint3 id : SV_DispatchThreadID)
    {
        int halfRes = texResolution >> 1;
        Result[id.xy] = float4(step(halfRes, id.x),step(halfRes, id.y),0,1);
    }

    解释一下,step 函数其实就是:

    step(edge, x){
        return x>=edge ? 1 : 0;
    }

    (uint)res >> 1 意思就是res的位往右边移动一位。相当于除2(二进制的内容)。

    这个计算方法就只是简单的依赖当前的线程id。

    位于左下角的线程永远输出黑色。因为step返回永远都是0。

    而左下半边的线程, id.x > halfRes ,因此在红通道返回1。

    以此类推,非常简单。如果你不信服,可以具体算一下,可以帮助理解线程id、线程组和线程组组的关系。

    img
    img

    3. 画圆

    原理听上去很简单,判断 (id.x, id.y) 是否在圆内,是则输出1,否则0。动手试试吧。

    img
    float inCircle( float2 pt, float radius ){
        return ( length(pt)<radius ) ? 1.0 : 0.0;
    }
    
    [numthreads(8,8,1)]
    void Circle (uint3 id : SV_DispatchThreadID)
    {
        int halfRes = texResolution >> 1;
        int isInside = inCircle((float2)((int2)id.xy-halfRes), (float)(halfRes>>1));
        Result[id.xy] = float4(0.0,isInside ,0,1);
    }

    img

    4. 总结/小测试

    如果输出是 256 为边长的RWTexture,哪个答案会产生完整的红色的纹理?

    RWTexture2D<float4> output;
    
    [numthreads(16,16,1)]
    void CSMain (uint3 id : SV_DispatchThreadID)
    {
         output[id.xy] = float4(1.0, 0.0, 0.0, 1.0);
    }

    img

    哪个答案将在纹理输出的左侧给出红色,右侧给出黄色?

    img

    L2 开始了

    1. 传递值给GPU

    img

    废话不多说,先画一个圆。两份初始代码在这里。

    PassData.cs: https://pastebin.com/PMf4SicK

    PassData.compute: https://pastebin.com/WtfUmhk2

    大体结构和上文的没有变化。可以看到最终调用了一个drawCircle函数来画圆。

    [numthreads(1,1,1)]
    void Circles (uint3 id : SV_DispatchThreadID)
    {
        int2 centre = (texResolution >> 1);
        int radius = 80;
        drawCircle( centre, radius );
    }

    这里使用的画圆方法是非常经典的光栅化绘制方法,对数学原理感兴趣的可以看 http://www.sunshine2k.de/coding/java/Bresenham/RasterisingLinesCircles.pdf 。大概思路是利用一种对称的思想生成的。

    不同的是,这里我们使用指定 (1,1,1) 为一个线程组的大小。在CPU端调用CS:

    private void DispatchKernel(int count)
    {
        shader.Dispatch(circlesHandle, count, 1, 1);
    }
    void Update()
    {
        DispatchKernel(1);
    }

    问题来了,请问一个线程执行了多少次?

    答:只执行了一次。因为一个线程组只有 111=1 个线程,并且CPU端只调用了 111=1 个线程组来计算。因此只用了一个线程完成了一个圆的绘制。也就是说,一个线程可以一次绘制一整个RWTexture,也不是之前那样,一个线程绘制一个pixel。

    这也说明了Compute Shader和Fragment Shader是有本质的区别的。片元着色器只是计算单个像素的颜色,而Compute Shader可以执行或多或少任意的操作!

    img

    回到Unity,想绘制好看的圆,就需要轮廓颜色、填充颜色。将这两个参数传递到CS中。

    float4 clearColor;
    float4 circleColor;

    并且增加颜色填充Kernel,并修改Circles内核。如果有多个内核同时访问一个RWTexture的时候,可以添加上 shared 关键词。

    #pragma kernel Circles
    #pragma kernel Clear
        ...
    shared RWTexture2D<float4> Result;
        ...
    [numthreads(32,1,1)]
    void Circles (uint3 id : SV_DispatchThreadID)
    {
        // int2 centre = (texResolution >> 1);
        int2 centre = (int2)(random2((float)id.x) * (float)texResolution);
        int radius = (int)(random((float)id.x) * 30);
        drawCircle( centre, radius );
    }
    
    [numthreads(8,8,1)]
    void Clear (uint3 id : SV_DispatchThreadID)
    {
        Result[id.xy] = clearColor;
    }

    在CPU端获取Clear内核,传入数据。

    private int circlesHandle;
    private int clearHandle;
        ...
    shader.SetVector( "clearColor", clearColor);
    shader.SetVector( "circleColor", circleColor);
        ...
    private void DispatchKernels(int count)
    {
        shader.Dispatch(clearHandle, texResolution/8, texResolution/8, 1);
        shader.Dispatch(circlesHandle, count, 1, 1);
    }
    void Update()
    {
        DispatchKernels(1); // 现在画面有32个圆圆
    }

    一个问题,如果代码改为:DispatchKernels(10) ,画面会有多少个圆?

    答:320个。一开始Dispatch为 111=1 时,一个线程组有 3211=32 个线程,每个线程画一个圆。小学数学。

    接下来,加入 _Time 变量,让圆圆随着时间变化。由于Compute Shader内部貌似没有_time这样的变量,所以只能由CPU传入。

    CPU端,注意,实时更新的变量需要在每次Dispatch前更新(outputTexture不需要,因为这outputTexture指向的实际上是GPU纹理的引用!):

    private void DispatchKernels(int count)
    {
        shader.Dispatch(clearHandle, texResolution/8, texResolution/8, 1);
        shader.SetFloat( "time", Time.time);
        shader.Dispatch(circlesHandle, count, 1, 1);
    }

    Compute Shader:

    float time;
    ...
    void Circles (uint3 id : SV_DispatchThreadID){
        ...
        int2 centre = (int2)(random2((float)id.x + time) * (float)texResolution);
        ...
    }

    当前版本代码:

    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L2_Circle_Time/Assets/Shaders/PassData.compute
    • CPU:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L2_Circle_Time/Assets/Scripts/PassData.cs

    但是现在的圆非常混乱,下一步就需要利用Buffer让圆圆看起来更有规律。

    img

    同时不需要担心多个线程尝试同时写入同一个内存位置(比如 RWTexture),可能会出现竞争条件(race condition)。当前的API都会很好的处理这个问题。

    2. 利用Buffer传递数据给GPU

    目前为止,我们学习了如何从CPU传送一些简单的数据给GPU。如何传递自定义的结构体呢?

    img

    我们可以使用Buffer作为媒介,其中Buffer当然是存在GPU当中的,CPU端(C#)只存储其引用。。首先,在CPU声明一个结构体,然后声明CPU端的引用和GPU端的引用

    struct Circle
    {
        public Vector2 origin;
        public Vector2 velocity;
        public float radius;
    }
        Circle[] circleData;  // 在CPU上
        ComputeBuffer buffer; // 在GPU上

    获取一个线程组的大小信息,可以这样,下面代码只获取了circlesHandles线程组的x方向上的线程数量,yz都不要了(因为假设线程组yz都是1)。并且乘上分配的线程组数量,就可以得到总的线程数量。

    uint threadGroupSizeX;
    shader.GetKernelThreadGroupSizes(circlesHandle, out threadGroupSizeX, out _, out _);
    int total = (int)threadGroupSizeX * count;

    现在把需要传给GPU的数据准备好。这里创建了线程数个圆形,circleData[threadNums]。

    circleData = new Circle[total];
    float speed = 100;
    float halfSpeed = speed * 0.5f;
    float minRadius = 10.0f;
    float maxRadius = 30.0f;
    float radiusRange = maxRadius - minRadius;
    for(int i=0; i<total; i++)
    {
        Circle circle = circleData[i];
        circle.origin.x = Random.value * texResolution;
        circle.origin.y = Random.value * texResolution;
        circle.velocity.x = (Random.value * speed) - halfSpeed;
        circle.velocity.y = (Random.value * speed) - halfSpeed;
        circle.radius = Random.value * radiusRange + minRadius;
        circleData[i] = circle;
    }

    然后在Compute Shader上接受这个Buffer。声明一个一模一样的结构体(Vector2和Float2是一样的),然后创建一个Buffer的引用。

    // Compute Shader
    struct circle
    {
        float2 origin;
        float2 velocity;
        float radius;
    };
    StructuredBuffer<circle> circlesBuffer;

    注意,这里使用的StructureBuffer是只读的,区别于下一节提到的RWStructureBuffer。

    回到CPU端,将刚才准备好的CPU数据通过Buffer发送给GPU。首先明确我们申请的Buffer大小,也就是我们要传多大的东西给GPU。这里一份圆形的数据有两个 float2 的变量和一个 float 的变量,一个float是4bytes(不同平台可能不同,你可以用 sizeof(float) 加以判断),并且有 circleData.Length 份圆数据需要传递。circleData.Length表示缓冲区需要存储多少个圆形对象,而stride定义了每个对象的数据占用多少字节。开辟了这么大的空间,接下来使用SetData()将数据填充到缓冲区,也就是这一步,将数据传递给了GPU。最后将数据所在的GPU引用绑定到Compute Shader指定的Kernel。

    int stride = (2 + 2 + 1) * 4; //2 floats origin, 2 floats velocity, 1 float radius - 4 bytes per float
    buffer = new ComputeBuffer(circleData.Length, stride);
    buffer.SetData(circleData);
    shader.SetBuffer(circlesHandle, "circlesBuffer", buffer);

    目前为止,我们已经将CPU准备好的一些数据,通过Buffer传递给了GPU。

    img

    OK,现在把好不容易传到GPU的数据利用起来。

    [numthreads(32,1,1)]
    void Circles (uint3 id : SV_DispatchThreadID)
    {
        int2 centre = (int2)(circlesBuffer[id.x].origin + circlesBuffer[id.x].velocity * time);
        while (centre.x>texResolution) centre.x -= texResolution;
        while (centre.x<0) centre.x += texResolution;
        while (centre.y>texResolution) centre.y -= texResolution;
        while (centre.y<0) centre.y += texResolution;
        uint radius = (int)circlesBuffer[id.x].radius;
        drawCircle( centre, radius );
    }

    就可以看到,现在的圆圆是连续运动的。因为我们Buffer存储了id.x为索引的圆在上一帧的位置以及这个圆的运动状态。

    img

    总结一下,这一节学会了如何在CPU端自定义一个结构体(数据结构),并且通过Buffer传递给GPU,在GPU上对数据进行处理。

    下一节,我们学习如何从GPU获取数据返回给CPU。

    • 当前版本代码:
    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L2_Using_Buffer/Assets/Shaders/BufferJoy.compute
    • CPU:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L2_Using_Buffer/Assets/Scripts/BufferJoy.cs

    3. 从GPU取得数据

    还是老样子,创建一个Buffer,用于把数据从GPU传回给CPU。并且在CPU这边定义一个数组,用于接受数据。然后创建好缓冲区、绑定到着色器上,最后在CPU上创建好准备接受GPU数据的变量。

    ComputeBuffer resultBuffer; // Buffer
    Vector3[] output;           // CPU接受
    ...
        //buffer on the gpu in the ram
        resultBuffer = new ComputeBuffer(starCount, sizeof(float) * 3);
        shader.SetBuffer(kernelHandle, "Result", resultBuffer);
        output = new Vector3[starCount];

    在Compute Shader中也接受这样一个Buffer。这里的Buffer是可读写的,也就是说这个Buffer可以被Compute Shader修改。上一节中,Compute Shader只需要读取Buffer,因此 StructuredBuffer 足矣。这里我们需要使用RW。

    RWStructuredBuffer<float3> Result;

    接下来,在Dispatch后面用 GetData 接收数据即可。

    shader.Dispatch(kernelHandle, groupSizeX, 1, 1);
    resultBuffer.GetData(output);
    img

    思路就是这么简单。现在我们尝试制作一大堆围绕球心运动的星星场景。

    将计算星星坐标的任务放到GPU上完成,最终获取计算好的各个星星的位置数据,在 C# 中实例化物体。

    Compute Shader中,每一个线程计算一个星星的位置,然后输出到Buffer当中。

    [numthreads(64,1,1)]
    void OrbitingStars (uint3 id : SV_DispatchThreadID)
    {
        float3 sinDir = normalize(random3(id.x) - 0.5);
        float3 vec = normalize(random3(id.x + 7.1393) - 0.5);
        float3 cosDir = normalize(cross(sinDir, vec));
        float scaledTime = time * 0.5 + random(id.x) * 712.131234;
        float3 pos = sinDir * sin(scaledTime) + cosDir * cos(scaledTime);
        Result[id.x] = pos * 2;
    }

    在CPU端通过 GetData 得到计算结果,时刻修改对应事先实例化好的GameObject的Pos。

    void Update()
    {
        shader.SetFloat("time", Time.time);
        shader.Dispatch(kernelHandle, groupSizeX, 1, 1);
        resultBuffer.GetData(output);
        for (int i = 0; i < stars.Length; i++)
            stars[i].localPosition = output[i];
    }
    img

    当前版本代码:

    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L2_GetData_From_Buffer/Assets/Shaders/OrbitingStars.compute
    • CPU:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L2_GetData_From_Buffer/Assets/Scripts/OrbitingStars.cs

    4. 使用噪声

    使用Compute Shader生成一张噪声图非常简单,并且非常高效。

    float random (float2 pt, float seed) {
        const float a = 12.9898;
        const float b = 78.233;
        const float c = 43758.543123;
        return frac(sin(seed + dot(pt, float2(a, b))) * c );
    }
    
    [numthreads(8,8,1)]
    void CSMain (uint3 id : SV_DispatchThreadID)
    {
        float4 white = 1;
        Result[id.xy] = random(((float2)id.xy)/(float)texResolution, time) * white;
    }
    img

    有一个库可以得到更多各式各样的噪声。https://pastebin.com/uGhMLKeM

    #include "noiseSimplex.cginc" // Paste the code above and named "noiseSimplex.cginc"
    
    ...
    
    [numthreads(8,8,1)]
    void CSMain (uint3 id : SV_DispatchThreadID)
    {
        float3 pos = (((float3)id)/(float)texResolution) * 2.0;
        float n = snoise(pos);
        float ring = frac(noiseScale * n);
        float delta = pow(ring, ringScale) + n;
    
        Result[id.xy] = lerp(darkColor, paleColor, delta);
    }

    img

    5. 变形的Mesh

    这一节中,我们将一个Cube正方体,通过Compute Shader变成一个球体,并且要有动画过程,是渐变的!

    img

    老样子,在CPU端声明顶点参数,然后丢到GPU里面计算,计算得到的新坐标newPos,应用到Mesh上。

    顶点结构的声明,CPU端的声明我们附带一个构造函数,这样方便些。GPU端的照葫芦画瓢。此处,我们打算向GPU传递两个Buffer,一个只读另一个可读写。一开始两个Buffer是一样的,随着时间变化(渐变),可读写的Buffer逐渐变化,Mesh从立方体不断变成球球。

    // CPU
    public struct Vertex
    {
        public Vector3 position;
        public Vector3 normal;
        public Vertex( Vector3 p, Vector3 n )
        {
            position.x = p.x;
            position.y = p.y;
            position.z = p.z;
            normal.x = n.x;
            normal.y = n.y;
            normal.z = n.z;
        }
    }
    ...
    Vertex[] vertexArray;
    Vertex[] initialArray;
    ComputeBuffer vertexBuffer;
    ComputeBuffer initialBuffer;
    // GPU
    struct Vertex {
        float3 position;
        float3 normal;
    };
    ...
    RWStructuredBuffer<Vertex>  vertexBuffer;
    StructuredBuffer<Vertex>    initialBuffer;

    初始化( Start() 函数)的完整步骤如下:

    1. 在CPU端,初始化kernel,获取Mesh引用
    2. 将Mesh数据传到CPU中
    3. 在GPU中声明Mesh数据的Buffer
    4. 将Mesh数据和其他参数传到GPU中

    完成这些操作后,每一帧Update,我们将从GPU得到的新顶点,应用给mesh。

    那GPU的计算怎么实现呢?

    相当简单的做法,我们只需要归一化模型空间的各个顶点即可!试想一下,当所有顶点位置向量都归一化了,那模型就变成一个球。

    img

    实际代码中,我们还需要同时计算法线,如果不改变法线,物体的光照就会非常奇怪。那问题来了,法线怎么计算呢?非常简单,原本正方体的顶点的坐标就是最终球球的法线向量!

    img

    为了实现“呼吸”的效果,加入一个正弦函数,控制归一化的系数。

    float delta = (Mathf.Sin(Time.time) + 1)/ 2;

    由于代码有点长,放一个链接吧。

    当前版本代码:

    • Compute Shader:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L2_Mesh_Cube2Sphere/Assets/Shaders/MeshDeform.compute
    • CPU:https://github.com/Remyuu/Unity-Compute-Shader-Learn/blob/L2_Mesh_Cube2Sphere/Assets/Scripts/MeshDeform.cs
    img

    6. 总结/小测试

    应该如何在GPU上定义这个结构:

    struct Circle
    {
        public Vector2 origin;
        public Vector2 velocity;
        public float radius;
    }
    img

    这个结构应该怎样设置ComputeBuffer的大小?

    struct Circle
    {
        public Vector2 origin;
        public Vector2 velocity;
        public float radius;
    }
    img

    下面代码为什么错误?

    StructuredBuffer<float3> positions;
    //Inside a kernel
    ...
    positions[id.x] = fixed3(1,0,0);
    img

    References

  • Games202 作业三 SSR实现

    Games202 作业三 SSR实现

    作业源代码:

    https://github.com/Remyuu/GAMES202-Homeworkgithub.com/Remyuu/GAMES202-Homework

    TODO List

    • 实现对场景直接光照的着色 (考虑阴影)。
    • 实现屏幕空间下光线的求交 (SSR)。
    • 实现对场景间接光照的着色。
    • 实现动态步长的RayMarch。
    • (还没写) Bonus 1:实现 Mipmap 优化的 Screen Space Ray Tracing。
    img

    采样数:32

    写在前面

    这一次作业的基础部分算是目前202所有作业中最简单的了,没有特别复杂的内容。但是Bonus部分不知如何下手,大佬们带带。

    框架的深度缓冲问题

    这一次作业在 macOS 上会遇到比较严重的问题。正方体贴近地面的部分会随着摄像机的距离远近变化表现出异常的裁切锯齿问题。这个现象在 windows 上没有遇到,比较奇怪。

    img

    个人感觉这与深度缓冲区的精度有关,可能是z-fighting导致的,其中两个或更多重叠的表面竞争同一像素的问题。对于这种问题一般下面几种解决方案:

    • 调整近平面和远平面:不要让近平面离摄像机太近,远平面不要太远。
    • 提高深度缓冲区的精度:采用32位或者更高的精度。
    • 多通渲染(Multi-Pass Rendering):对不同距离范围的物体采用不同的渲染方案。

    最简单的解决办法就是修改近平面的大小,定位到框架的 engine.js 的25行。

    // engine.js
    // const camera = new THREE.PerspectiveCamera(75, gl.canvas.clientWidth / gl.canvas.clientHeight, 0.0001, 1e5);
    const camera = new THREE.PerspectiveCamera(75, gl.canvas.clientWidth / gl.canvas.clientHeight, 5e-2, 1e2);

    这样就可以得到相当锐利的边界了。

    img

    增加「暂停渲染」功能

    这个部分是可选的。为了减轻电脑的压力,简单写一个暂停渲染的按钮。

    // engine.js
    let settings = {
        'Render Switch': true
    };
    function createGUI() {
        ...
        // Add the boolean switch here
        gui.add(settings, 'Render Switch');
        ...
    }
    function mainLoop(now) {
        if(settings['Render Switch']){
            cameraControls.update();
            renderer.render();
        }
        requestAnimationFrame(mainLoop);
    }
    requestAnimationFrame(mainLoop);
    img

    image-20231117191114477

    1. 实现直接光照

    实现「shaders/ssrShader/ssrFragment.glsl」中的 EvalDiffuse(vec3 wi, vec3 wo, vec2 uv) 和 EvalDirectionalLight(vec2 uv) 。

    // ssrFragment.glsl
    vec3 EvalDiffuse(vec3 wi, vec3 wo, vec2 screenUV) {
      vec3 reflectivity = GetGBufferDiffuse(screenUV);
      vec3 normal = GetGBufferNormalWorld(screenUV);
      float cosi = max(0., dot(normal, wi));
      vec3 f_r = reflectivity * cosi;
      return f_r;
    }
    vec3 EvalDirectionalLight(vec2 screenUV) {
      vec3 Li = uLightRadiance * GetGBufferuShadow(screenUV);
      return Li;
    }

    第一段代码其实就是实现了Lambertian reflection model,对应渲染方程里面的 $f_r \cdot \text{cos}(\theta_i)$ 。

    我这里是除了 $\pi$ ,但是按照作业框架给出的结果,应该是没有除的,这里随便吧。

    第二部分负责直接光照(包括阴影遮挡),相对渲染方程的 $L_i \cdot V$ 。

    Lo(p,ωo)=Le(p,ωo)+∫ΩLi(p,ωi)⋅fr(p,ωi,ωo)⋅V(p,ωi)⋅cos⁡(θi)dωi

    这里顺便复习一下Lambertian反射模型。我们注意到 EvalDiffuse 传入了wi, wo 两个方向,但我们只是用了入射光的方向 wi 。这是因为Lambertian模型与观察的方向没有关系,只和表面法线与入射光线的余弦值有关。

    最后在 main() 中设置结果。

    // ssrFragment.glsl
    void main() {
      float s = InitRand(gl_FragCoord.xy);
      vec3 L = vec3(0.0);
      vec3 wi = normalize(uLightDir);
      vec3 wo = normalize(uCameraPos - vPosWorld.xyz);
      vec2 worldPos = GetScreenCoordinate(vPosWorld.xyz);
      L = EvalDiffuse(wi, wo, worldPos) * 
          EvalDirectionalLight(worldPos);
      vec3 color = pow(clamp(L, vec3(0.0), vec3(1.0)), vec3(1.0 / 2.2));
      gl_FragColor = vec4(vec3(color.rgb), 1.0);
    }
    img

    2. 镜面SSR – 实现RayMarch

    实现 RayMarch(ori, dir, out hitPos) 函数,求出光线与物体的交点,返回光线是否与物体相交。参数 ori 和 dir 为世界坐标系中的值,分别代表光线的起点和方向,其中方向向量为单位向量。 更多资料可以参考EA在SIG15的课程报告

    作业框架的「cube1」本身就包含了地面,所以这玩意最终得到的SSR效果就不太美观。这里的“美观”是指论文中结果图的清晰度或游戏中积水反射效果的精致度。

    准确地说,在本文中我们实现的是最基础的「镜面SSR」,即Basic mirror-only SSR。

    img

    实现「镜面SSR」最简单的方法就是使用Linear Raymarch,通过一个个小步进逐步确定当前位置与gBuffer的深度位置的遮挡关系。

    img
    // ssrFragment.glsl
    bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
      const int totalStepTimes = 60;
      const float threshold = 0.0001;
      float step = 0.05;
      vec3 stepDir = normalize(dir) * step;
      vec3 curPos = ori;
      for(int i = 0; i < totalStepTimes; i++) {
        vec2 screenUV = GetScreenCoordinate(curPos);
        float rayDepth = GetDepth(curPos);
        float gBufferDepth = GetGBufferDepth(screenUV);
        // Check if the ray has hit an object
        if(rayDepth > gBufferDepth + threshold){
          hitPos = curPos;
          return true;
        }
        curPos += stepDir;
      }
      return false;
    }

    最后微调步进 step 的大小。最终我取到0.05。如果步进取的太大,反射的画面会“断层”。如果步进取得太小且步进次数又不够,那么可能导致本来应该反射的地方因为步进距离不够导致计算的终止。下图的最大步进数为150。

    img
    // ssrFragment.glsl
    vec3 EvalSSR(vec3 wi, vec3 wo, vec2 screenUV) {
      vec3 worldNormal = GetGBufferNormalWorld(screenUV);
      vec3 relfectDir = normalize(reflect(-wo, worldNormal));
      vec3 hitPos;
      if(RayMarch(vPosWorld.xyz, relfectDir, hitPos)){
        vec2 INV_screenUV = GetScreenCoordinate(hitPos);
        return GetGBufferDiffuse(INV_screenUV);
      }
      else{
        return vec3(0.); 
      }
    }

    写一个调用 RayMarch 的函数包装起来,方便在 main() 中使用。

    // ssrFragment.glsl
    void main() {
      float s = InitRand(gl_FragCoord.xy);
      vec3 L = vec3(0.0);
      vec3 wi = normalize(uLightDir);
      vec3 wo = normalize(uCameraPos - vPosWorld.xyz);
      vec2 screenUV = GetScreenCoordinate(vPosWorld.xyz);
      // Basic mirror-only SSR
      float reflectivity = 0.2;
      L = EvalDiffuse(wi, wo, screenUV) * EvalDirectionalLight(screenUV);
      L+= EvalSSR(wi, wo, screenUV) * reflectivity;
      vec3 color = pow(clamp(L, vec3(0.0), vec3(1.0)), vec3(1.0 / 2.2));
      gl_FragColor = vec4(vec3(color.rgb), 1.0);
    }

    如果单纯想测试SSR的效果,请在 main() 中自行调整。

    img
    img

    在2013年”Killzone Shadow Fall”发布之前,SSR技术仍然受到较大的限制,因为在实际开发中,我们通常需要模拟Glossy的物体,由于当时性能的限制,SSR技没有大规模采用。随着“Killzone Shadow Fall”的发布,标志着实时反射技术取得了重大的进展。得益于PS4的特殊硬件,使得实时渲染高质量Glossy和semi-reflective的物体成为可能。

    img

    在接下来的几年中,SSR技术发展迅速,尤其是与PBR等技术的结合。

    从Nvidia的RTX显卡开始,实时光线追踪的兴起逐渐开始在某些场景替代了SSR。但是在大多数开发场景中,传统的SSR仍然占有相当大的戏份。

    未来的发展趋势依然是传统SSR技术与光线追踪技术的混合。

    3. 间接光照

    照着伪代码写。也就是用蒙特卡洛方法求解渲染方程。与之前不同的是,这次的样本都在屏幕空间中。在采样的过程中可以使用框架提供的 SampleHemisphereUniform(inout s, ou pdf) 和 SampleHemisphereCos(inout s, out pdf) ,其中,这两个函数返回局部坐标,传入参数分别是随机数 s 和采样概率 pdf 。

    这个部分需要理解下图伪代码,然后照着完成 EvalIndirectionLight() 就好了。

    img

    首先需要知道,我们本次采样仍然是基于屏幕空间的。因此不在屏幕(gBuffer)中的内容我们就当作不存在。理解为只有一层正好面向摄像机的外壳。

    间接光照涉及上半球方向的随机采样和对应pdf计算。使用 InitRand(screenUV) 得到随机数就可以了,然后二选一, SampleHemisphereUniform(inout float s, out float pdf) 或 SampleHemisphereCos(inout float s, out float pdf) ,更新随机数同时得到对应 pdf 和单位半球上的局部坐标系的位置 dir 。

    将当前Shading Point的法线坐标传入函数 LocalBasis(n, out b1, out b2) ,随后返回 b1, b2 ,其中 n, b1, b2 这三个单位向量两两正交。通过这三个向量所构成的局部坐标系,将 dir 转换到世界坐标中。关于这个 LocalBasis() 的原理,我写在最后了。

    By the way, the matrix constructed with the vectors n (normal), b1, and b2 is commonly referred to as the TBN matrix in computer graphics.

    // ssrFragment.glsl
    #define SAMPLE_NUM 5
    vec3 EvalIndirectionLight(vec3 wi, vec3 wo, vec2 screenUV){
      vec3 L_ind = vec3(0.0);
      float s = InitRand(screenUV);
      vec3 normal = GetGBufferNormalWorld(screenUV);
      vec3 b1, b2;
      LocalBasis(normal, b1, b2);
      for(int i = 0; i < SAMPLE_NUM; i++){
        float pdf;
        vec3 direction = SampleHemisphereUniform(s, pdf);
        vec3 worldDir = normalize(mat3(b1, b2, normal) * direction);
        vec3 position_1;
        if(RayMarch(vPosWorld.xyz, worldDir, position_1)){ // 采样光线碰到了 position_1
          vec2 hitScreenUV = GetScreenCoordinate(position_1);
          vec3 bsdf_d = EvalDiffuse(worldDir, wo, screenUV); // 直接光照
          vec3 bsdf_i = EvalDiffuse(wi, worldDir, hitScreenUV); // 间接光照
          L_ind += bsdf_d / pdf * bsdf_i * EvalDirectionalLight(hitScreenUV);
        }
      }
      L_ind /= float(SAMPLE_NUM);
      return L_ind;
    }
    // ssrFragment.glsl
    // Main entry point for the shader
    void main() {
      vec3 wi = normalize(uLightDir);
      vec3 wo = normalize(uCameraPos - vPosWorld.xyz);
      vec2 screenUV = GetScreenCoordinate(vPosWorld.xyz);
      // Basic mirror-only SSR coefficient
      float ssrCoeff = 0.0;
      // Indirection Light coefficient
      float indCoeff = 0.3;
      // Direction Light
      vec3 L_d = EvalDiffuse(wi, wo, screenUV) * EvalDirectionalLight(screenUV);
      // SSR Light
      vec3 L_ssr = EvalSSR(wi, wo, screenUV) * ssrCoeff;
      // Indirection Light
      vec3 L_i = EvalIndirectionLight(wi, wo, screenUV) * IndCorff;
      vec3 result = L_d + L_ssr + L_i;
      vec3 color = pow(clamp(result, vec3(0.0), vec3(1.0)), vec3(1.0 / 2.2));
      gl_FragColor = vec4(vec3(color.rgb), 1.0);
    }

    只显示间接光照。采样数=5。

    img

    直接光照+间接光照。采样数=5。

    img

    写这个部分真是头痛啊,即使 SAMPLE_NUM 设置为1,我的电脑都汗流浃背了。Live Server一开,直接打字都有延迟了,受不了。M1pro就这么点性能了吗。而且最让我受不了的是,Safari浏览器卡就算了,为什么整个系统连带一起卡顿呢?这就是你macOS的User First策略吗?我不理解。迫不得已,我只能掏出我的游戏电脑通过局域网测试项目了(悲)。只是没想到RTX3070运行起来也有点大汗淋漓,看来我写的算法就是一坨狗屎,我的人生也是一坨狗屎啊

    4. RayMarch改进

    目前的 RayMarch() 其实是有问题的,会出现漏光的现象。

    img

    在采样数为5的情况下只有46.2帧左右。我的设备是M1pro 16GB。

    img

    这里重点说说为什么会产生漏光的现象,看下面这个图示。我们gBuffer里只有蓝色部分的深度信息,即使我们上面的算法已经判断了当前 curPos 已经比gBuffer的深度要深了,这也不能确保这个 curPos 是否就是碰撞点。因此上面的算法并没有考虑图中的情况,进而导致漏光的现象。

    img

    为了解决漏光问题,我们引入一个阈值 thresholds 解决这个问题(没错,又是一个近似),如果 curPos 和当前gBuffer记录的深度的差大于某个阈值,那就进入下图的情况。这个时候屏幕空间的信息没办法正确提供反射的信息,因此这个Shading Point的SSR结果就是 vec3(0) 。就是这么的简单粗暴!

    img

    代码的思路跟前面的差不多,每一次步进时,判断下一步位置的深度与gBuffer的深度的关系,如果下一步的位置在gBuffer的前面(nextDepth<gDepth),则可以步进。如果下一步的深度没有gBuffer的深,就判断一下深度相差多少,有没有给定的阈值大。如果比阈值大,那么就直接返回 false ,否则,这个时候就可以执行SSR了。先让当前位置步进一个step,返回给 hitPos ,然后返回真。

    bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
      const float EPS = 1e-2;
      const int totalStepTimes = 60;
      const float threshold = 0.1;
      float step = 0.05;
      vec3 stepDir = normalize(dir) * step;
      vec3 curPos = ori + stepDir;
      vec3 nextPos = curPos + stepDir;
      for(int i = 0; i < totalStepTimes; i++) {
        if(GetDepth(nextPos) < GetGBufferDepth(GetScreenCoordinate(nextPos))){
          curPos = nextPos;
          nextPos += stepDir;
        }else if(GetGBufferDepth(GetScreenCoordinate(curPos)) - GetDepth(curPos) + EPS > threshold){
          return false;
        }else{
          curPos += stepDir;
          vec2 screenUV = GetScreenCoordinate(curPos);
          float rayDepth = GetDepth(curPos);
          float gBufferDepth = GetGBufferDepth(screenUV);
          if(rayDepth > gBufferDepth + threshold){
            hitPos = curPos;
            return true;
          }
        }
      }
      return false;
    }

    但是帧率降到了42.6左右,但是却显著的改善了画面!至少是没有显著的漏光现象了。

    img

    但是画面还有一些瑕疵,就是在边缘的时候会有发毛的反射图样,也就是说漏光问题依旧没有解决,如下图所示:

    img

    上面的方法确实是存在问题的,在与阈值做对比的时候我们错误的使用了 curPos 来比较(即下图的Step n点),导致了代码也能进入第三个分支,返回那个错误 curPos 的 hitPos 。

    img

    再退一步,我们没有办法保证最终计算的 curPos 正好落在物体边缘与摄像机原点的线上。说白了,就是下图中蓝色的线是相当离散的。我们想要得到“恰好”在边界的 curPos ,进而将「Step n」到「“恰好”的curPos」这段距离的瑕疵(即上面的毛刺错误)处理掉,但是显然因为各种精度的原因,我们没办法获得。下图中,绿色的线代表一次step。

    img

    即使我们调整 threshold/step 的比值,使其接近1,我们也难以根除这个问题,最多只能起到缓解作用,就像下图所示。

    img

    因此我们需要再次改进刚刚的「防漏光」方法。

    换一句话说,就是让改进的思想也非常简单,既然我没办法获得“恰好”的 curPos 点,那我就把它猜出来。具体来说就是,直接来一个线性插值。插值之前再做一个近似,也就是将视线看作相互平行的,接着就像下图一样做一个相似三角形,猜出我们想要的 curPos ,然后把它当作 hitPos 。

    img

    hitPos=curPos+s1s1+s2

    bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
      bool result = false;
      const float EPS = 1e-3;
      const int totalStepTimes = 60;
      const float threshold = 0.1;
      float step = 0.05;
      vec3 stepDir = normalize(dir) * step;
      vec3 curPos = ori + stepDir;
      vec3 nextPos = curPos + stepDir;
      for(int i = 0; i < totalStepTimes; i++) {
        if(GetDepth(nextPos) < GetGBufferDepth(GetScreenCoordinate(nextPos))){
          curPos = nextPos;
          nextPos += stepDir;
          continue;
        }
        float s1 = GetGBufferDepth(GetScreenCoordinate(curPos)) - GetDepth(curPos) + EPS;
        float s2 = GetDepth(nextPos) - GetGBufferDepth(GetScreenCoordinate(nextPos)) + EPS;
        if(s1 < threshold && s2 < threshold){
          hitPos = curPos + stepDir * s1 / (s1 + s2);
          result = true;
        }
        break;
      }
      return result;
    }

    效果相当可以,没有鬼影和边界的瑕疵了。并且帧率也跟最开始的算法相似,在平均49.2左右。

    img

    接下来重点优化一下性能,具体而言就是:

    • 加入自适应step
    • 屏幕外忽略的判断

    屏幕外忽略的判断 非常简单。如果 curPos 的uvScreen不在0到1之间,那么直接放弃当前步进。

    详细说说自适应step。也就是在for的开头加上两行。实测帧率会稍微提高2-3帧左右。

    vec2 uvScreen = GetScreenCoordinate(curPos);
    if(any(bvec4(lessThan(uvScreen, vec2(0.0)), greaterThan(uvScreen, vec2(1.0))))) break;

    自适应step 也不难。首先为初始步进 step 设置一个较大的值,如果监测到步进之后的 curPos 不在屏幕内 或着 深度值比gBuffer的深 或者 不满足“s1 < threshold && s2 < threshold” ,那么就让step步进减半,以确保精度。

    bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
      const float EPS = 1e-2;
      const int totalStepTimes = 20;
      const float threshold = 0.1;
      bool result = false, firstIn = false;
      float step = 0.8;
      vec3 curPos = ori;
      vec3 nextPos;
      for(int i = 0; i < totalStepTimes; i++) {
        nextPos = curPos+dir*step;
        vec2 uvScreen = GetScreenCoordinate(curPos);
        if(any(bvec4(lessThan(uvScreen, vec2(0.0)), greaterThan(uvScreen, vec2(1.0))))) break;
        if(GetDepth(nextPos) < GetGBufferDepth(GetScreenCoordinate(nextPos))){
          curPos += dir * step;
          if(firstIn) step *= 0.5;
          continue;
        }
        firstIn = true;
        if(step < EPS){
          float s1 = GetGBufferDepth(GetScreenCoordinate(curPos)) - GetDepth(curPos) + EPS;
          float s2 = GetDepth(nextPos) - GetGBufferDepth(GetScreenCoordinate(nextPos)) + EPS;
          if(s1 < threshold && s2 < threshold){
            hitPos = curPos + 2.0 * dir * step * s1 / (s1 + s2);
            result = true;
          }
          break;
        }
        if(firstIn) step *= 0.5;
      }
      return result;
    }

    改进了之后,帧率一下子来到了100帧,几乎翻倍了。

    img

    最后整理一下代码。

    #define EPS 5e-2
    #define TOTAL_STEP_TIMES 20
    #define THRESHOLD 0.1
    #define INIT_STEP 0.8
    bool outScreen(vec3 curPos){
      vec2 uvScreen = GetScreenCoordinate(curPos);
      return any(bvec4(lessThan(uvScreen, vec2(0.0)), greaterThan(uvScreen, vec2(1.0))));
    }
    bool testDepth(vec3 nextPos){
      return GetDepth(nextPos) < GetGBufferDepth(GetScreenCoordinate(nextPos));
    }
    bool RayMarch(vec3 ori, vec3 dir, out vec3 hitPos) {
      float step = INIT_STEP;
      bool result = false, firstIn = false;
      vec3 nextPos, curPos = ori;
      for(int i = 0; i < TOTAL_STEP_TIMES; i++) {
        nextPos = curPos + dir * step;
        if(outScreen(curPos)) break;
        if(testDepth(nextPos)){ // 可以进步
          curPos += dir * step;
          continue;
        }else{ // 过于进步了
          firstIn = true;
          if(step < EPS){
            float s1 = GetGBufferDepth(GetScreenCoordinate(curPos)) - GetDepth(curPos) + EPS;
            float s2 = GetDepth(nextPos) - GetGBufferDepth(GetScreenCoordinate(nextPos)) + EPS;
            if(s1 < THRESHOLD && s2 < THRESHOLD){
              hitPos = curPos + 2.0 * dir * step * s1 / (s1 + s2);
              result = true;
            }
            break;
          }
          if(firstIn) step *= 0.5;
        }
      }
      return result;
    }

    切换到洞穴场景,采样率设置为32,帧率就只有可怜的4帧了。

    img

    而且不过次级光源质量非常不错了。

    img

    然而这个算法运用在反射上就会导致新的问题了。尤其是下边这张图,走样非常严重。

    img
    img

    5. Mipmap实现

    Hierarchical-Z map based occlusion culling

    6. LocalBasis构建TBN原理

    一般来说,构建法切副(法线、切线、副切线向量)是通过叉积来实现,实现方法非常简单,先任选一个跟法线向量不平行的辅助向量,两者做一次叉积得到第一个切线向量,然后这个切线向量和法线向量又做一次叉积,得到副切线向量。具体代码是这样写:

    void CalculateTBN(const vec3 &normal, vec3 &tangent, vec3 &bitangent) {
        vec3 helperVec;
        if (abs(normal.x) < abs(normal.y))
            helperVec = vec3(1.0, 0.0, 0.0);
        else
            helperVec = vec3(0.0, 1.0, 0.0);
        tangent = normalize(cross(helperVec, normal));
        bitangent = normalize(cross(normal, tangent));
    }

    但是作业框架中的代码避免了使用叉积,非常巧妙。简单的说,就是确保向量间的点积都是0。

    • $b1⋅n=0$
    • $b2⋅n=0$
    • $b1⋅b2=0$
    void LocalBasis(vec3 n, out vec3 b1, out vec3 b2) {
      float sign_ = sign(n.z);
      if (n.z == 0.0) {
        sign_ = 1.0;
      }
      float a = -1.0 / (sign_ + n.z);
      float b = n.x * n.y * a;
      b1 = vec3(1.0 + sign_ * n.x * n.x * a, sign_ * b, -sign_ * n.x);
      b2 = vec3(b, sign_ + n.y * n.y * a, -n.y);
    }

    这个算法是一个比较启发式的,引入了一个符号函数,相当有逼格。还考虑了除0的情况,格局也是拉满。但是下面这四行,应该是作者不知道在哪一天写公式的时候随便乱拆给他拆出来的而已,这里我还原一下当时作者的拆解步骤。也就是倒推的过程。

    img

    另外说一下,代码中的符号函数可以在最后一步乘上。

    实际上,这样的公式我可以造出一百个,我也不知道这些个公式之间有啥区别,知道的小伙伴请告诉我QAQ。如果硬要说,那么就可以这样解释:

    传统的基于叉乘的方法可能会产生数值不稳定,因为叉乘结果在这种情况下接近于零向量。 本文采用的方法是一种启发式方法,它通过一系列精心设计的步骤来构造正交基。这种方法特别注意了数值稳定性,使其在处理接近极端方向的法线向量时仍然有效和稳定。

    感谢 @我是龙套小果丁 的指出,上面这个方法是有讲究的。作业框架中提供的算法是Tom Duff等人在17年通过改进Frisvad’s方法得到的,具体可以看下面两篇paper。

    https://graphics.pixar.com/library/OrthonormalB/paper.pdfgraphics.pixar.com/library/OrthonormalB/paper.pdf

    https://backend.orbit.dtu.dk/ws/portalfiles/portal/126824972/onb_frisvad_jgt2012_v2.pdfbackend.orbit.dtu.dk/ws/portalfiles/portal/126824972/onb_frisvad_jgt2012_v2.pdf

    References

    1. Games 202
    2. LearnOpenGL – Normal Mapping
  • Games202 作业二 PRT实现

    Games202 作业二 PRT实现

    img
    img
    img

    因为我也是菜鸡,所以不能确保所有都是正确的,希望大佬指正。

    知乎的公式有点丑陋,可以去:GitHub

    项目源代码:

    https://github.com/Remyuu/GAMES202-Homeworkgithub.com/Remyuu/GAMES202-Homework

    预计算球谐系数

    利用框架nori预先计算球谐函数系数。

    环境光照:计算cubemap每个像素的球谐系数

    ProjEnv::PrecomputeCubemapSH(images, width, height, channel); 使用黎曼积分的方法计算环境光球谐函数的系数。

    完整代码

    // TODO: here you need to compute light sh of each face of cubemap of each pixel
    // TODO: 此处你需要计算每个像素下cubemap某个面的球谐系数
    Eigen::Vector3f dir = cubemapDirs[i * width * height + y * width + x];
    int index = (y * width + x) * channel;
    Eigen::Array3f Le(images[i][index + 0], images[i][index + 1],
                      images[i][index + 2]);
    // 描述在球面坐标上当前的角度
    double theta = acos(dir.z());
    double phi = atan2(dir.y(), dir.x());
    // 遍历球谐函数的各个基函数
    for (int l = 0; l <= SHOrder; l++){
        for (int m = -l; m <= l; m++){
            float sh = sh::EvalSH(l, m, phi, theta);
            float delta = CalcArea((float)x, (float)y, width, height);
            SHCoeffiecents[l*(l+1)+m] += Le * sh * delta;
        }
    }
    C#

    分析

    球谐系数是球谐函数在一个球面上的投影,可以用于表示函数在球面上的分布。由于我们有RGB值有三个通道,因此我们会的球谐系数会存储为一个三维的向量。需要完善的部分:

    /// prt.cpp - PrecomputeCubemapSH()
    // TODO: here you need to compute light sh of each face of cubemap of each pixel
    // TODO: 此处你需要计算每个像素下cubemap某个面的球谐系数
    Eigen::Vector3f dir = cubemapDirs[i * width * height + y * width + x];
    int index = (y * width + x) * channel;
    Eigen::Array3f Le(images[i][index + 0], images[i][index + 1],
                      images[i][index + 2]);
    C#

    首先从六个 cubemap ( images 数组)的每个像素采样方向(一个三维向量,表示从中心到像素的方向),将方向转化为球面坐标( theta 和 phi )。

    接着将各个球面坐标传入 sh::EvalSH() 分别计算每个球谐函数(基函数)的实值 sh 。同时计算每个 cubemap 中每个像素所占据球面区域的比重 delta 。

    最后累加球谐系数,代码中我们可以对 cubemap 伤的所有像素进行累加,近似在原始计算球谐函数积分的操作。

    $$
    Ylm=∫ϕ=02π∫θ=0πf(θ,ϕ)Ylm(θ,ϕ)sin⁡(θ)dθdϕ
    $$

    其中:

    • θ 是天顶角,范围从 0 到 π; ϕ 是方位角,范围从 0 到 2pi 。
    • f(θ,ϕ) 是球面上某点的函数值。
    • Ylm 是球谐函数,它由相应的勒让德多项式 Plm 和一些三角函数组成。
    • l 是球谐函数的阶数; m 是球谐函数的序数,范围从 −l 到 l 。

    为了更加具体的让读者理解,这里写出代码中球谐函数离散形式的估计,即黎曼积分的方法来计算。

    $$
    Ylm=∑i=1Nf(θi,ϕi)Ylm(θi,ϕi)Δωi
    $$

    其中:

    • f(θi,ϕi) 是球面上某点的函数值。
    • Ylm(θi,ϕi) 是球谐函数在该点的值。
    • Δωi 是该点在球面上的微小区域或权重。
    • N 是所有离散点的总数。

    代码细节

    • 从 cubemap 获取RGB光照信息
    Eigen::Array3f Le(images[i][index + 0], images[i][index + 1],
                      images[i][index + 2]);
    C#

    channel 的值是3,对应于RGB三个通道。因此,index 就指向了某一像素的红色通道的位置,index + 1 指向绿色通道的位置,index + 2 指向蓝色通道的位置。

    • 将方向向量转换为球面坐标
    double theta = acos(dir.z());
    double phi = atan2(dir.y(), dir.x());
    C#

    theta 是正z轴到 dir 方向的夹角,而 phi 表示从正x轴到 dir 在xz平面上的投影的夹角。

    • 遍历球谐函数的各个基函数
    for (int l = 0; l <= SHOrder; l++){
        for (int m = -l; m <= l; m++){
            float sh = sh::EvalSH(l, m, phi, theta);
            float delta = CalcArea((float)x, (float)y, width, height);
            SHCoeffiecents[l*(l+1)+m] += Le * sh * delta;
        }
    }
    C#

    无阴影的漫反射项

    scene->getIntegrator()->preprocess(scene); 计算 Diffuse Unshadowed 的情况。化简渲染方程,将上节的球谐函数代入进一步计算为BRDF的球谐投影的系数。其中关键的函数是ProjectFunction。我们要为这个函数编写一个lambda表达式,用于计算传输函数项 。

    分析

    对于漫反射传输项,可以分为三种情况考虑:有阴影的无阴影的相互反射的

    首先考虑最简单的没有阴影的情况。我们有渲染方程

    其中,

    • 是入射辐射度。
    • 是几何函数,表面的微观特性和入射光的方向有关。
    • 是入射光方向。

    对于表面处处相等的漫反射表面,我们可以简化得到 Unshadowed 光照方程

    其中:

    • 是点 的漫反射出射辐射度。
    • 是表面法线。

    入射辐射度 和传输函数项 相互独立,因为前者代表场景中光源的贡献,后者表示表面如何响应入射的光线。因此将这两个部分独立处理。

    具体到运用球谐函数近似是,我们分别对这两项展开。前者的输入是光的入射方向,后者输入的是反射(或者出射方向),并且展开是两个系列的数组,因此我们使用名为查找表(Look-Up Table,简称LUT)的数据结构。

    auto shCoeff = sh::ProjectFunction(SHOrder, shFunc, m_SampleCount);
    C#

    其中,最重要的是上面这个函数 ProjectFunction 。我们要为这个函数编写一个Lambda表达式(shFunc)作为传参,表达式用于计算传输函数项 。

    ProjectFunction 函数传参:

    • 球谐阶数
    • 需要投影在基函数上的函数(我们需要编写的)
    • 采样数

    该函数会取Lambda函数返回的结果投影在基函数上得到系数,最后把各个样本系数累加并乘以权重,最后得出该顶点的最终系数。

    完整代码

    计算几何项,即传输函数项 。

    // prt.cpp
    ...
    double H = wi.normalized().dot(n.normalized()) / M_PI;
    if (m_Type == Type::Unshadowed){
        // TODO: here you need to calculate unshadowed transport term of a given direction
        // TODO: 此处你需要计算给定方向下的unshadowed传输项球谐函数值
        return (H > 0.0) ? H : 0.0;
    }
    C#

    总之最后的积分结果要记得除以 ,再传给 m_TransportSHCoeffs 。

    有阴影的漫反射项

    scene->getIntegrator()->preprocess(scene); 计算 Diffuse Shadowed 的情况。这一项多了一个可见项 。

    分析

    Visibility项()是一个非1即0的值,利用 bool rayIntersect(const Ray3f &ray) 函数,从顶点位置到采样方向反射一条射线,若击中物体,则认为被遮挡,有阴影,返回0;若射线未击中物体,则仍然返回 即可。

    完整代码

    // prt.cpp
    ...
    double H = wi.normalized().dot(n.normalized()) / M_PI;
    ...
    else{
        // TODO: here you need to calculate shadowed transport term of a given direction
        // TODO: 此处你需要计算给定方向下的shadowed传输项球谐函数值
        if (H > 0.0 && !scene->rayIntersect(Ray3f(v, wi.normalized())))
            return H;
        return 0.0;
    }
    C#

    总之最后的积分结果要记得除以 ,再传给 m_TransportSHCoeffs 。

    导出计算结果

    nori框架会生成的两个预计算结果的文件。

    添加运行参数:

    ./scenes/prt.xml

    在 prt.xml 中,需要做以下修改,就可以选择渲染的环境光cubemap。另外,模型、相机参数等也可自行修改。

    // prt.xml
    
    <!-- Render the visible surface normals -->
    <integrator type="prt">
        <string name="type" value="unshadowed" />
        <integer name="bounce" value="1" />
        <integer name="PRTSampleCount" value="100" />
    <!--        <string name="cubemap" value="cubemap/GraceCathedral" />-->
    <!--        <string name="cubemap" value="cubemap/Indoor" />-->
    <!--        <string name="cubemap" value="cubemap/Skybox" />-->
        <string name="cubemap" value="cubemap/CornellBox" />
    
    </integrator>
    C#

    其中,标签可选值:

    • type:unshadowed、shadowed、 interreflection
    • bounce:interreflection类型下的光线弹射次数(目前尚未实现)
    • PRTSampleCount:传输项每个顶点的采样数
    • cubemap:cubemap/GraceCathedral、cubemap/Indoor、cubemap/Skybox、cubemap/CornellBox
    img

    上图分别是GraceCathedral、Indoor、Skybox和CornellBox的 unshadowed 渲染结果,采样数是1。

    使用球谐系数着色

    将nori生成的文件手动拖到实时渲染框架中,并且对实时框架做一些改动。

    上一章计算完成后,将对应cubemap路径中的 light.txt 和 transport.txt 拷贝到实时渲染框架的cubemap文件夹中。

    预计算数据解析

    取消 engine.js 中88-114行的注释,这一段代码用于解析刚才添加进来的txt文件。

    // engine.js
    // file parsing
    ... // 把这块代码取消注释
    C#

    导入模型/创建并使用PRT材质Shader

    在materials文件夹下建立文件 PRTMaterial.js 。

    //PRTMaterial.js
    
    class PRTMaterial extends Material {
        constructor(vertexShader, fragmentShader) {
            super({
                'uPrecomputeL[0]': { type: 'precomputeL', value: null},
                'uPrecomputeL[1]': { type: 'precomputeL', value: null},
                'uPrecomputeL[2]': { type: 'precomputeL', value: null},
            }, 
            ['aPrecomputeLT'], 
            vertexShader, fragmentShader, null);
        }
    }
    
    async function buildPRTMaterial(vertexPath, fragmentPath) {
        let vertexShader = await getShaderString(vertexPath);
        let fragmentShader = await getShaderString(fragmentPath);
    
        return new PRTMaterial(vertexShader, fragmentShader);
    }
    C#

    然后在 index.html 里引入。

    // index.html
    <script src="src/materials/Material.js" defer></script>
    <script src="src/materials/ShadowMaterial.js" defer></script>
    <script src="src/materials/PhongMaterial.js" defer></script>
    <!-- Edit Start --><script src="src/materials/PRTMaterial.js" defer></script><!-- Edit End -->
    <script src="src/materials/SkyBoxMaterial.js" defer></script>
    C#

    在 loadOBJ.js 加载新的材质。

    // loadOBJ.js
    
    switch (objMaterial) {
        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;
        // TODO: Add your PRTmaterial here
        //Edit Start
        case 'PRTMaterial':
            material = buildPRTMaterial("./src/shaders/prtShader/prtVertex.glsl", "./src/shaders/prtShader/prtFragment.glsl");
            break;
        //Edit End
        // ...
    }
    C#

    给场景添加mary模型,设置位置与大小,并且使用刚建立的材质。

    //engine.js
    
    // Add shapes
    ...
    // Edit Start
    let maryTransform = setTransform(0, -35, 0, 20, 20, 20);
    // Edit End
    ...
    // TODO: load model - Add your Material here
    ...
    // Edit Start
    loadOBJ(renderer, 'assets/mary/', 'mary', 'PRTMaterial', maryTransform);
    // Edit End
    C#

    计算着色

    将预计算数据载入GPU中。

    在渲染循环的camera pass中给材质设置precomputeL实时的值,也就是传递预先计算的数据给shader。下面代码是每一帧中每一趟camera pass中每一个网格mesh的每一个uniforms的遍历。实时渲染框架已经解析了预计算的数据并且存储到了uniforms中。precomputeL是一个 9×3 的矩阵,代表这里分别有RGB三个通道的前三阶(9个)球谐函数(实际上我们会说这是一个 3×3 的矩阵,但是我们写代码直接写成一个长度为9的数组)。为了方便使用,通过 tool.js 的函数将 precomputeL 转换为 3×9 的矩阵。

    通过 uniformMatrix3fv 函数,我们可以将材质里存储的信息上传到GPU上。这个函数接受三个参数,具体请查阅 WebGL文档 – uniformMatrix 。其中第一个参数的作用是在我们自己创建的 PRTMaterial 中,uniforms 包含了 uPrecomputeL[0] , uPrecomputeL[1] 和 uPrecomputeL[2] 。在GPU内的工作不需要我们关注,我们只需要有在CPU上的 uniform ,就可以通过API自动访问到GPU上对应的内容。换句话说,当获取一个 uniform 或属性的位置,实际上得到的是一个在CPU端的引用,但在底层,这个引用会映射到GPU上的一个具体位置。而链接 uniform 的步骤在 Shader.js 的 this.program = this.addShaderLocations() 中完成(看看代码就能懂了,只是比较绕,在我的HW1文章中也有分析过), shader.program 有三个属性分别是:glShaderProgram, uniforms, 和 attribs。而具体声明的位置则是在 XXXshader.glsl 中,在下一步中我们就会完成它。

    总结一下,下面这段代码主要工作就是为片段着色器提供预先处理过的数据。

    // WebGLRenderer.js
    
    if (k == 'uMoveWithCamera') { // The rotation of the skybox
        gl.uniformMatrix4fv(
            this.meshes[i].shader.program.uniforms[k],
            false,
            cameraModelMatrix);
    }
    
    // Bonus - Fast Spherical Harmonic Rotation
    //let precomputeL_RGBMat3 = getRotationPrecomputeL(precomputeL[guiParams.envmapId], cameraModelMatrix);
    
    // Edit Start
    let Mat3Value = getMat3ValueFromRGB(precomputeL[guiParams.envmapId]);
    
    if (/^uPrecomputeL\[\d\]$$/.test(k)) {
        let index = parseInt(k.split('[')[1].split(']')[0]);
        if (index >= 0 && index < 3) {
            gl.uniformMatrix3fv(
                this.meshes[i].shader.program.uniforms[k],
                false,
                Mat3Value[index]
            );
        }
    }
    // Edit End
    C#

    也可以将 Mat3Value 的计算放在i循环的外面,减少计算次数。

    编写顶点着色器

    明白了上面代码的作用之后,接下来的任务就非常明了了。上一步我们将每一个球谐系数都传到了 GPU 的 uPrecomputeL[] 中,接下来在GPU上编程计算球谐系数和传输矩阵的点乘,也就是下图 light_coefficient * transport_matrix。

    实时渲染框架中已经完成了Light_Transport到对应方向的矩阵的化简,我们只需要分别对三个颜色通道的长度为9的向量做点乘就行了。值得一提的是,PrecomputeL 和 PrecomputeLT 既可以传给顶点着色器也可以传给片段着色器,若传给顶点着色器,就只需要在片段着色器中差值得到颜色,速度更快,但是真实性就稍差一些。怎么计算取决于不同的需求。

    img
    //prtVertex.glsl
    
    attribute vec3 aVertexPosition;
    attribute vec3 aNormalPosition;
    attribute mat3 aPrecomputeLT;  // Precomputed Light Transfer matrix for the vertex
    
    uniform mat4 uModelMatrix;
    uniform mat4 uViewMatrix;
    uniform mat4 uProjectionMatrix;
    uniform mat3 uPrecomputeL[3];  // Precomputed Lighting matrices
    varying highp vec3 vNormal;
    
    varying highp vec3 vColor;     // Outgoing color after the dot product calculations
    
    float L_dot_LT(const mat3 PrecomputeL, const mat3 PrecomputeLT) {
      return dot(PrecomputeL[0], PrecomputeLT[0]) 
            + dot(PrecomputeL[1], PrecomputeLT[1]) 
            + dot(PrecomputeL[2], PrecomputeLT[2]);
    }
    
    void main(void) {
      // 防止因为浏览器优化报错,无实际作用
      aNormalPosition;
    
      for(int i = 0; i < 3; i++) {
          vColor[i] = L_dot_LT(aPrecomputeLT, uPrecomputeL[i]);
      }
    
      gl_Position = uProjectionMatrix * uViewMatrix * uModelMatrix * vec4(aVertexPosition, 1.0);
    }
    C#

    另外值得一说的是,在渲染框架中为一个名为 aNormalPosition 的attribute设置了数值,如果在Shader中没有使用的话就会被WebGL优化掉,导致浏览器不停报错。

    编写片元着色器

    在顶点着色器中完成对当前顶点着色的计算之后,在片元着色器中插值计算颜色。由于在顶点着色器中为每个顶点计算的vColor值会在片元着色器中被自动插值,因此直接使用就可以了。

    // prtFragment.glsl
    
    #ifdef GL_ES
    precision mediump float;
    #endif
    
    varying highp vec3 vColor;
    
    void main(){
      gl_FragColor = vec4(vColor, 1.0);
    }
    C#

    曝光与颜色矫正

    虽然框架作者提及PRT预计算保存的结果是在线性空间中的,不需要再进行 gamma 矫正了,但是显然最终结果是有问题的。如果您没有事先在计算系数的时候除 ,那么以Skybox场景为例子,就会出现过曝的问题。如果事先除了 ,但是没有做色彩矫正,就会在实时渲染框架中出现过暗的问题。

    img

    首先在计算系数的时候除以 ,然后再做一个色彩矫正。怎么做呢?我们可以参照nori框架的导出图片过程中有一个 toSRGB() 的函数:

    // common.cpp
    Color3f Color3f::toSRGB() const {
        Color3f result;
    
        for (int i=0; i<3; ++i) {
            float value = coeff(i);
    
            if (value <= 0.0031308f)
                result[i] = 12.92f * value;
            else
                result[i] = (1.0f + 0.055f)
                    * std::pow(value, 1.0f/2.4f) -  0.055f;
        }
    
        return result;
    }
    C#

    我们可以仿照这个在片元着色其中做色彩矫正。

    //prtFragment.glsl
    
    #ifdef GL_ES
    precision mediump float;
    #endif
    
    varying highp vec3 vColor;
    
    vec3 toneMapping(vec3 color){
        vec3 result;
    
        for (int i=0; i<3; ++i) {
            if (color[i] <= 0.0031308)
                result[i] = 12.92 * color[i];
            else
                result[i] = (1.0 + 0.055) * pow(color[i], 1.0/2.4) - 0.055;
        }
    
        return result;
    }
    
    void main(){
      vec3 color = toneMapping(vColor); 
      gl_FragColor = vec4(color, 1.0);
    }
    C#

    这样就可以保证实时渲染框架渲染的结果与nori框架的截图结果一致了。

    img

    我们也可以做其他的颜色矫正,这里提供几种常见的Tone Mapping方法,用于将HDR范围转换至LDR范围。

    vec3 linearToneMapping(vec3 color) {
        return color / (color + vec3(1.0));
    }
    vec3 reinhardToneMapping(vec3 color) {
        return color / (vec3(1.0) + color);
    }
    vec3 exposureToneMapping(vec3 color, float exposure) {
        return vec3(1.0) - exp(-color * exposure);
    }
    vec3 filmicToneMapping(vec3 color) {
        color = max(vec3(0.0), color - vec3(0.004));
        color = (color * (6.2 * color + 0.5)) / (color * (6.2 * color + 1.7) + 0.06);
        return color;
    }
    C#

    到这里为止,作业的基础部分就完成了。

    添加CornellBox场景

    默认框架代码中没有CornellBox,但是资源文件里面有,这就需要我们自行添加:

    // engine.js
    
    var envmap = [
        'assets/cubemap/GraceCathedral',
        'assets/cubemap/Indoor',
        'assets/cubemap/Skybox',
        // Edit Start
        'assets/cubemap/CornellBox',
        // Edit End
    ];
    // engine.js
    
    function createGUI() {
        const gui = new dat.gui.GUI();
        const panelModel = gui.addFolder('Switch Environemtn Map');
        // Edit Start
        panelModel.add(guiParams, 'envmapId', { 'GraceGathedral': 0, 'Indoor': 1, 'Skybox': 2, 'CornellBox': 3}).name('Envmap Name');
        // Edit End
        panelModel.open();
    }
    C#
    img

    基础部分结果展示

    分别展示shadowed和unshadowed的四个场景。

    img

    考虑传输项光线多次弹射(bonus 1)

    这是提高的第一部分。 计算多次弹射的光线传输与光线追踪有相似之处,在使用球谐函数(Spherical Harmonics,SH)进行光照近似时,您可以结合光线追踪来计算这些多次反射的效果。

    完整代码

    // TODO: leave for bonus
    Eigen::MatrixXf m_IndirectCoeffs = Eigen::MatrixXf::Zero(SHCoeffLength, mesh->getVertexCount());
    int sample_side = static_cast<int>(floor(sqrt(m_SampleCount)));
    
    std::random_device rd;
    std::mt19937 gen(rd());
    std::uniform_real_distribution<> rng(0.0, 1.0);
    
    const double twoPi = 2.0 * M_PI;
    
    for(int bo = 0; bo < m_Bounce; bo++)
    {
        for (int i = 0; i < mesh->getVertexCount(); i++)
        {
            const Point3f &v = mesh->getVertexPositions().col(i);
            const Normal3f &n = mesh->getVertexNormals().col(i);
    
            std::vector<float> coeff(SHCoeffLength, 0.0f);
            for (int t = 0; t < sample_side; t++) {
                for (int p = 0; p < sample_side; p++) {
                    double alpha = (t + rng(gen)) / sample_side;
                    double beta = (p + rng(gen)) / sample_side;
                    double phi = twoPi * beta;
                    double theta = acos(2.0 * alpha - 1.0);
    
                    Eigen::Array3d d = sh::ToVector(phi, theta);
                    const Vector3f wi(d[0], d[1], d[2]);
    
                    double H = wi.dot(n);
                    if(H > 0.0) {
                        const auto ray = Ray3f(v, wi);
                        Intersection intersect;
                        bool is_inter = scene->rayIntersect(ray, intersect);
                        if(is_inter) {
                            for(int j = 0; j < SHCoeffLength; j++) {
                                const Vector3f coef3(
                                    m_TransportSHCoeffs.col((int)intersect.tri_index[0]).coeffRef(j),
                                    m_TransportSHCoeffs.col((int)intersect.tri_index[1]).coeffRef(j),
                                    m_TransportSHCoeffs.col((int)intersect.tri_index[2]).coeffRef(j)
                                );
                                coeff[j] += intersect.bary.dot(coef3) / m_SampleCount;
                            }
                        }
                    }
                }
            }
    
            for (int j = 0; j < SHCoeffLength; j++)
            {
                m_IndirectCoeffs.col(i).coeffRef(j) = coeff[j] - m_IndirectCoeffs.col(i).coeffRef(j);
            }
        }
        m_TransportSHCoeffs += m_IndirectCoeffs;
    }
    C#

    分析

    在计算有遮挡的阴影的基础上(直接光照),加上二次反射光(间接照明)的贡献。而二次反射的光线也可以再进行相同的步骤。对于间接光照的计算,使用球谐函数对这些反射光线的照明进行近似。如果考虑多次弹射,则使用 进行递归计算,终止条件可以是递归深度或光线强度低于某个阈值。下面就是文字的公式描述。

    简略代码与注释如下:

    // TODO: leave for bonus
    // 首先初始化球谐系数
    Eigen::MatrixXf m_IndirectCoeffs = Eigen::MatrixXf::Zero(SHCoeffLength, mesh->getVertexCount());
    // 采样侧边的大小 = 样本数量的平方根 // 这样我们在后面可以进行二维的采样
    int sample_side = static_cast<int>(floor(sqrt(m_SampleCount)));
    
    // 生成随机数,范围是 [0,1]
    ...
    std::uniform_real_distribution<> rng(0.0, 1.0);
    
    // 定义常量 2 \pi
    ...
    
    // 循环计算多次反射 (m_Bounce 次)
    for (int bo = 0; bo < m_Bounce; bo++) {
      // 对每个顶点做处理
      // 对于每个顶点,会做如下操作
      // - 获取该顶点的位置和法线 v n
      // - rng()获得随机的二维方向 alpha beta
      // - 如果wi在顶点法线的同一侧,则继续进行:
      // - 生成一条从顶点出发的射线,并检查这条射线是否与场景中的其他物体相交
      // - 如果有相交的物体,代码会使用相交处的信息和现有的球谐系数来更新该顶点的光线间接反射信息。
      for (int i = 0; i < mesh->getVertexCount(); i++) {
        const Point3f &v = mesh->getVertexPositions().col(i);
        const Normal3f &n = mesh->getVertexNormals().col(i);
        ...
        for (int t = 0; t < sample_side; t++) {
          for (int p = 0; p < sample_side; p++) {
            ...
            double H = wi.dot(n);
            if (H > 0.0) {
              // 这里就是公式中的 $$(1-V(w_i))$$ 如果不满足,这一轮循环就不累加
              bool is_inter = scene->rayIntersect(ray, intersect);
              if (is_inter) {
                for (int j = 0; j < SHCoeffLength; j++) {
                  ...
                  coeff[j] += intersect.bary.dot(coef3) / m_SampleCount;
                }
              }
            }
          }
        }
        // 对于每个顶点,会根据计算的反射信息更新其球谐系数。
        for (int j = 0; j < SHCoeffLength; j++) {
          m_IndirectCoeffs.col(i).coeffRef(j) = coeff[j] - m_IndirectCoeffs.col(i).coeffRef(j);
        }
      }
      m_TransportSHCoeffs += m_IndirectCoeffs;
    }
    C#

    在之前的步骤中,我们只是计算了每一个顶点的球谐函数,并不涉及到三角形中心的插值计算。但是在光线多次弹射的实现中,从顶点向正半球发射的光线会与顶点之外的位置相交,因此我们需要通过重心坐标插值计算获取发射光线与三角形内部的交点的信息,这就是 intersect.bary 的作用。

    结果

    观察一下,整体上没有太大差异,只是阴影的地方更加亮了。

    img

    环境光照球谐函数旋转(bonus 2)

    提高2。 低阶的球鞋光照的旋转可以使用「低阶SH快速旋转方法」。

    代码

    首先让Skybox转起来。 [0, 1, 0] 意味着绕y轴旋转。然后通过 getRotationPrecomputeL 函数计算旋转后的球谐函数。最后应用到 Mat3Value 。

    // WebGLRenderer.js
    let cameraModelMatrix = mat4.create();
    // Edit Start
    mat4.fromRotation(cameraModelMatrix, timer, [0, 1, 0]);
    // Edit End
    if (k == 'uMoveWithCamera') { // The rotation of the skybox
        gl.uniformMatrix4fv(
            this.meshes[i].shader.program.uniforms[k],
            false,
            cameraModelMatrix);
    }
    
    // Bonus - Fast Spherical Harmonic Rotation
    // Edit Start
    let precomputeL_RGBMat3 = getRotationPrecomputeL(precomputeL[guiParams.envmapId], cameraModelMatrix);
    Mat3Value = getMat3ValueFromRGB(precomputeL_RGBMat3);
    // Edit End
    C#

    接下来跳转到 tool.js ,编写 getRotationPrecomputeL 函数。

    // tools.js
    function getRotationPrecomputeL(precompute_L, rotationMatrix){
        let rotationMatrix_inverse = mat4.create()
        mat4.invert(rotationMatrix_inverse, rotationMatrix)
        let r = mat4Matrix2mathMatrix(rotationMatrix_inverse)
    
        let shRotateMatrix3x3 = computeSquareMatrix_3by3(r);
        let shRotateMatrix5x5 = computeSquareMatrix_5by5(r);
    
        let result = [];
        for(let i = 0; i < 9; i++){
            result[i] = [];
        }
        for(let i = 0; i < 3; i++){
            let L_SH_R_3 = math.multiply([precompute_L[1][i], precompute_L[2][i], precompute_L[3][i]], shRotateMatrix3x3);
            let L_SH_R_5 = math.multiply([precompute_L[4][i], precompute_L[5][i], precompute_L[6][i], precompute_L[7][i], precompute_L[8][i]], shRotateMatrix5x5);
    
            result[0][i] = precompute_L[0][i];
            result[1][i] = L_SH_R_3._data[0];
            result[2][i] = L_SH_R_3._data[1];
            result[3][i] = L_SH_R_3._data[2];
            result[4][i] = L_SH_R_5._data[0];
            result[5][i] = L_SH_R_5._data[1];
            result[6][i] = L_SH_R_5._data[2];
            result[7][i] = L_SH_R_5._data[3];
            result[8][i] = L_SH_R_5._data[4];
        }
    
        return result;
    }
    
    function computeSquareMatrix_3by3(rotationMatrix){ // 计算方阵SA(-1) 3*3 
    
        // 1、pick ni - {ni}
        let n1 = [1, 0, 0, 0]; let n2 = [0, 0, 1, 0]; let n3 = [0, 1, 0, 0];
    
        // 2、{P(ni)} - A  A_inverse
        let n1_sh = SHEval(n1[0], n1[1], n1[2], 3)
        let n2_sh = SHEval(n2[0], n2[1], n2[2], 3)
        let n3_sh = SHEval(n3[0], n3[1], n3[2], 3)
    
        let A = math.matrix(
        [
            [n1_sh[1], n2_sh[1], n3_sh[1]], 
            [n1_sh[2], n2_sh[2], n3_sh[2]], 
            [n1_sh[3], n2_sh[3], n3_sh[3]], 
        ]);
    
        let A_inverse = math.inv(A);
    
        // 3、用 R 旋转 ni - {R(ni)}
        let n1_r = math.multiply(rotationMatrix, n1);
        let n2_r = math.multiply(rotationMatrix, n2);
        let n3_r = math.multiply(rotationMatrix, n3);
    
        // 4、R(ni) SH投影 - S
        let n1_r_sh = SHEval(n1_r[0], n1_r[1], n1_r[2], 3)
        let n2_r_sh = SHEval(n2_r[0], n2_r[1], n2_r[2], 3)
        let n3_r_sh = SHEval(n3_r[0], n3_r[1], n3_r[2], 3)
    
        let S = math.matrix(
        [
            [n1_r_sh[1], n2_r_sh[1], n3_r_sh[1]], 
            [n1_r_sh[2], n2_r_sh[2], n3_r_sh[2]], 
            [n1_r_sh[3], n2_r_sh[3], n3_r_sh[3]], 
    
        ]);
    
        // 5、S*A_inverse
        return math.multiply(S, A_inverse)   
    
    }
    
    function computeSquareMatrix_5by5(rotationMatrix){ // 计算方阵SA(-1) 5*5
    
        // 1、pick ni - {ni}
        let k = 1 / math.sqrt(2);
        let n1 = [1, 0, 0, 0]; let n2 = [0, 0, 1, 0]; let n3 = [k, k, 0, 0]; 
        let n4 = [k, 0, k, 0]; let n5 = [0, k, k, 0];
    
        // 2、{P(ni)} - A  A_inverse
        let n1_sh = SHEval(n1[0], n1[1], n1[2], 3)
        let n2_sh = SHEval(n2[0], n2[1], n2[2], 3)
        let n3_sh = SHEval(n3[0], n3[1], n3[2], 3)
        let n4_sh = SHEval(n4[0], n4[1], n4[2], 3)
        let n5_sh = SHEval(n5[0], n5[1], n5[2], 3)
    
        let A = math.matrix(
        [
            [n1_sh[4], n2_sh[4], n3_sh[4], n4_sh[4], n5_sh[4]], 
            [n1_sh[5], n2_sh[5], n3_sh[5], n4_sh[5], n5_sh[5]], 
            [n1_sh[6], n2_sh[6], n3_sh[6], n4_sh[6], n5_sh[6]], 
            [n1_sh[7], n2_sh[7], n3_sh[7], n4_sh[7], n5_sh[7]], 
            [n1_sh[8], n2_sh[8], n3_sh[8], n4_sh[8], n5_sh[8]], 
        ]);
    
        let A_inverse = math.inv(A);
    
        // 3、用 R 旋转 ni - {R(ni)}
        let n1_r = math.multiply(rotationMatrix, n1);
        let n2_r = math.multiply(rotationMatrix, n2);
        let n3_r = math.multiply(rotationMatrix, n3);
        let n4_r = math.multiply(rotationMatrix, n4);
        let n5_r = math.multiply(rotationMatrix, n5);
    
        // 4、R(ni) SH投影 - S
        let n1_r_sh = SHEval(n1_r[0], n1_r[1], n1_r[2], 3)
        let n2_r_sh = SHEval(n2_r[0], n2_r[1], n2_r[2], 3)
        let n3_r_sh = SHEval(n3_r[0], n3_r[1], n3_r[2], 3)
        let n4_r_sh = SHEval(n4_r[0], n4_r[1], n4_r[2], 3)
        let n5_r_sh = SHEval(n5_r[0], n5_r[1], n5_r[2], 3)
    
        let S = math.matrix(
        [    
            [n1_r_sh[4], n2_r_sh[4], n3_r_sh[4], n4_r_sh[4], n5_r_sh[4]], 
            [n1_r_sh[5], n2_r_sh[5], n3_r_sh[5], n4_r_sh[5], n5_r_sh[5]], 
            [n1_r_sh[6], n2_r_sh[6], n3_r_sh[6], n4_r_sh[6], n5_r_sh[6]], 
            [n1_r_sh[7], n2_r_sh[7], n3_r_sh[7], n4_r_sh[7], n5_r_sh[7]], 
            [n1_r_sh[8], n2_r_sh[8], n3_r_sh[8], n4_r_sh[8], n5_r_sh[8]], 
        ]);
    
        // 5、S*A_inverse
        return math.multiply(S, A_inverse)  
    }
    
    function mat4Matrix2mathMatrix(rotationMatrix){
    
        let mathMatrix = [];
        for(let i = 0; i < 4; i++){
            let r = [];
            for(let j = 0; j < 4; j++){
                r.push(rotationMatrix[i*4+j]);
            }
            mathMatrix.push(r);
        }
        // Edit Start
        //return math.matrix(mathMatrix)
        return math.transpose(mathMatrix)
        // Edit End
    }
    function getMat3ValueFromRGB(precomputeL){
    
        let colorMat3 = [];
        for(var i = 0; i<3; i++){
            colorMat3[i] = mat3.fromValues( precomputeL[0][i], precomputeL[1][i], precomputeL[2][i],
                                            precomputeL[3][i], precomputeL[4][i], precomputeL[5][i],
                                            precomputeL[6][i], precomputeL[7][i], precomputeL[8][i] ); 
        }
        return colorMat3;
    }
    C#

    结果

    img

    动画GIF可以在此处 获取。

    原理

    两项关键性质

    首先简单说说原理,这里利用了球谐函数的两个性质。

    1. 旋转不变性

    在三维空间中旋转一个函数的坐标,并将这个旋转后的坐标代入球谐函数,那么你会得到与原始函数相同的结果。

    1. 旋转的线性性

    对于球谐函数的每一“层”或“带”(band)(也就是给定的阶数 l 的所有球谐函数),其SH系数可以被旋转,并且这个旋转是线性的。也就是说,可以通过一个矩阵乘法来旋转一个球谐函数展开的系数。

    Wigner D矩阵旋转方法概述

    球谐函数的旋转是一个深入的话题,这里直接概述,不涉及复杂的数学证明。 作业框架中给的是基于投影的方法,本文先介绍一个更精确的方法,Wigner D矩阵。 更加详细的内容请去看:球谐光照笔记(旋转篇) – 网易游戏雷火事业群的文章 – 知乎 ,反正我是没看懂QAQ。

    由于当前使用的是前三阶的球谐函数,并且 band0 只有一个投影系数,所以我们只需要处理band1, band2 两层上各自 , 的旋转矩阵 。

    球谐函数 的旋转可以表示为:

    其中, 是旋转矩阵元素,它给出了如何将球谐系数从原始方向旋转到新方向。

    假设有一个函数 ,它可以展开为球谐函数的线性组合 :

    如果想要旋转这个函数,我们不直接旋转每一个球谐函数,而是旋转它们的系数。新的展开系数 可以由原始系数 通过旋转矩阵得到 :

    接下来就到关键的一步了,如何计算旋转矩阵

    img

    在作业框架中,我们了解到,band 1需要构建一个 的矩阵,band 2需要构建 的矩阵。也就是说,对于每个阶数为 的 band,它都有 个合法的解,每个解对应当前 band 上的一个基函数,这是勒让德方程的一个特性。

    现在,我们来考虑旋转的影响。

    当我们旋转一个环境光照 ,我们不会去旋转基函数,而是“旋转”所有的系数。旋转一个特定的系数的过程涉及到使用Wigner D矩阵 。首先,当我们谈论旋转,我们通常指的是围绕某个轴的旋转,定义由欧拉角来指定。我们就为每一阶都计算一个边长是 的方阵 。

    一旦得到了每一阶对应的旋转矩阵,我们就可以轻松计算出“旋转”后的新系数:

    然而,计算Wigner D矩阵的元素可能会有些复杂,特别是对于较高的阶数。因此,作业提示中给出的是一种基于投影的方法。接下来我们看看上面两段代码是怎么实现的。

    投影的近似方法

    首先,选择 个 normal vector ,这个量的选取需要确保线性独立性,也就是尽可能均匀的覆盖球面(Fibonacci球面采样也许是个不错的选择),否则在后面会出现计算奇异的矩阵的错误,确保生成的矩阵是满秩的

    对于每一个normal vector ,在球谐函数上投影(SHEval函数),这实际上是在计算球谐函数与该方向上的点乘。从这个投影中,可以得到一个 维向量 ,它的每一个分量都是球谐函数的一个系数。

    使用上面得到的 向量,我们可以构建矩阵 和逆矩阵 。如果我们记 为 normal vector 在球谐函数上的第 个系数, 那么矩阵 可以写为:

    对于每一个normal vector , 应用旋转 , 得到 ,即(前乘):

    然后,对于这些旋转后的normal vectors, 再次进行球谐函数投影, 得到 。

    使用从旋转后的normal vectors得到的 向量, 我们可以构建矩阵S。计算旋转矩阵 : 旋转矩阵 可以告诉我们如何通过简单的矩阵乘法来旋转球谐系数。

    使用矩阵 乘以原始的球谐系数向量,我们可以得到旋转后的球谐系数。对每个 层重复: 为了得到完整的旋转后的球谐系数,我们需要对每个 层重复上述过程。

    Reference

    1. Games 202
    2. https://github.com/DrFlower/GAMES_101_202_Homework/tree/main/Homework_202/Assignment2
  • 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的问题

    不知道,晚点再写。

zh_CNCN