Skip to content

将渲染计时和正确性测试纳入自动测试范围 #8

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
greyishsong opened this issue Aug 8, 2024 · 15 comments
Open

将渲染计时和正确性测试纳入自动测试范围 #8

greyishsong opened this issue Aug 8, 2024 · 15 comments
Assignees

Comments

@greyishsong
Copy link
Member

起因

1.0 版本下,要创建 Scene / Group / Object 对象,就要使用与 OpenGL Buffer 绑定的 ArrayBuffer / ElementArrayBuffer 对象存储顶点和索引数据,所以自动测试代码中想要管理场景数据非常不便。在 2.0 版本中,新的 VertexAttribute / VertexIndices 对象不再必须绑定某个 OpenGL Buffer ,因此整个场景都可以脱离 OpenGL Context 存在,也就可以在无屏幕的环境下进行自动测试了。

Catch2 测试框架提供了完善的计时和统计功能,完全自动化的性能测试便于我们在修改代码后快速测试对性能的影响,并且不需要向渲染管辖代码中插入计时代码。所以,是时候实现离屏 (headless) 的自动化渲染测试了!

实现方案

对于 CPU 离线渲染而言,所有数据都在内存中,因此没有加载数据的过程,执行渲染就是调用 RenderEngine::render 函数。自动测试渲染的过程,就是在测试环境下离屏创建场景和 RenderEngine ,然后进行渲染、获取图像、检查结果。

所有关于渲染的测试逻辑都写在 test/rendering_tests.cpp 中。

离屏场景加载和渲染参数设置

在有 GUI 的情况下,加载文件是通过调用 Scene::load 函数完成的。这个函数会新建一个 Group 对象,然后调用 Group::load 函数执行加载过程。Group::load 函数会创建 Object 、新的顶点和索引数据,并为它们创建 OpenGL Buffer Object 。

离屏运行与有屏幕运行时的不同在于:

  • 所有和 GUI 相关的类(对象)都不能创建,所以原先由 Controller 对象管理的 Scene 现在需要在测试时手动创建
  • 加载文件的过程并无差异,但不需要创建 OpenGL Buffer ,只需要将各 VertexAttribute / VertexIndices 对象的 buffer 成员直接置为 nullptr

添加光源、设置材质和相机参数的过程可以参考 UI::Toolbarlayout_moderender_mode 两个方法,直接修改场景数据即可。不过,当前 Scene 的构造函数会创建用于渲染 3D UI 元素的 OpenGL Buffer ,我们需要为它添加一个参数来控制是否创建 OpenGL Buffer ,并保证默认行为是创建(与原先相同)。加载完成后,再像 UI::Toolbar::render_mode 中那样创建一个 RenderEngine 对象并执行渲染。

要加载的模型文件不属于代码仓库,所以文件路径(即包含模型文件的 dandelion-testset 目录)暂定在运行时指定。如果没有指定测试文件路径,默认到源代码目录的父目录下寻找。

性能测试

性能测试应当考虑场景复杂度和渲染线程数两个正交的参数:

  • 场景复杂度包括模型的面数、光源数量、物体可见性(相互遮挡及视锥体裁剪)三方面,至少设置三个不同等级。
  • 渲染线程数至少包括 1, 2, 4, 8 四种情况。注意:软件线程数超过硬件线程数对计算密集型任务而言是无意义的,所以我们要在测试前检测硬件线程数。如果硬件线程数大于 8 ,可以视情况开启更多线程进行测试。

渲染性能测试应当实现为两个单元测试,分别测试单线程与多线程的情况。多线程的单元测试除了输出渲染时长以外,还需要输出相较于单线程的加速比。

另外,目前的渲染计时代码是写在 RenderEngine 中的,实现自动性能测试之前应当首先将这些代码移除。

正确性测试

如果一位同学正确地实现了渲染管线,那么他输出的图像应该和参考实现(即 dandelion-dev 的实现)差不多。不过由于光栅化、着色计算等实现细节上的差异,有少数像素的颜色不同是正常现象,因此比较渲染图时不能要求严格相同,而是要利用 PSNR(峰值信噪比)度量程序输出与参考图像间的差异。在 2023-2024 学年,我们因不确定使用何种度量方法而延后了实现渲染自动测试的计划,加上时间紧张,最终就没有实现。

为了实现它,我们需要准备测试数据并对 Dandelion 作一点修改。

  • 测试数据包括场景参数和参考答案
    • 场景参数指物体参数、相机参数、光源参数
    • 参考答案就是 dandelion-dev 给出的渲染图
  • 现有的 RenderEngine 输出的 .ppm 图像文件体积太大,不利于放在 dandelion-testset 中作为教学资料发放。所以我们要调用 stb_image API ,输出 PNG 图像作为参考答案。
@greyishsong greyishsong converted this from a draft issue Aug 8, 2024
@north-will
Copy link

1.“为渲染模块编写单元测试”是指写一个类似test目录下的cpp文件那样的基于catch2框架的TEST_CASE宏吗,那这些文件的运行逻辑大概是什么样的,比如TESTCASE在什么时候被调用、在TEST_CASE中如何获取主程序的变量值(?)
2.光栅化渲染器的调用接口里似乎是把每个像素的RGB值存入一个ppm文件,如果没理解错意思的话,实现在渲染结果图出现前判断渲染本身的正误,应该是需要计算这个ppm文件和标准结果图的PSNR值(?)

@greyishsong
Copy link
Member Author

greyishsong commented Aug 8, 2024

  1. 是的,每个 TEST_CASE 宏表示一个单元测试,用于测试一项具体的功能。所有的测试代码编译后形成一个可执行文件 test,通过命令行参数指定执行哪个(哪些)单元测试。单元测试是相互独立的,编写时可以忽略主程序,绝大多数变量都需要自己创建。
  2. 所有渲染器都需要通过 RenderEngine 间接调用,渲染结果存储在 RenderEngine::rendering_res 里,这是个 vector<unsigned char>,按照 RGBRGB…… 的格式、行优先的顺序存储每个像素的颜色,渲染完成后你直接读取这个属性即可,不需要访问文件。标准结果是以 .ppm 图片形式给出的,对比时需要先读入内存,然后计算渲染结果与标准结果之间的 PSNR 。

现在先只考虑写正确性测试,性能测试之后再说。

参考:

  • Catch2:我们使用的测试框架
  • Catch2 Tutorial
  • 我给你的标准结果是 ASCII PPM,需要自己写代码解析。你也可以先用工具转换成 .png 格式的图片,然后用项目里已经包含的 stb_image 读取。stb_image 的使用方法见 deps/stb/stb_image.h 文件开头的注释,直接调用 stbi_load 这个函数即可。

@north-will
Copy link

最近感觉大致了解了CATCH2框架,但关于TEST_CASE的运行时机我还是有些困惑,想来确认一下理解。问题主要在于假设以为同学写完了渲染实验之后,TEST_CASE如何通过这位同学编写的渲染代码得到结果。
假设现在有一位同学写完了渲染实验,并通过点击图形化界面中的渲染按钮开始了渲染。那存储渲染结果的RenderEngine变量在内存中,它如何被TEST_CASE所在的程序段访问——是否因为RenderEngine()中把光栅化渲染器设置为了全局唯一的变量,所以可以通过类似于
std::vector<unsigned char> stu_ans = RenderEngine::RenderEngine()->rendering_res
这样的方式实现读取?
还是说,上述理解并不成立,因为写好代码的同学并不需要点击图形化界面的按钮,而是直接运行TEST_CASE?故整个TEST_CASE中,应该包含调用同学编写好的RenderEngine代码进行渲染这一过程,即调用渲染的API,RenderEngine::render(Scene& scene, RendererType type),那这里的Scene如何获取呢?是需要自己来创建一个Scene,通过它创建物体和相机(且这两者的参数根据所提供文档的description.txt或实验手册的要求来,设置参数的过程也就是创建对象的过程直接写在TEST_CASE的代码中)吗。

@greyishsong
Copy link
Member Author

所谓单元测试,就是每个测试点独立性很强,大多数时候你可以理解为每个 TEST_CASE 就像一个单独的 main 函数,和其他 TEST_CASE 没有关联。一个单元执行时自己初始化自己的资源,执行完之后所有的变量自动销毁,其他单元中一般不可见。所有的单元测试会被编译成一个可执行文件 test(Windows 上是 test.exe),只会从命令行调用,没有可见的 GUI。

当你需要在单元测试代码中执行渲染时,应该自己创建 RenderEngine 并设置参数、创建 Scene 并使用 load 函数加载模型等,然后调用 RenderEngine::render 渲染图像,整个过程完全不涉及 UI 操作或代码。场景创建的过程就是根据 description.txt 的提示设置参数的过程,直接写在单元测试代码中。

@north-will
Copy link

话说test.exe如何编译得到,是在test文件夹下单独cmake编译吗

@greyishsong
Copy link
Member Author

是的,test 目录下有独立的 CMakeLists 文件,编译产物就是单元测试程序。具体说明可以参考实验手册基础部分的第三章。

@north-will
Copy link

这周在调试代码,遇到一个bug,报错信息为bad allocation
通过在TEST_CASE中的某些行添加INFO输出来进行调试,推断出现bad allocation的位置在调用渲染接口时。(因为前两个INFO输出了,但最后一个INFO没有输出)

TEST_CASE("Cow Rendering Test", "[rendering]")
{
    INFO(fmt::format("Cow Rendering Test begins."));

    Scene scene;
    scene.load("../input/rendering/cow.dae");
    scene.camera = Camera(camera_position, camera_target);
    scene.lights.push_back(Light(light_position, light_intensity));
    RenderEngine render_engine;
    INFO(fmt::format("Begin rendering"));
    render_engine.render(scene, RendererType::RASTERIZER);
    INFO(fmt::format("Finish rendering"));
    ...
}

我已经用reference文件夹中的代码替换了src/render目录下的rasterizer.cppshader.cpp,但还是发生了上述bug。我想知道是我调用API的方式不对还是我漏了加什么头文件之类(

@greyishsong
Copy link
Member Author

greyishsong commented Aug 30, 2024

目前有两个地方是能马上看出问题的:

  • Scene::camera 是由 Scene 对象初始化的,你不需要重新构造。在 Scene 对象完成构造之后,你只需要重设 scene.cameraposition / target 等属性,不应该重新构造一个 Camera
  • RenderEngine 对象构造时不会初始化图像宽/高,你需要确定图片宽度,并根据 scene.cameraaspect_ratio 属性算出图片高度,分别赋值给 render_enginewidth / height 属性,否则后续所有的 buffer 尺寸都是未定义——我猜这可能是出现内存分配错误的原因。

另外需要补充一下,我以前写代码的时候考虑不周,导致默认的渲染图像尺寸会随屏幕分辨率变化。你可能也注意到我给你的渲染图尺寸有点奇怪,这是因为默认宽度是 480/屏幕缩放倍率 ,本来固定成 480 更合适。你可以用 PS 之类的工具把我发给你的参考图像宽度都缩小到 480 px,然后在测试时也统一按 480 宽度渲染。主体部分我之后会修改。

@north-will
Copy link

north-will commented Sep 2, 2024

好的!我想问一下description中的diffuse和specular两个值如何赋值,看起来这两个值应该是物体组Group中的物体Object的材质属性GL::Material,但我似乎没有在Object头文件的定义中找到GL::Material;以及description中出现的这些表示颜色的6位16进制数允许直接赋值给vector3f吗?似乎应该是可以的?

@greyishsong
Copy link
Member Author

GL::Material 的声明在 src/platform/gl.hpp 中,你也可以直接到开发者文档上搜索这个类,它只是一个简单的 Phong 光照模型材质。

不可以直接把 16 进制颜色赋值给 Vector3f,Eigen 显然是不支持这么做的,并且颜色的值域应该是 0 到 1 而不是 0 到 255。不过我们在 src/utils/rendering.hpp 中提供了一个工具宏 RGB,可以用它来构造表示颜色的三维向量,例如构造一个表示颜色 #5F321E 的向量可以这样写:

Vector3f color(RGB(0x5f, 0x32, 0x1e));

@north-will
Copy link

抱歉之前可能没有描述清楚,我的意思是GL::Material和物体Object是怎么对应起来的,或者说在渲染时是怎么处理物体材质的,这个接口我一直没找到。所以我去看了Group加载的代码——如果我没有理解错的话——似乎物体材质是在加载时就确定的。
因为我只有一个普通材质兔子的模型,但example2中的兔子是粉色的。考虑到不可能为了改材质新建很多个模型用于测试,所以我的问题的更准确的表述是,Dandelion中是否存在修改物体的材质的接口()

@greyishsong
Copy link
Member Author

加载的时候确实会试图加载文件中的材质参数,不过加载之后也可以随时修改。材质不是 Object 的成员而是 GL::Mesh 的成员,你可以访问 obj.mesh.material 来访问 obj 这个物体的材质。更详细的说明可以去查开发者文档中 Object 类和 GL::Mesh 结构体的介绍。

@north-will
Copy link

代码主体部分基本写完了。如果希望执行测试,只需要把输入的模型文件放在\dandelion\test\input\rendering\model_name.dae中,
标准结果放在\dandelion\test\ans\rendering\png_name.png中即可。
但是今天检查的时候发现了一个新的bug。
我参考description.txt的描述设置了参数,并使用了标准文件在src文件夹进行了替换。运行程序后,在test文件的运行目录下可以看到调用渲染接口渲染出的文件rasterizer_res.ppm,发现渲染结果的输出和示例答案图片不一致。
结果如下图所示,左边是标准例图,右边是用标准代码渲染出的图像。
image
我用的模型文件是几年前图形学课上提供的cow.daebunny.dae,渲染前设置scene的代码如下。代码应该没有其他地方和渲染器交互。经过反复检查感觉非默认的参数应该也基本设置全了(也不排除我确实还是漏了一两个……?),就查不出这种现象出现的原因,想来问一下……

TEST_CASE("Cow Rendering Test", "[rendering]")
{
    const Vector3f camera_position(1.0f, 2.0f, 2.0f);
    const Vector3f camera_target(0.0f, 0.5f, 0.0f);
    const Vector3f light_position(0.6f, 1.0f, 3.0f);
    const float light_intensity = 10.0;

    Scene scene;
    scene.load("../input/rendering/cow.dae");
    scene.camera.position = camera_position;
    scene.camera.target = camera_target;
    scene.lights.push_back(Light(light_position, light_intensity));

    RenderEngine render_engine;
    render_engine.width = image_width;
    render_engine.height = image_width / scene.camera.aspect_ratio;
    render_engine.render(scene, RendererType::RASTERIZER);
}

@greyishsong
Copy link
Member Author

抱歉,这几天发布新版本忙得有些累,回复晚了。

发现渲染结果的输出和示例答案图片不一致。

从你发来的对比图上,我目前能猜到的原因是: Object::model, Camera::viewCamera::projection 三个函数的实现不正确,相当于 MVP 变换都是不对的,所以物体的位置看起来不对、画面也是平行投影而不是透视投影。

我会发给你正确的 MVP 变换实现,填入上面所说的三个函数后应该能解决问题,请妥善保管。

@north-will
Copy link

修改了./scene/object.cpp./scene/camera.cpp中的提及的函数,渲染结果仍然不一样:
image
image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: In Progress
Development

No branches or pull requests

2 participants