游戏技术博客

专注于游戏引擎、渲染和算法

我将详细解析这段代码中的 BVH(Bounding Volume Hierarchy)加速结构 的构建过程,结合图例说明其结构和构建方式。

一、BVH 结构概览

1. BVH 节点结构(BVHNode)

struct BVHNode {
    glm::vec3 min;      // 包围盒最小值
    float pad0;         // 填充
    glm::vec3 max;      // 包围盒最大值
    float pad1;         // 填充
    int leftChild = -1; // 左子节点索引
    int firstPrim = 0;  // 图元起始索引(叶子节点)或右子节点索引(内部节点)
    int primCount = 0;  // 图元数量(叶子节点)
    int pad2;           // 填充
};

设计特点: - 使用 紧凑的 AABB(轴对齐包围盒) 表示 - 支持 32字节对齐(便于 GPU 访问) - 复用字段:firstPrim 在叶子节点中存储图元索引,在内部节点中存储右子节点索引

2. 构建数据结构

struct BuildPrimitive {
    AABB bounds;        // 三角形包围盒
    glm::vec3 center;   // 三角形中心点
    int triangleIndex;  // 对应三角形索引
    int pad0;
};

二、BVH 构建流程

步骤 1:数据准备

graph TD
    A[输入网格 Mesh] --> B[提取三角形]
    B --> C[应用变换矩阵]
    C --> D[创建 BuildPrimitive 数组]
    D --> E[每个三角形计算包围盒和中心]

步骤 2:递归构建(核心算法)

int BuildRecursive(std::vector<BuildPrimitive>& prims, int start, int end, int depth)

构建流程图:

graph TD
    A[开始 prims【start:end】] --> B{图元数量 ≤ 2?}
    B -->|是| C[创建叶子节点]
    B -->|否| D[计算包围盒和分割轴]
    D --> E[分割图元数组]
    E --> F[递归构建左子树]
    E --> G[递归构建右子树]
    F --> H[合并子节点包围盒]
    G --> H
    C --> I[计算叶子包围盒]
    H --> J[返回节点索引]
    I --> J
    
    subgraph "叶子节点"
        C -->|设置| K[primCount = 图元数]
        C -->|设置| L[firstPrim = start]
        C -->|设置| M[leftChild = -1]
    end
    
    subgraph "内部节点"
        H -->|设置| N[primCount = 0]
        H -->|设置| O[firstPrim = 右子节点索引]
        H -->|设置| P[leftChild = 左子节点索引]
    end

步骤 3:图元重排(关键优化)

// 在 Build() 函数中,构建完成后:
std::vector<Triangle> sortedTriangles;
for (const auto& prim : prims) {
    sortedTriangles.push_back(triangles[prim.triangleIndex]);
}
triangles = std::move(sortedTriangles);

为什么需要重排? - 构建过程中 prims 数组被 std::partition 重新排列 - 重排后,同一叶子节点内的三角形在内存中连续存储 - 显著提高 缓存命中率,提升遍历性能

三、BVH 树结构示例

假设有 8 个三角形,构建后的 BVH 树可能如下:

Level 0:                     [根节点 0]
                            /          \
Level 1:           [内部节点 1]      [内部节点 2]
                   /         \        /         \
Level 2:    [叶子节点 3] [叶子节点 4] [叶子节点 5] [叶子节点 6]
            (三角形0-1)   (三角形2-3) (三角形4-5) (三角形6-7)

内存布局

nodes 数组: [节点0, 节点1, 节点2, 节点3, 节点4, 节点5, 节点6]
triangles 数组: [三角形0, 三角形1, 三角形2, 三角形3, 三角形4, 三角形5, 三角形6, 三角形7]
                ↑叶子3的三角形↑     ↑叶子4的三角形↑     ↑叶子5的三角形↑     ↑叶子6的三角形↑

四、分割策略(SplitNode 函数)

分割流程:

graph LR
    A[开始] --> B[计算所有图元中心的包围盒]
    B --> C{选择最长轴 XYZ}
    C --> D[计算分割点 = 中点]
    D --> E[使用 std::partition 分割]
    E --> F{分割是否平衡?}
    F -->|是| G[返回成功]
    F -->|否| H[强制中点分割]
    H --> G

分割准则: 1. 轴选择:选择图元中心点分布最分散的轴(X、Y 或 Z) 2. 分割点:选择该轴的中点 3. 平衡检查:确保两边都有图元,否则强制平分

五、遍历算法(IntersectRecursive)

bool IntersectRecursive(int nodeIndex, const Ray& ray, float tMin, float tMax, HitRecord& rec) const

遍历流程:

graph TD
    A[开始遍历] --> B[获取当前节点]
    B --> C{与节点AABB相交?}
    C -->|否| D[返回 false]
    C -->|是| E{是否为叶子节点?}
    
    E -->|是| F[遍历所有三角形]
    F --> G{有命中?}
    G -->|是| H[更新最近命中]
    G -->|否| I[继续]
    
    E -->|否| J[递归遍历左子树]
    J --> K[递归遍历右子树]
    K --> L{比较左右命中结果}
    L --> M[返回最近命中]

六、GPU 友好设计

1. 数据结构对齐

#pragma pack(push, 16)  // 强制 16 字节对齐
struct BVHNode { ... };
struct Triangle { ... };
struct GPUMaterial { ... };
#pragma pack(pop)

2. 内存布局优化

  • 节点数组:连续存储,索引访问
  • 三角形数组:按 BVH 顺序重排,提高缓存一致性
  • 材质数组:独立存储,支持纹理索引

七、性能优化要点

1. 构建阶段优化

  • 使用 std::partition 快速分割
  • 限制叶子节点大小(≤ 2 个三角形)
  • 记录最大深度用于分析

2. 遍历阶段优化

  • 早期退出:不相交的节点立即跳过
  • 最近命中更新:维护当前最近命中距离 tMax
  • 堆栈优化:递归实现简单,但可以考虑迭代+栈优化

3. 内存优化

  • 紧凑存储:32字节/节点,16字节/三角形
  • 重排数据:提高空间局部性
  • 填充字段:确保 GPU 内存对齐

八、混合渲染应用

RayTracer::RenderShadows()RenderReflections() 中:

// 共享相同的 BVH 数据
bvhNodesBuffer.Bind(4);     // BVH 节点缓冲区
trianglesBuffer.Bind(5);    // 三角形缓冲区
materialsBuffer.Bind(6);    // 材质缓冲区

优势: - 一次构建,多次使用 - 支持阴影光线、反射光线、主光线等不同查询类型 - GPU 计算着色器直接访问结构化数据

总结

这个 BVH 实现采用了 自顶向下递归构建 + SAH(Surface Area Heuristic)近似分割 的策略。虽然代码中使用了简单的 中点分割(而非完整的 SAH),但通过图元重排、紧凑存储和 GPU 友好设计,实现了高效的射线相交加速。

关键创新点: 1. 字段复用设计firstPrim 在叶子/内部节点的不同用途 2. 构建后重排:优化内存访问模式 3. GPU 对齐布局:便于计算着色器直接访问 4. 混合渲染共享:同一 BVH 支持多种光线追踪任务

这是一个实用的、面向实时渲染的 BVH 实现,平衡了构建速度、遍历效率和内存使用。

HybridPBR 光线追踪着色器优化——基础篇

在实时渲染与离线渲染的边界日益模糊的今天,在 GPU 上实现一个高效的路径追踪器(Path Tracer)已经成为图形学爱好者的必修课。本文将基于一段核心的 GLSL 代码,剖析一个简易但功能完备的 PBR 路径追踪器的实现细节。我们将探讨如何利用蒙特卡洛积分、重要性采样以及"俄罗斯轮盘赌"等技术,在有限的计算资源下画出逼真的光影。

一、 随机数的艺术:Wang Hash

在 GPU 这种大规模并行计算环境中,生成高质量的伪随机数是蒙特卡洛积分的基石。传统的 rand() 函数在 Shader 中不可用,我们需要一个确定性雪崩效应(Avalanche Effect)极好的哈希函数。

核心代码

uint WangHash(uint seed) {
    seed = (seed ^ 61) ^ (seed >> 16);
    seed *= 9;
    seed = seed ^ (seed >> 4);
    seed *= 0x27d4eb2d;
    seed = seed ^ (seed >> 15);
    return seed;
}

为什么选择 Wang Hash? 1. 速度快:全是位运算和乘法,没有昂贵的三角函数或开方。 2. 雪崩效应:输入的微小变化(如像素坐标 x, y 的差异)会导致输出产生巨大的、不可预测的变化,这对于消除图像中的波纹(Pattern)至关重要。 3. 状态无关:它是一个纯函数,不需要维护全局状态,非常适合并行计算。

配合 RandomFloat 将 uint 映射到 [0, 1] 区间,我们就有了构建整个随机世界的基石。


二、 物理渲染的核心:Cook-Torrance BRDF

为了让材质看起来真实,我们采用了标准的 Cook-Torrance 微表面模型。它由三部分组成:法线分布函数(D)、几何遮蔽函数(G)和菲涅尔方程(F)。

1. 法线分布 (NDF) - GGX

描述微表面法线的朝向分布。表面越粗糙,法线越乱,高光越散。

float DistributionGGX(vec3 N, vec3 H, float roughness) {
    float a = roughness * roughness;
    float a2 = a * a;
    float NdotH = max(dot(N, H), 0.0);
    float NdotH2 = NdotH * NdotH;
    float num = a2;
    float denom = (NdotH2 * (a2 - 1.0) + 1.0);
    return num / (PI * denom * denom + 1e-6);
}

2. 几何遮蔽 (Geometry) - Smith

描述微表面之间相互遮挡(Shadowing)和掩蔽(Masking)的概率。

float GeometrySchlickGGX(float NdotV, float roughness) {
    float r = (roughness + 1.0);
    float k = (r * r) * 0.125; 
    float num = NdotV;
    float denom = NdotV * (1.0 - k) + k;
    return num / (denom + 1e-6);
}

3. 菲涅尔 (Fresnel) - Schlick近似

描述反射率随视角的变化。掠射角(视角与法线垂直)时反射率趋近于 1。

vec3 FresnelSchlick(float cosTheta, vec3 F0) {
    return F0 + (1.0 - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}

三、 直接光照优化:随机光源采样

这是路径追踪中最关键的优化之一。如果我们场景里有 100 个光源,每一帧对每个像素都循环计算 100 次光照简直是灾难。

蒙特卡洛策略: 我们只随机挑选 1 个光源进行计算,但将结果乘以光源总数(lightCount)。

数学原理

直接光照积分原本是求和: 现在的估计量是: 如果均匀采样,选到任意一个灯的概率

代码解析

// 1. 随机挑一个
int lightIndex = int(RandomFloat(seed) * float(lightCount));
// ... 计算 L, attenuation, shadow ...

// 2. 计算光照贡献 Lo
vec3 Lo = (kD * surf.albedo * INV_PI + specular) * radiance * NdotL;

// 3. 权重补偿:期望正确
return Lo * float(lightCount);

通俗解释:就像请客吃饭。为了省事(省算力),我只随机请 一个 光源吃饭,但在结账时,我按 所有人头(lightCount) 实行 AA 制。虽然单次看账单波动很大(有时抽到大灯,有时抽到没电的灯),但在时间积累下,期望账单和请所有人吃饭是一模一样的。


四、 路径追踪主循环 (TracePath)

这是渲染器的引擎。它不再使用递归(GLSL 对递归支持很差),而是使用 for 循环进行迭代。

核心流程图

  1. 射线求交:没打中物体 -> 返回天空色。
  2. 直接光采样 (NEE):主动连接光源,计算直接照明。
  3. 俄罗斯轮盘赌:决定光线是否“死亡”。
  4. BSDF 采样:计算下一条光线的反弹方向。

关键技术点

1. 俄罗斯轮盘赌 (Russian Roulette)

随着光线不断弹射,能量 throughput 会越来越小。继续计算微弱的光线对画面贡献很低,却浪费算力。

float p = max(max(throughput.r, throughput.g), throughput.b);
if (RandomFloat(seed) > p) break; // 概率性死亡
throughput /= p; // 生存下来的光线能量加倍补偿,保证无偏
这保证了路径长度是有限的,但数学期望上能量不会损失。

2. 下一跳方向的选择

代码根据菲涅尔项(F)动态决定光线是发生镜面反射还是漫反射

  • 镜面概率:取决于材质的金属度和当前的菲涅尔反射率。
  • 漫反射:使用余弦加权采样(Cosine Weighted Sampling),即 normalize(N + RandomSphere)
  • 镜面:使用重要性采样,朝反射方向附近抖动(粗糙度决定抖动范围)。
if (RandomFloat(seed) < specProb) {
    // 镜面反射分支:完美反射方向 + 粗糙度扰动
    vec3 reflectDir = reflect(-V, shadingNormal);
    nextDir = normalize(reflectDir + RandomInUnitSphere(seed) * surf.roughness);
    throughput *= F; // 能量乘以菲涅尔项
} else {
    // 漫反射分支
    nextDir = normalize(shadingNormal + RandomInUnitSphere(seed));
    throughput *= surf.albedo; // 能量乘以反照率
}

五、 时间积累 (Temporal Accumulation)

单帧的路径追踪充满了噪点(Noise)。为了得到平滑的图像,我们利用时间的维度。

if (frameNumber == 0) {
    // 第一帧直接写入
    outputPixels[pixelIndex] = vec4(currentColor, 1.0);
} else {
    // 后续帧:当前颜色与历史颜色混合
    vec3 history = accumulationPixels[pixelIndex].rgb;
    // 混合权重 1/(N+1),也就是累加平均
    vec3 newColor = mix(history, currentColor, 1.0 / float(frameNumber + 1)); 
    outputPixels[pixelIndex] = vec4(newColor, 1.0);
}

通过 mix 函数,我们将每一帧的结果平均化。随着 frameNumber 增加,新的一帧权重越来越小,画面逐渐收敛,噪点消失,最终得到一张完美的渲染图。


总结

这段 Shader 代码展示了一个现代 GPU 路径追踪器的最小闭环: * 用 Wang Hash 制造混沌。 * 用 PBR 约束光影的物理规律。 * 用 随机光源采样 解决直接光照的性能瓶颈。 * 用 俄罗斯轮盘赌时间积累 平衡效率与质量。

掌握这些基础,你就在写出自己的“Cyberpunk”渲染器的路上迈出了坚实的一步.

程序化地图生成技术详解

引言

在现代游戏开发中,程序化内容生成(Procedural Content Generation, PCG)是一项关键技术,它能够自动生成游戏内容,如关卡、地图、纹理等。这种方法不仅可以大大减少开发者的手动工作量,还能创造出几乎无限的变化,增强游戏的可重玩性。本文将深入探讨程序化地图生成的各种技术和算法。

阅读全文 »

c++ 拾遗

1,左值和右值

左值是可以被修改的值,而右值则不能被修改,一般而言变量都是左值,而常数则是右值。

左值引用和右值引用

int a = 1;
int &b = a;
int &&c = 1;

此时b为左值引用,因为a是左值。而c则是右值引用,注意右值引用要用&&。

std::string function() {
    return "hello world";
}
std::string&& a = function();

右值引用常用于接收函数将亡返回值,节省开销。 此外,右值用于移动语义和完美转发(后面填坑)。

2,指针

关于malloc,free以及new,delete

malloc和free是c语言的库函数,而new和delete是c++的运算符。 malloc和free是函数,new和delete是运算符。 malloc直接分配空间而new是先分配空间再调用构造函数。

指针的情况

指针的值(即地址)应属下列4种状态之一 1.指向一个对象。 2.指向紧邻对象所占空间的下一个位置。(end迭代器) 3.空指针,意味着指针没有指向任何对象。 4.无效指针,也就是上述情况之外的其他值。 试图拷贝或以其他方式访问无效指针的值都将引发错误。编译器并不负责检査此类错误,这一点和试图使用未经初始化的变量是一样的,访问无效指针的后果无法预计,因此程序员必须清楚任意给定的指针是否有效。尽管第2种和第3种形式的指针是有效的,但其使用同样受到限制;显然这些指针没有指向任何具体对象,所以试图访问此类指针(假定的)对象的行为不被允许。如果这样做了,后果也无法预计。

万能指针

void *p;
int a=1;
p=&a;
*((int*)p);

万能指针不受类型限制,可以指向任意类型的对象。但是解引用时则必须进行类型转换

3,const

需要注意const只在本文件内有效,有点像#define的效果,编译器会把const替换成具体的值。多个文件如果重复定义const,那么它们也是独立的值,互不影响。而extenrn则是在其他文件中定义的const,需要在本文件中使用。可以避免多开销。

double pi=3.14;
//int &a=pi; //错误,类型不匹配
const int &a=pi; //正确
其实在上述情况编译器进行了隐式转换,把pi转换成了int类型。只有constt才可以这样。

重要的一点要区分以下两个:

int a=5;
const int* p=&a;//1.
int const* p=&a;//2.
第一个是表示不能通过p解引用来修改a的值,但是p存储的地址可以被修改即可以让p指向其他变量。而第二个则表示p存储的地址不能被修改,即不能指向其他变量了,但可以通过解引用修改p指向的值。
const int* const p=&a;//3.
第三种情况最惨,p存储的地址和p指向的值都不能被修改。属于是集百家之短了。

4,auto和decltype

auto是c++11引入的关键字,用于自动推导变量的类型。

auto a=1;
auto b=1.0;
auto c='a';
auto d="hello world";
### auto的const auto会忽略掉顶层const,保留底层const。
const int a=1;
auto b=a; //b的类型是int
### auto的引用 auto会忽略掉引用。
int a=1;
auto &b=a; //b的类型是int
### auto的数组 auto会忽略掉数组的维度。
int a[10];
auto b=a; //b的类型是int*
### auto的指针 auto会忽略掉指针的星号。
int *a;
auto b=a; //b的类型是int
### decltype decltype是c++11引入的关键字,用于获取变量的类型。
int a=1;
decltype(a) b=a; //b的类型是int
### decltype的const decltype会保留顶层const。
const int a=1;
decltype(a) b=a; //b的类型是const int
### decltype的引用 decltype会保留引用。
int a=1;
decltype(a) &b=a; //b的类型是int&
### decltype的数组 decltype会保留数组的维度。
int a[10];
decltype(a) b=a; //b的类型是int[10]
### decltype的指针 decltype会保留指针的星号。
int *a;
decltype(a) b=a; //b的类型是int*

5,结构体

c++中的结构体十分的灵活,基本上和类的功能和操作一样了,唯一的区别就是结构体的成员默认是public的,而类的成员默认是private的。实际用法上,结构体用于存储数据,而类用于封装数据和操作。、

6,仿函数

又称函数对象,是一个类,重载了()运算符,使得该类的对象可以像函数一样调用。

class MyClass {
public:
    void operator()(int a, int b) {
        cout << a + b << endl;
    }
}

MyClass myClass;
myClass(1, 2); //输出3
优点: 1. 可以像函数一样调用。 2. 可以在类中保存状态。 3. 可以作为函数参数。 4. 可以作为函数返回值。 5. 可以作为模板参数。 6. 可以作为STL算法的参数。

光线追踪技术以及c++实现心得

光线追踪原理

光线追踪是一种基于物理原理的渲染方法,它通过模拟光线在场景中的传播来生成图像。光线追踪的核心思想是将场景中的物体看作是光源和材质的组合,然后通过计算光线与物体的交点来确定像素的颜色。

轴对齐包围盒AABB

在光线追踪中,AABB(Axis-Aligned Bounding Box)是一种常用的加速结构,用于快速判断光线是否与物体相交。AABB是一个包围盒,它与物体的每个面都平行,并且包围了物体的所有顶点。通过判断光线与AABB的相交情况,可以快速排除大部分不相交的物体,提高光线追踪的效率。

大家还记得三维空间中的射线参数方程吗:

其中, 是参数, 是射线起点, 是射线方向向量。

利用平板法,可以得知射线是否与AABB相交,平板法的基本思想是通过计算射线与AABB的每个面相交的参数范围,然后检查这些范围是否重叠。

compute (tx0, tx1)
compute (ty0, ty1)
compute (tz0, tz1)
return overlap ? ((tx0, tx1), (ty0, ty1), (tz0, tz1))

加速结构bvh

在光线追踪中,其实大部分时间都浪费在判断光线是否与场景中的三角形面相交上,当模型面数多起来的时候,速度会显著变慢,这是就要用到BVH(Bounding Volume Hierarchy)是一种常用的加速结构,用于快速判断光线是否与物体相交。BVH是一种树形结构,它将场景中的物体划分为若干个小的包围盒,然后将这些包围盒按照一定的规则组织成一棵树。通过判断光线与BVH的相交情况,可以快速排除大部分不相交的物体,提高光线追踪的效率。

strcut BVH_Node{
    AABB box;
    BVH_Node* left;
    BVH_Node* right;
    int n,index;
}

上面是一个简单定义的bvh节点类型,其中n是该节点所包含三角形个数,只有在该节点为叶子节点才有用,不为叶子节点则n为0。 index是该节点所包含的第一个三角形的索引。

游戏开发中的寻路算法:从A*到导航网格

引言

在游戏开发中,寻路算法是实现NPC智能移动的核心技术。无论是RTS游戏中的单位移动,还是RPG中的敌人追逐,高效的寻路算法都是不可或缺的。本文将介绍游戏中常用的寻路算法,从经典的A*算法到现代的导航网格技术。

阅读全文 »

着色器编程基础:GLSL入门指南

引言

着色器是现代图形编程中不可或缺的一部分,它们允许开发者直接在GPU上执行代码,从而实现各种复杂的视觉效果。本文将介绍GLSL(OpenGL着色语言)的基础知识,帮助游戏开发者入门着色器编程。

阅读全文 »
0%