Featured image of post Hazel学习笔记:游戏功能完善(持续更新)

Hazel学习笔记:游戏功能完善(持续更新)

Hazel学习内容整理

Hazel学习笔记:游戏功能完善

由于Hazel剩下的内容非常多,我以前也没有接触过游戏、渲染部分,所以学起来比较吃力。这部分我将作为OpenGL、游戏相关知识的积累,沉淀一些个人理解,并持续更新。


ECS

ECS是游戏中常用的架构,他之所以流行,是因为:他把对象的存储和使用分开,在不影响易用性的前提下,保证了性能。

如果没有ECS,我们应该如何处理游戏对象?

假如我们现在有一个路灯和一个玩家,他们作为游戏世界的一员,首先需要有自己的位置信息,现在要让这个路灯和这个玩家都具备发光的能力,那么我们很容易能想到:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

class Transform
{
public:
    void SetPos(vec3 pos) {}
    vec3 GetPos() {}
private:
    vec3 pos;
};

class Light
{
public:
    void StartLight();
    void StopLight();
};

class StreetLight : Light, Transform
{
public:
    void StartLight() { /* ... */ }
};

class Player : Light, Transform
{
    public:
    void StartLight() { /* ... */ }
}

后来游戏逐渐更新,玩家的灯光逻辑变得越来越复杂…

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

class Light
{
public:
    void StartLight();
    void StopLight();

    void SetLightingDirection(int dir) {}
    void SetLightingIntensity(float intensity) {}
    void SetLightingColor() {}
    // ...
};

class StreetLight : Light, Transform
{
public:
    void StartLight() { /* ... */ }
};

class Player : Light, Transform
{
    public:
    void StartLight() { /* ... */ }
}

很容易就会让原本的路灯得到一些他根本用不上的属性,更不用说玩家可能还会有:声音、生命值、攻击能力…,玩家就会变得越来越臃肿,很不利于维护。

如果我们把“继承”变成“组合”呢?

组合的思路是把能力拆成组件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20

class StreetLight
{
public:
    void StartLight() { /* ... */ }
private:
    Transform transform;
    Light light;
    // ...
};

class Player
{
public:
    void StartLight() { /* ... */ }
private:
    Transform transform;
    Light light;
    // ...
}

好处显而易见:我们不用维护越来越复杂的继承树了,现在我们的属性能够随意拆解组装。PlayerStreetLight 可以共享组件。

但是对于我的游戏循环来说,这种方式仍然不直观,你可能需要在你的游戏循环中写一大堆if-else来维护你玩家的各种各样的能力:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void Update(Player& o, float dt) {
    if (o.velocity) {
        o.transform.x += o.velocity->vx * dt;
        o.transform.y += o.velocity->vy * dt;
    }
    if (o.sprite) {
        // render ...
    }
    // if (has AI) 
    // if (has Physics) ...
}

也许你会说,我不怕麻烦,我愿意写成百上千行的条件判断来处理我玩家越来越复杂的能力。

是的,我们当然可以这样,但是游戏其实对性能的要求很高,我们选择C++来做游戏底层开发就是因为他可以直接和操作系统硬件交互,中间层很少,那么对于这种越来越多的条件判断,尤其是对象越来越多的时候,我们每一帧都要对每一个对象的每一个功能做条件判断,这就对CPU的缓存越来越不友好了。

之前看到过有说,想让你的程序跑的更快,可以从以下三个方面入手优化:

  • 磁盘IO(或者网络带宽)
  • 算法优化
  • CPU缓存 显然我们现在正在游戏开发的初期,算法和磁盘我们都还不涉及,那么我们在设计之初就应该考虑考虑CPU缓存的问题。

现在我们存在的问题是:

  • Update 里全是条件分支。
  • 每个对象每帧都要跑一遍 if。
  • 很难并行。很难做批处理。

现代 CPU 快不快,关键看数据是不是连续。

普通 OOP 组合一般是“对象数组”,你可能会想到将所有的相同对象都放在一起,对象内部又指向一堆组件或资源。访问路径是跳来跳去的指针追踪。

当我访问其中某一个对象时,CPU会假设我未来会访问这个对象旁边的其他一些对象,就会顺手将他们全部加载进 CPU Cache 中,但是当我访问对象内部信息时,仍然会出现大量缓存未命中的情况。

ECS的核心:所有的数据当作组件存储,以逻辑为单位进行运行

ECS(Entity/Component/System):

  • E:一个游戏实体(可能是一个玩家、一个路灯),一般只是一个ID,不携带数据
  • C:纯数据,与实体无关
  • S:整合E和C,做逻辑处理
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19

// Entity
using Entity = uint32_t;

// Component
struct Transform { float x, y; };
struct Velocity  { float vx, vy; };

// System
struct MovementSystem {
    void Update(float dt,
                std::vector<Transform>& transforms,
                std::vector<Velocity>& velocities,
                const std::vector<Entity>& entities) {
        for (Entity e : entities) {
            // 这里假设能通过 e 定位到对应的 Transform/Velocity
        }
    }
};

通常ECS实现会将各个组件都放在一段连续的内存中,这样就能够:

  • 在更新帧时只遍历“有 Velocity 的实体”。没有无意义 if
  • 数据之间内存连续。CPU缓存命中率高
  • 同一系统的更新循环非常适合并行和 SIMD
  • 渲染系统可以自然做批处理。比如按材质、功能分组

简单实现可能像这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using Entity = uint32_t;

struct Transform { float x = 0, y = 0; };
struct Velocity  { float vx = 0, vy = 0; };

struct World {
    Entity nextId = 1;
    std::vector<Entity> entities;

    // 组件存储:按类型分开
    std::unordered_map<Entity, Transform> transforms;
    std::unordered_map<Entity, Velocity> velocities;

    Entity CreateEntity() {
        Entity e = nextId++;
        entities.push_back(e);
        return e;
    }

    bool HasTransform(Entity e) const { return transforms.find(e) != transforms.end(); }
    bool HasVelocity(Entity e) const  { return velocities.find(e) != velocities.end(); }
};

struct MovementSystem {
    void Update(World& w, float dt) {
        // 只遍历有 Velocity 的实体
        for (auto& [e, v] : w.velocities) {
            auto it = w.transforms.find(e);
            if (it == w.transforms.end()) continue;

            Transform& t = it->second;
            t.x += v.vx * dt;
            t.y += v.vy * dt;
        }
    }
};

这个仍然有很多优化的地方,比如 unordered_map 可能不连续(桶与桶之间)。但 ECS 的思路大致如上。

Shader

Shader实现屏幕水滴效果

以下为我代码中的雨天和屏幕水滴效果的开发记录,作为着色器的知识积累。


效果展示

想象你站在窗边,看着外面的雨。玻璃上布满了水滴,透过水滴看到的景象会有轻微的扭曲——这就是我们要用代码实现的效果。


实现过程

在图形渲染领域,许多实现方式都与我们的直觉相悖。例如:光线追踪中的"光线"实际上是从摄像机发出、逆向追踪到光源的;3D 游戏中那些看起来丝滑飘逸的布料,本质上是由成千上万的离散顶点模拟出来的;实时阴影并非通过物理遮挡计算,而是借助"从灯光视角拍摄的深度照片"来判断。

屏幕水滴效果同样如此。如果直接在画面上叠加一张水滴贴图,得到的只是"贴纸般的圆点"——缺乏真实感,效果单一。我们真正想要的,是那种站在雨天玻璃窗前的视觉体验:透过水滴看到的背景会产生微妙的扭曲和偏移。

这种效果源于折射——光线穿过水滴时会发生弯曲。然而在 GPU 着色器中,我们并没有真正模拟光线的物理路径,而是采用了一种巧妙的近似:直接偏移纹理的采样坐标(UV)。当一个像素位于"水滴区域"内时,我们让它去采样稍微偏移后的背景位置,视觉上就产生了"透过水滴看东西"的折射效果。

这正是渲染吸引我的魅力所在:很多的特效背后的实现逻辑总是那么的出乎意料。

UV 扭曲,偏移纹理坐标

在着色器中,我们用 UV 坐标 来决定"从背景图的哪个位置取颜色"。但是,从背景图的哪个位置取?取多少?如何确定需要扭曲的范围?这都是我们需要计算的。

网格法确定水滴位置
2.1 如何确定水滴位置?

假设我们想在屏幕上放 100 个水滴,最直接的想法是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 存储每个水滴的位置
vec2 dropPositions[100] = { 
    vec2(0.1, 0.2), 
    vec2(0.3, 0.7), 
    ... 
};

// 在着色器中,对每个像素都要检查所有水滴
for (int i = 0; i < 100; i++) {
    if (distance(currentPixel, dropPositions[i]) < 0.05) {
        // 这个像素在水滴范围内
    }
}

// 在CPU端控制水滴位置动态变化...

如果此时水滴数量增多,那么每次就需要向GPU中传送大量的数据,并且是每一帧都传!效率大大降低了,那有没有办法将水滴的位置控制放在计算性能更高的GPU端呢?

2.2 解决方案:网格 + 伪随机

核心:通过把屏幕分成很多个格子,让每个格子内"自动生成“一个“位置随机”水滴。

我们可以将uv坐标按照坐标划分网格,有一种快速划分网格的方法:

  • 将原本在[0, 1]范围内的uv坐标转化成[10, 10]的坐标(即:放大原始坐标值)
  • 将新坐标的整数部分作为格子的ID,将小数部分作为新坐标在该格子内的坐标

代码实现

1
2
3
uv *= vec2(10.0, 2.5);   // 放大,创建 10×2.5 的网格
vec2 cellId = floor(uv);      // 格子 ID(floor,取整数部分)
vec2 localPos = fract(uv) - 0.5; // 格子内坐标(-0.5 ~ 0.5,fract,取小数部分)

比如说:一个原始uv坐标为(0.23, 0.4)的像素,在经过换算后会得到一个在格子[2, 1],格子内偏移量(0.3, -0.2)的结果。

2.3 伪随机函数

现在每个格子需要一个"独特的水滴位置”,我们用 伪随机函数

1
2
3
4
5
6
float Hash21(vec2 coord)  // 返回 [0, 1)
{
    coord = fract(coord * vec2(123.34, 345.45));
    coord += dot(coord, coord + 34.345); // dot: 返回向量点积的结果
    return fract(coord.x * coord.y);
}
1
2
float cellRandom = Hash21(cellId + seed);     // 用格子 ID 查询
float dropX = cellRandom - 0.5;     		 // 水滴在格子内的 X 偏移 (-0.5 ~ 0.5)

这样做的好处是:对于同一个格子,相同的种子来说,得到的随机数一定相同,可以作为水滴下滑的初始位置,并保证水滴在相同格子滑落时能够竖直向下(格子ID不变时,x偏移量不变)。


水滴动画
3.1 垂直滚动

最简单的动画:让整个 UV 向上滚动,视觉上水滴就会向下流。

1
uv.y += u_Time * 0.05;  // 随时间向上偏移

原理:让同一时刻的同一位置的uv能够处在不同的“方格”,从而达到“滚动”的错觉。

3.2 锯齿动画

但只是匀速下落太单调了。真实的水滴会:

  1. 积累一会儿
  2. 突然滑落
  3. 再积累…

我们用一个特殊的 锯齿波函数

1
2
3
4
float SawtoothWave(float x)
{
    return (cos(x) + cos(x * 3.0) * 0.5 + sin(x * 5.0) * 0.1) * 0.4 + 0.5;
}
锯齿波形

代码中的使用

1
2
float animPhase = fract(time + cellRandom);  		// 每个水滴的动画起始位置不同
float dropY = (SawtoothWave(animPhase) - 0.5) * 0.9; // 垂直位置随时间变化

通过在animPhase中添加cellRandom变量来控制同一时刻,不同格子内的水滴位置不会相同,形成“错落”的感觉。

水滴形状绘制
4.1 主水滴

一个水滴首先要有"形状"。我们用 距离场 (SDF)来定义:

1
2
3
vec2 dropCenter = vec2(dropX, dropY);         // 水滴中心位置
float distToDrop = length(localPos - dropCenter);     // 当前像素到中心的距离
float mainDrop = smoothstep(0.2, 0.0, distToDrop);    // 距离 < 0.2 的区域是水滴

**smoothstep(0.2, 0.0, d) **能够在像素距离中心[0, 0.2]的时候返回[0, 1]

4.2 水滴尾迹

真实的雨滴不只是一个圆点,下落时会拖出一条"尾巴"

尾迹的实现分为三步:

  • 第一步:限制在水滴下方

    1
    
    float trailMask = smoothstep(0.0, 0.2, localPos.y - dropCenter.y);
    
  • 第二步:限制尾迹长度

    1
    
    trailMask *= smoothstep(0.5, 0.0, localPos.y - dropCenter.y);
    
  • 第三步:限制尾迹宽度

    1
    
    trailMask *= smoothstep(0.05, 0.03, abs(localPos.x - dropCenter.x));
    

你可能会问:为什么三个条件之间是用 *= 而不是 += 或别的进行连接?

因为乘法是实现逻辑“或”的一种方式,每个 smoothstep 返回的是 0 ~ 1 之间的值:

  • 返回 1 = 这个条件完全满足
  • 返回 0 = 这个条件完全不满足
  • 返回 0.5 = 这个条件部分满足

最终就能实现:只要有一个条件不满足,则整体条件都不满足的效果。

  • 第四步:绘制尾迹

    1
    2
    3
    
    float trailDist = abs(localPos.x - dropCenter.x);  // 到尾迹中心线的距离
    float dropTrail = smoothstep(0.1, 0.02, trailDist);
    dropTrail *= trailMask;  // 应用三个限制条件
    
折射!

这是整个效果的最关键一步

1
2
3
vec2 offsetDirection = localPos - dropCenter;
float offsetStrength = mainDrop + dropTrail * 0.5;
uvOffset = offsetStrength * offsetDirection;

这行代码到底在做什么?为什么一行就能实现折射?

让我们把它拆成三个部分:


offsetDirection — 偏移方向

1
vec2 offsetDirection = localPos - dropCenter;  // 从水滴中心指向当前像素的向量

真实水滴像一个凸透镜,光线会向中心聚焦。我们用 localPos - dropCenter 作为偏移方向,最终就会将远处相应方向的像素偏移至当前像素位置,就是在模拟"从边缘向中心弯曲"的折射效果。


mainDrop + dropTrail * 0.5 — 偏移强度

1
float offsetStrength = mainDrop + dropTrail * 0.5;  // 0 ~ 1 之间的遮罩

这是一个遮罩值,决定了"这个像素需要多大的偏移",偏移强度随当前像素到水滴中心和尾部的距离来决定,尾部占比会小一点,因为水滴滑落时尾部水量更少。


**A × B **

1
uvOffset = offsetStrength * offsetDirection;

向量乘以标量 = 保持方向,改变长度。


多层折射叠加

只用一层水滴会很单调——所有水滴大小相同、间距均匀。

1
2
3
4
5
6
7
8
9
vec2 CalculateRaindropsOffset(vec2 uv, float intensity)
{
    vec2 totalOffset = vec2(0.0);
    totalOffset += GetDrops(uv, 1.0, intensity);
    totalOffset += GetDrops(uv * 1.4 + 7.23, 1.25, intensity);
    totalOffset += GetDrops(uv * 2.1 + 1.17, 1.5, intensity) * 0.5;

    return totalOffset * 0.03 * intensity;
}

我们对每一次偏移设置不同的seed值,不同的位置,从而最终得到不同大小,不同位置的“水滴”,让水滴变得更密集,更有随机性。

随后再进行纹理采样,安全性检查,就得到了开头的水滴效果:

1
2
3
4
5
vec2 offset = CalculateRaindropsOffset(uv, u_RaindropsIntensity);
uv = clamp(uv + offset, vec2(0.0), vec2(1.0));

vec4 color = texture(u_ScreenTexture, uv);
o_Color = color;  // 最终颜色

总结

现在的效果还比较简陋,后面可以继续进行拓展,同时我的效果和原始的效果相比还有很大差距。 参考代码:https://www.shadertoy.com/view/MdfBRX

完整GLSL:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#version 450 core

in vec2 v_TexCoord;
out vec4 o_Color;

uniform sampler2D u_ScreenTexture;
uniform int u_RaindropsEnabled;
uniform float u_RaindropsIntensity;
uniform float u_RaindropsTime;

float Hash21(vec2 coord)
{
    coord = fract(coord * vec2(123.34, 345.45));
    coord += dot(coord, coord + 34.345);
    return fract(coord.x * coord.y);
}

float SawtoothWave(float x)
{
    return (cos(x) + cos(x * 3.0) * 0.5 + sin(x * 5.0) * 0.1) * 0.4 + 0.5;
}

vec2 GetDrops(vec2 uv, float seed, float intensity)
{
    float time = u_RaindropsTime + intensity * 30.0;
    vec2 uvOffset = vec2(0.0);

    uv.y += time * 0.05;

    uv *= vec2(10.0, 2.5) * seed;
    vec2 cellId = floor(uv);
    vec2 localPos = fract(uv) - 0.5;

    float cellRandom = Hash21(cellId + seed);
    float dropX = cellRandom - 0.5;
    
    float animPhase = fract(time + cellRandom);
    float dropY = (SawtoothWave(animPhase) - 0.5) * 0.9;

    vec2 dropCenter = vec2(dropX, dropY);
    
    float distToDrop = length(localPos - dropCenter);

    float mainDrop = smoothstep(0.2, 0.0, distToDrop);

    float trailMask = smoothstep(0.0, 0.2, localPos.y - dropCenter.y);
    trailMask *= smoothstep(0.5, 0.0, localPos.y - dropCenter.y);
    trailMask *= smoothstep(0.05, 0.03, abs(localPos.x - dropCenter.x));

    float trailDist = abs(localPos.x - dropCenter.x);
    float dropTrail = smoothstep(0.1, 0.02, trailDist);
    dropTrail *= trailMask;conditions (AND logic via multiplication);

    vec2 offsetDirection = localPos - dropCenter;
    float offsetStrength = mainDrop + dropTrail * 0.5;
    uvOffset = offsetStrength * offsetDirection;

    return uvOffset;
}

vec2 CalculateRaindropsOffset(vec2 uv, float intensity)
{
    vec2 totalOffset = vec2(0.0);

    totalOffset += GetDrops(uv, 1.0, intensity);
    totalOffset += GetDrops(uv * 1.4 + 7.23, 1.25, intensity);
    totalOffset += GetDrops(uv * 2.1 + 1.17, 1.5, intensity) * 0.5;

    return totalOffset * 0.03 * intensity;
}

void main()
{
    vec2 uv = v_TexCoord;

    if (u_RaindropsEnabled != 0 && u_RaindropsIntensity > 0.0)
    {
        vec2 offset = CalculateRaindropsOffset(uv, u_RaindropsIntensity);
        uv = clamp(uv + offset, vec2(0.0), vec2(1.0));
    }

    vec4 color = texture(u_ScreenTexture, uv);
    o_Color = color;
}

2D锥形光照实现

效果


实现思路

都需要哪些数据?

如果想要实现一个类似于手电筒的效果,如果只渲染一个简单的三角形那是远远不够的,观察日常生活中的手电筒效果,随着照射距离增加,光照强度会逐渐降低,对于手电筒光照中心,应该是最亮的,随后向锥体两边逐渐变暗,所以我们至少需要:

  • 两个锥体,也就是两个角度
  • 光照强度(最远能照射到的范围)
  • 光线的衰减速率

所以我们可以先确定光照需要的所有属性:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
enum class Light2DType : uint8_t
{
    Spot
};

struct Light2D
{
    Light2DType type = Light2DType::Spot;
    glm::vec2 position = { 0.0f, 0.0f };    // 世界坐标位置
    glm::vec3 color = { 1.0f, 1.0f, 1.0f }; // RGB 颜色
    float intensity = 1.0f;                  // 强度 0~∞
    float radius = 5.0f;                     // 光照半径(光照最远距离)
    float falloff = 2.0f;                    // 控制光线渐变速度

    float direction = 0.0f;                  // 朝向角度(弧度)
    float innerAngle = 0.3f;                 // 内锥角(完全亮)
    float outerAngle = 0.6f;                 // 外锥角(渐变到 0)

    bool enabled = true;                     // 是否启用
};

距离衰减实现
1
2
3
4
float dist = length(currentPos - lightPos);  // 计算当前像素到光源的距离
float normalizedDist = dist / lightRadius;  // 归一化

float attenuation = 1.0 - pow(clamp(normalizedDist, 0.0, 1.0), falloff);  // 衰减公式

这里使用1-pow(normalizedDist, falloff)来做到中心值最大,越靠外强度越小:

1-x2

锥形区域
3.1 角度计算
1
2
3
4
5
6
7
8
9
// 从光源指向当前像素的向量
vec2 toLight = currentPos - lightPos;
// 计算角度(弧度,-π 到 +π)
float angle = atan(toLight.y, toLight.x);
// 计算与光源朝向的角度差
float angleDiff = abs(angle - lightDirection);
// 处理角度回绕(例如 -170° 和 +170° 实际只差 20°)
if (angleDiff > PI)
    angleDiff = 2.0 * PI - angleDiff;
3.2 锥形衰减
1
2
3
4
5
// 内锥角内:完全明亮
// 外锥角外:完全黑暗
// 内外之间:平滑过渡
float spotFactor = 1.0 - smoothstep(innerAngle, outerAngle, angleDiff);
attenuation *= spotFactor;  // 结合之前的距离衰减
完整的聚光灯 Shader
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#version 450 core

in vec2 v_TexCoord;
out vec4 o_Color;

uniform vec2 u_LightPosition;   // 屏幕空间 [0, 1]
uniform vec3 u_LightColor;
uniform float u_LightRadius;    // 屏幕空间半径
uniform float u_LightIntensity;
uniform float u_LightFalloff;
uniform float u_AspectRatio;

// 聚光灯参数
uniform int u_IsSpotLight;
uniform float u_LightDirection; // 弧度
uniform float u_InnerAngle;
uniform float u_OuterAngle;

const float PI = 3.14159265359;

void main()
{
    vec2 uv = v_TexCoord;
    
    // 修正宽高比
    vec2 correctedUV = uv;
    correctedUV.x *= u_AspectRatio;
    vec2 correctedLightPos = u_LightPosition;
    correctedLightPos.x *= u_AspectRatio;
    
    // 计算到光源的距离
    vec2 toLight = correctedUV - correctedLightPos;
    float dist = length(toLight);
    
    // 距离衰减
    float normalizedDist = dist / (u_LightRadius * u_AspectRatio);
    float attenuation = 1.0 - pow(clamp(normalizedDist, 0.0, 1.0), u_LightFalloff);
    attenuation = clamp(attenuation, 0.0, 1.0);
    
    // 聚光灯角度衰减
    float angle = atan(toLight.y, toLight.x);
    float angleDiff = abs(angle - u_LightDirection);

    // 处理角度回绕
    if (angleDiff > PI)
        angleDiff = 2.0 * PI - angleDiff;

    float spotFactor = 1.0 - smoothstep(u_InnerAngle, u_OuterAngle, angleDiff);
    attenuation *= spotFactor;
    
    vec3 finalColor = u_LightColor * u_LightIntensity * attenuation;
    o_Color = vec4(finalColor, 1.0);
}

其他问题

如何处理光源叠加?

场景中可能有多个光源——主角的手电筒、墙上的火把、地上的蜡烛… 最直接的想法是把所有光照效果相加

1
光源A + 光源B + 光源C = 最终光照

在 OpenGL 中实现:

1
2
glEnable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ONE);  // 源 × 1 + 目标 × 1
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
void OpenGLLightingPass::BeginLightMap(const LightingConfig& config)
{
    m_lightMapFBO->Bind();

    // 用环境光颜色清空光照图
    glm::vec3 ambient = config.ambientColor * config.ambientIntensity;
    glClearColor(ambient.r, ambient.g, ambient.b, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);

    // 启用加法混合
    glEnable(GL_BLEND);
    glBlendFunc(GL_ONE, GL_ONE);
}

void OpenGLLightingPass::EndLightMap()
{
    // 恢复标准 Alpha 混合
    glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
    m_lightMapFBO->Unbind();
}

Framebuffer 渲染管线

渲染代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
void LightLayer::OnUpdate(Yuicy::Timestep ts)
{
	// 获取鼠标位置(屏幕坐标)
	auto [mouseX, mouseY] = Yuicy::Input::GetMousePosition();
	// 归一化、Y轴翻转
	glm::vec2 mouseUV = {mouseX / viewportSize.x, 1.0f - (mouseY / viewportSize.y)};
	
	// 假设灯光射出位置为屏幕中心
	glm::vec2 lightPos = { 0.5f, 0.5f };
	
	// 灯光照射角度
	glm::vec2 dir = mouseUV - lightPos;
	lightDirection = std::atan2(dir.y, dir.x);

	// 场景渲染
	sceneFbo->Bind();
	Yuicy::RenderCommand::SetClearColor({ 0.1f, 0.1f, 0.1f, 1.0f });
	Yuicy::RenderCommand::Clear();
	// Render...
	sceneFbo->Unbind();

	// 灯光渲染
	lightFbo->Bind();
	
	// 清屏
	glm::vec3 ambient = ambientColor * ambientIntensity;
	Yuicy::RenderCommand::SetClearColor({ ambient.r, ambient.g, ambient.b, 1.0f });
	Yuicy::RenderCommand::Clear();

	if (lightEnabled)
	{
		// 混合模式处理灯光叠加
		glEnable(GL_BLEND);
		glBlendFunc(GL_ONE, GL_ONE);

		lightShader->Bind();
		
		lightShader->SetFloat2("u_LightPosition", { 0.5f, 0.5f });
		lightShader->SetFloat3("u_LightColor", lightColor);
		lightShader->SetFloat("u_LightRadius", lightRadius);
		lightShader->SetFloat("u_LightIntensity", lightIntensity);
		lightShader->SetFloat("u_LightFalloff", lightFalloff);
		lightShader->SetFloat("u_AspectRatio", viewportSize.x / viewportSize.y);
		lightShader->SetFloat("u_LightDirection", lightDirection);
		lightShader->SetFloat("u_InnerAngle", innerAngle);
		lightShader->SetFloat("u_OuterAngle", outerAngle);
        
		quadVao->Bind();
		Yuicy::RenderCommand::DrawIndexed(quadVao);
        
		glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
	}

	lightFbo->Unbind();

	// 场景混合着色器
	Yuicy::RenderCommand::SetClearColor({ 0.0f, 0.0f, 0.0f, 1.0f });
	Yuicy::RenderCommand::Clear();

	compositeShader->Bind();

	// 绑定场景纹理
	uint32_t sceneTexId = sceneFbo->GetColorAttachmentRendererID();
    // glBindTextureUnit(0, sceneTexId);
	glActiveTexture(GL_TEXTURE0);
	glBindTexture(GL_TEXTURE_2D, sceneTexId);
	compositeShader->SetInt("u_SceneTexture", 0);

	// 绑定灯光纹理
	uint32_t lightTexId = lightFbo->GetColorAttachmentRendererID();
    // glBindTextureUnit(1, lightTexId);
	glActiveTexture(GL_TEXTURE1);
	glBindTexture(GL_TEXTURE_2D, lightTexId);
	compositeShader->SetInt("u_LightMap", 1);

	compositeShader->SetInt("u_LightingEnabled", lightEnabled ? 1 : 0);

	quadVao->Bind();
	Yuicy::RenderCommand::DrawIndexed(quadVao);
}

要在后处理中加入光照,只需要将光照计算的结果与其他处理效果相乘:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
uniform sampler2D u_ScreenTexture;  // 场景纹理
uniform sampler2D u_LightMap;       // 光照图
uniform int u_LightingEnabled;

void main()
{
    vec4 sceneColor = texture(u_ScreenTexture, v_TexCoord);
    if (u_LightingEnabled != 0)
    {
        vec3 lightColor = texture(u_LightMap, v_TexCoord).rgb;
        sceneColor.rgb *= lightColor;
    }
    o_Color = sceneColor;
}

您的浏览器不支持 SVG
Licensed under CC BY-NC-SA 4.0
最后更新于 Jan 27, 2026 23:00 CST
comments powered by Disqus

本博客已稳定运行 小时 分钟
共发表 7 篇文章 · 总计 27.20 k 字
本站总访问量