Margoo

...?

【EasyGPU】Lesson 3:EasyGPU 上的控制流与 Uniform 变量 金牌收录

第三节:EasyGPU 上的控制流与 Uniform 变量

在本章节中,我们将介绍 GPU 上的控制流以及 EasyGPU 的 Uniform 变量 API。我们依然会通过一个简单的例子来向你来介绍。

本节示例代码

#include <GPU.h>
#include <graphics.h>

#pragma comment(lib, "opengl32.lib")

int main() {
    initgraph(640, 480);

    BeginBatchDraw();

    constexpr int size = 30;

    try {
        Texture2D<PixelFormat::RGBA8> texture(getwidth(), getheight(), GetImageBuffer());
        Uniform<int> time;
        Kernel2D kernel("render_window", [&](Int &X, Int &Y) {
            Int t = time.Load();

            auto r = ToFloat(X) / getwidth();
            auto g = ToFloat(Y) / getwidth();
            auto b = (r + g) / 2;

            auto width = getwidth() / 2 + ToInt((getwidth() / 2) * Abs(Sin(ToFloat(t) / 1000)));
            auto height = getheight() / 2 + ToInt((getheight() / 2) * Abs(Sin(ToFloat(t) / 1000)));

            If(X >= width || Y >= height, [&] {
                Return();
            });

            auto tex = texture.Bind();

            Bool isWhite = ToBool((ToInt(ToFloat(X) / size) + ToInt(ToFloat(Y) / size)) & 1);
            If(isWhite, [&]() {
                tex.Write(X, Y, MakeFloat4(r, g, b, 1.0) * MakeFloat4(1, 1, 1, 1));
            }).Else([&]() {
                tex.Write(X, Y, MakeFloat4(0, 0, 0, 1));
            });
        });

        while (true) {
            cleardevice();

            time = clock();

            texture.Upload(GetImageBuffer());
            kernel.Dispatch((getwidth() + 15) / 16, (getheight() + 15) / 16, true);
            texture.Download(GetImageBuffer());

            FlushBatchDraw();
        }
    }
    catch (ShaderCompileException& e) {
        MessageBoxA(nullptr, e.what(), "Shader Compile Error", MB_ICONERROR);
        std::cout << e.GetBeautifulOutput();
    }

    return 0;
}

运行代码,我们可以看到一个随时间大小变化的渐变色棋盘:

如果你做了 Lesson2 的作业,应该记得第二题要求输入固定大小的图像。为什么非得固定?这是个问题。

我们的 Kernel 以 16×16 的线程块为单位执行,WorkGroup 数量计算公式是 (width / 16, height / 16)。假如图像尺寸不是 16 的倍数,整数除法会自动截断——比如宽 100 像素的图像,100 / 16 = 6,只能覆盖 96 列,右边会缺一块。

那改成向上取整呢?(width + 15) / 16。这样确实能覆盖完整图像,但会多出一批越界的线程。比如宽 100 像素的图像,(100 + 15) / 16 = 7,第 7 个线程块会访问 X=96~111 的区域,其中 100~111 是非法的。

所以我们必须在 Kernel 内部判断 X、Y 是否越界。但这里有个问题:EasyGPU 的 Kernel 最终翻译成 GPU 着色器,我们不能直接写 C++ 的 if/else,而得用 EasyGPU 提供的控制流 API——If、For、While、Do-While、Return。于是,这便引出了我们本节的第一个话题——EasyGPU 中的控制流语句。

 EasyGPU 中的控制流

在 EasyGPU 中,你可以使用 If、For、While、Do-While、Return 等一系列 EasyGPU 提供的函数来表示在 Kernel 中控制流。不同于 C++ 的 if/else 等控制流语句——它们不会被记入 IR (中间表示)并被翻译成 GPU 着色器语言—— EasyGPU的这套 API 允许你创建 GPU 中的逻辑控制流。下面分别介绍不同控制流的语法。


If 控制流

正如你所熟知 if/else if/else 那样,If 控制流通过链式调用完成控制链的构造,具体语法如下:

If(Cond, [&]() {
    ...
}).Elif(Cond1, [&]() {
    ...
}).Elif(Cond2, [&]() {
    ...
})
...
.Else([&]() {
    ...
});

在 EasyGPU 中的 Kernel 中,应用 Elif 替代 else if。Else、Elif 均为可选添加,不做强制要求。如示例代码中:

If(isWhite, [&]() {
    tex.Write(X, Y, MakeFloat4(r, g, b, 1.0) * MakeFloat4(1, 1, 1, 1));
}).Else([&]() {
    tex.Write(X, Y, MakeFloat4(0, 0, 0, 1));
});

 For 控制流

For 控制流不同于 C++ 中的 for 控制流,其有两种语法:

For (start, end, [&](Int &Index) {
    ...
});
For (start, end, step, [&](Int &Index) {
    ...
});

其中 lambda 函数中的形参 Index 表示本次循环的循环量(Index = start + step * n), start, end 必须是整数类型,可以通过 step 参数指定 For 的步进步长(若不指定,则默认为 1),如:

For (1, 10, 2, [&](Int &Index) {
    ...
});

将以 2 为步长进行步进。

 While 控制流

While 控制流的语法如下:

While (cond, [&]() {
    ...
});

其中 cond 表示条件,只要 cond 为 true,则持续执行 lambda 函数内的逻辑。

 Do-While 控制流

DoWhile([&]() {
    ...
}, Cond);

DoWhile 会首先尝试执行 lambda 函数体内的逻辑,再判断 Cond 是否为 true,若为 true 则继续执行 lambda 函数体内的逻辑,直到 Cond 为 false。

Return 控制流

Return 控制流有两种语法,一种是空 Return,一种是含值 Return:

Return(); // 空 Return
Return(Val); // 含值 Return

在 Kernel 中不能使用含值 Return,含值 Return 供给  Callable 使用(其将会在后面的 Lesson 中介绍到,是 EasyGPU 中用于封装重复逻辑的解决方案)。

重要提示:在 GPU 程序中尽可能减少控制流是一个明智的选择,过多的控制流会导致程序性能大大下降。这是因为 GPU 中没有 CPU 一样的分支预测等专门用于处理复杂逻辑的能力!

Uniform 变量

在前面的 Lessons 中,我们已经学习了如何创建 Buffer<T> 来与 GPU 交换大规模的数据,如何通过 Texture2D<PixelFormat> 来与 EasyX 中的图像缓存进行 CPU-GPU 交互。这些数据传输手段看起来已经足够了,然而,设想这么一个情况:如果我只想修改 GPU 中的一个变量呢?难道我要反复上传一个 Buffer<T> 吗?答案是否定的。这便是 Uniform 变量的用武之地。请看示例代码中出现了这么一行:

Uniform<int> time;

这行代码定义了一个 Uniform 变量 time,它在每一次渲染循环中都会被赋值为 clock() 再传入 Kernel。于是 Kernel 内就可以根据时间动态调整棋盘大小。Uniform 变量是一系列只读变量,GPU 不可以修改 Uniform 变量,只可以读取。其适用于单变量只读操作,相比 Buffer<T> 和 Texture2D<PixelFormat> 方法在特定的条件下会获得更佳的性能。

类似地,如果你要在你的 Kernel 中引用一个 Uniform 变量,你需要在你的 Kernel 中使用 Load 函数来将你的 Uniform 变量加载到 Kernel 中,如示例中的:

Int t = time.Load();

一旦 Uniform 加载到了 Kernel,那么每次 Kernel 被调度时,Kernel 内对应的变量就会变成提交前对应的 C++ 端的变量的值。如:

Uniform<int> a = 0;
Kernel1D kernel([&]() {
    auto var = a.Load();
});

a = 1;
kernel.Dispatch(1, true); // Now var = 1

课后作业

作业题目

  1. 试阐述以下问题:
      • 为什么在 EasyGPU 中不能直接使用 C++ 的 if/else,而必须使用 If/Else 等控制流 API?
      • Uniform<T> 的作用是什么?如何在 Kernel 中读取 Uniform 变量的值?
      • 当图像尺寸不是 16 的倍数时,如何处理越界的线程?

2. 使用 Uniform<int>、Texture2D 和 Kernel2D 实现一个动态的波纹效果

   要求:

  • 加载一张图片(或使用纯色背景)
  • 使用 Uniform<int> 传入当前时间(毫秒)
  • 在 Kernel 中根据时间计算波纹效果:对于每个像素 (X, Y),计算它到图像中心 (cx, cy) 的距离 d,然后根据 d 和时间 t 计算偏移量,产生类似水波的扭曲效果
  • 公式提示:可以使用 Sin(d / wavelength - t / speed) 来计算波纹强度
  • 使用 If 判断来处理越界情况(图像尺寸可能不是 16 的倍数)
  • 在 CPU 端每帧更新时间并重新 Dispatch,实现动画效果

   提示: 你需要在 Kernel 中使用 texture.Read() 读取原始像素,计算偏移后的采样坐标,然后将结果 Write 回去。

3. (可选)尝试使用 For 循环在 Kernel 中实现一个简单的高斯模糊效果(3×3 或 5×5 的卷积核)。

答案

1. 略

2.

#include <GPU.h>
#include <graphics.h>
#include <iostream>

#pragma comment(lib, "opengl32.lib")

int main() {
    initgraph(640, 480);

    IMAGE img(640, 480);
    loadimage(&img, TEXT("./test.jpg"));

    int width = img.getwidth();
    int height = img.getheight();
    int cx = width / 2;
    int cy = height / 2;

    Texture2D<PixelFormat::RGBA8> inputTex(width, height, GetImageBuffer(&img));
    Texture2D<PixelFormat::RGBA8> outputTex(width, height);

    Uniform<int> time;

    try {
        // 创建波纹效果 Kernel
        Kernel2D rippleKernel("Ripple Effect", [&](Int& X, Int& Y) {
            auto inTex = inputTex.Bind();
            auto outTex = outputTex.Bind();

            // 越界检查
            If(X >= width || Y >= height, [] {
                Return();
            });

            // 读取时间
            Int t = time.Load();

            // 计算到中心的距离
            Float dx = ToFloat(X) - MakeFloat(cx);
            Float dy = ToFloat(Y) - MakeFloat(cy);
            Float dist = Sqrt(dx * dx + dy * dy);

            // 波纹参数
            Float wavelength = MakeFloat(50.0f);  // 波长
            Float speed = MakeFloat(200.0f);      // 传播速度
            Float amplitude = MakeFloat(10.0f);   // 振幅

            // 计算波纹偏移
            Float phase = dist / wavelength - ToFloat(t) / speed;
            Float offset = amplitude * Sin(phase);

            // 根据距离衰减波纹强度
            Float maxDist = MakeFloat(300.0f);
            Float decay = MakeFloat(1.0f) - Min(dist / maxDist, MakeFloat(1.0f));
            offset = offset * decay;

            // 计算采样坐标
            Float sampleX = ToFloat(X) + dx / dist * offset;
            Float sampleY = ToFloat(Y) + dy / dist * offset;

            // 边界保护
            sampleX = Max(MakeFloat(0.0f), Min(sampleX, MakeFloat(width - 1)));
            sampleY = Max(MakeFloat(0.0f), Min(sampleY, MakeFloat(height - 1)));

            // 读取并写入
            Float4 color = inTex.Read(ToInt(sampleX), ToInt(sampleY));
            outTex.Write(X, Y, MakeFloat4(color.x(), color.y(), color.z(), color.w()));
        });

        inputTex.Upload(GetImageBuffer(&img));

        BeginBatchDraw();
        while (true) {
            cleardevice();

            // 更新时间
            time = clock();

            // 执行波纹效果(从 inputTex 读取原始图像,写入 outputTex)
            rippleKernel.Dispatch((width + 15) / 16, (height + 15) / 16, true);

            // 下载结果到 img 用于显示
            outputTex.Download(GetImageBuffer(&img));

            // 显示
            putimage(0, 0, &img);

            FlushBatchDraw();

            Sleep(10);
        }
        EndBatchDraw();
    }
    catch (ShaderCompileException& e) {
        MessageBoxA(nullptr, e.what(), "Shader Compile Error", MB_OK | MB_ICONERROR);
        std::cout << e.GetBeautifulOutput() << std::endl;
    }

    closegraph();
    return 0;
}

程序说明:

1. 双缓冲设计:使用两个纹理 inputTex 和 outputTex,避免在 Kernel 中读写同一个纹理造成冲突。inputTex 存储原始图像,只上传一次;outputTex 存储每帧的渲染结果
2. 避免效果叠加:注意 inputTex.Upload() 只在初始化时调用一次,不在渲染循环中调用。如果每帧都重新上传,会导致波纹效果被反复叠加,产生闪烁
3. 越界处理:使用 If 语句在 Kernel 开头检查 X 和 Y 是否超出图像范围
4. 波纹计算

  • 计算每个像素到中心的距离 dist
  • 使用正弦函数 Sin(dist / wavelength - time / speed) 产生波动的相位
  • 根据距离衰减波纹强度,使波纹在边缘逐渐消失

5. 边界保护:使用 Max/Min 确保采样坐标不会越界

效果示意:

程序运行后,你会看到图像中心产生向外扩散的水波效果,波纹随时间动态传播。

3. 略

添加评论