VR 着色器编辑器案例研究
1. 视觉系统与 UI 设计
人类视觉系统具备随着时间重建缺失信息的出色能力。在非固定的用户界面(UI)中,当你查看文本并轻微移动头部,改变文本渲染的像素和子像素时,往往能更轻松地重构单词形状并理解所读内容,像游戏《精英:危险》的 VR 模式就是这样的例子。
经过多番考量,我们选择了一个文本字段为 70×34 的 UI,并且为了清晰起见,使用了超大尺寸的控制小部件。在 Oculus Rift 中,这感觉就像是在大约一米外的空间里操作一台 40 英寸的电视。
2. 窗口和 UI 库的选择
在项目迁移过程中,我们面临的下一个挑战是选择一个能满足需求的 UI 窗口工具包。我们需要它具备在 OpenGL 应用程序中打开和关闭窗口的功能,按钮要有可点击的文本,还要有实用的可拖动滚动条等。而 GLFW 无法提供这样的 UI 功能支持。显然,我们不想从头编写一个新的 UI 库,所以需要寻找其他能支持渲染所需 UI 元素的库,要么补充 GLFW 的功能,要么完全替代它。
我们对 UI 库的选择有以下几个主要要求:
- 能够创建实现所需的各类 UI 元素,主要包括标签、图像、按钮,还需要一个文本编辑器窗口。
- UI 库要么能原生将 OpenGL 纹理作为输出目标,要么至少能让我们将 UI 表面转换为 2D 图像,以便随意复制到 OpenGL 纹理中。
- 能够获取在主 OpenGL 输出窗口(显示 Rift 畸变图像)中接收到的鼠标和键盘输入,并直接注入到 UI 元素中。
- UI 要具备响应性,戴上头戴设备与 UI 交互时,不能有使用 1985 年 PC 的糟糕体验。
市面上有不少用于抽象开发窗口化 UI 的库,我们考察了很多来满足需求。第一个要求比较容易实现,很难找到不能创建标签、按钮、图像和文本窗口的 UI 库,否则就不能称之为 UI 库。而后两个要求则更具挑战性。大多数 UI 库专注于窗口抽象,假定主要目标是在传统桌面隐喻中显示一个或多个窗口。它们假定输出最终是某种平台原生窗口,输入是平台原生事件,通常会转换为库特定的事件包装器。也就是说,大多数 UI 库旨在解决跨平台应用开发的问题,而非完全脱离桌面隐喻。
对于 C/C++ 应用程序,最流行和成熟的 UI 框架可能是 Qt(qt - project.org)、GTK +(www.gtk.org)和 wxWidgets(www.wxwidgets.org)。我们也考虑过用 Java 编写应用程序,这样就可以使用 Swing、AWT 或 SWT。不过,专门将 OpenGL 作为“原生”输出选项的库很少,但确实存在,比如 libRocket(librocket.com)和 CEGUI(cegui.org.uk)。这两个库都能让你创建 UI,通过文档完善的函数注入鼠标和键盘输入与界面交互,并将生成的 UI 状态渲染到各种后端渲染器,都支持 OpenGL 和 Direct3D 作为渲染器。libRocket 旨在让客户端基于 HTML/CSS 创建界面,CEGUI 允许客户端通过编程方式创建界面,或使用布局工具编写自定义 XML 文件,在运行时将其扩展为 UI 对象。
然而,libRocket 和 CEGUI 都没有从底层平台获取输入事件的机制,都依赖使用它们的应用程序拦截这些事件、进行转换,然后通过为此目的设计的一组方法将其转发给库。而且这两个库都不处理窗口创建或 OpenGL 上下文创建。使用它们中的任何一个都意味着要扩展我们的 GLFW 应用程序框架,将接收到的 GLFW 键盘和鼠标事件转换为相应的库事件,并调用库的注入函数,这虽然不一定糟糕,但可能会很繁琐。
最终,我们选择了 Qt 作为 UI 库,原因如下:
|优点|详情|
| ---- | ---- |
|所需 UI 元素|Qt 提供了大量的 UI 组件,以及用于设计 UI 的编程和声明机制。|
|离屏渲染|Qt 通过多种机制支持 UI 的离屏渲染。例如,你可以将任何 Qt QWidget(Qt 对窗口和控件的抽象)直接转换为 QImage,然后复制到 OpenGL 纹理中。|
|输入注入|QQuickRenderControl 专门设计用于接收来自另一个 Qt 组件的转发输入事件。在本例中,我们使用 OpenGL 渲染窗口接收事件并将其转发到离屏 UI。|
|性能|在测试中,Qt 的 UI 表现流畅,即使在离屏渲染并叠加到 Rift 中时也是如此。|
此外,Qt 非常成熟、广泛使用、文档完善且积极开发。它在不断添加新功能的同时,大多能保持与以前编写的应用程序的兼容性,并且支持 CMake。不过,使用 Qt 意味着不能使用 GLFW,因为两者都想处理来自原生平台的输入,不能在同一个应用程序中同时使用。但这也不全是坏事,它促使我们考虑 Rift 如何与其他底层平台交互,并应对相应挑战。
3. 实现过程
在研究过程中,我们构建了多个版本的 ShadertoyVR,在不断迭代中学习。我们原以为最具挑战性的是在 VR 中运行着色器,处理畸变和双目视角。开发初期,我们就有了一个能加载片段着色器并将其渲染为 VR 环境的原型应用程序,还沾沾自喜,以为大功告成。但后来发现,复制原网站的基本渲染功能是编写应用程序中最容易的部分,最难的是实现 UI。
3.1 在 Qt 中支持 Rift
使用 Qt 就不能使用 GLFW,所以我们需要创建一个类,以便在 Qt 中进行 Rift 渲染。由于之前构建完整 RiftApp 的方式是基于 RiftGlfwApp 和 GlfwApp,与 GLFW 紧密相连。我们不想复制类似结构创建一个大部分代码相同但与 Qt 紧密相连的新类,而是想从 RiftApp 中提取所有与“Rift 相关”的部分,放入一个尽可能不依赖于创建渲染上下文底层库概念的新类中。
以下是新类
RiftRenderingApp
的代码:
class RiftRenderingApp : public RiftManagerApp {
ovrEyeType currentEye{ovrEye_Count};
FramebufferWrapperPtr eyeFramebuffers[2];
unsigned int frameCount{ 0 };
protected:
ovrPosef eyePoses[2];
ovrTexture eyeTextures[2];
ovrVector3f eyeOffsets[2];
glm::mat4 projections[2];
bool eyePerFrameMode{false};
ovrEyeType lastEyeRendered{ ovrEye_Count };
private:
virtual void * getNativeWindow() = 0;
protected:
virtual void initializeRiftRendering();
virtual void drawRiftFrame() final;
virtual void perEyeRender() {};
virtual void perFrameRender() {};
public:
RiftRenderingApp();
virtual ~RiftRenderingApp();
};
这个类包含了与 RiftApp 很多相同的代码,但没有窗口创建和定位的具体细节。它专注于使用 OpenGL 进行 Rift 初始化和畸变的抽象,而不涉及具体实现。
在之前的示例中,将所有渲染封装在
renderScene()
方法中就足够了。但在 Shadertoy 应用程序中,我们需要做一些渲染工作,将最新的 UI 视图与鼠标光标合成到一个纹理中。每帧做两次这样的工作没有必要,因为结果应该是相同的。所以我们将
renderScene()
拆分为两个函数:
-
perFrameRender()
:如名称所示,每帧调用一次。我们希望这里的任何渲染工作都能纳入 Oculus 计时机制,所以在 SDK 帧开始方法
ovrHmd_BeginFrame()
之后调用。默认实现不做任何事情,如果每帧没有需要做的工作,可以忽略它。
-
perEyeRender()
:直接替代旧的
renderScene()
方法,在每只眼睛的循环中调用,就像之前的示例一样。
我们还添加了每帧只渲染一只眼睛的支持,通过
eyePerFrameMode
布尔成员作为开关,
lastEyeRendered
成员记住上一次渲染的眼睛,这样每帧在两只眼睛之间交替。主要绘制函数如下:
void RiftRenderingApp::drawRiftFrame() {
++frameCount;
ovrHmd_BeginFrame(hmd, frameCount);
MatrixStack & mv = Stacks::modelview();
MatrixStack & pr = Stacks::projection();
perFrameRender();
ovrPosef fetchPoses[2];
ovrHmd_GetEyePoses(hmd, frameCount,
eyeOffsets, fetchPoses, nullptr);
for (int i = 0; i < 2; ++i) {
ovrEyeType eye = currentEye =
hmd->EyeRenderOrder[i];
if (eye == lastEyeRendered) {
continue;
}
lastEyeRendered = eye;
eyePoses[eye] = fetchPoses[eye];
Stacks::withPush(pr, mv, [&] {
pr.top() = projections[eye];
glm::mat4 eyePose = ovr::toGlm(eyePoses[eye]);
mv.preMultiply(glm::inverse(eyePose));
eyeFramebuffers[eye]->Bind();
perEyeRender();
});
if (eyePerFrameMode) {
break;
}
}
if (endFrameLock) {
endFrameLock->lock();
}
ovrHmd_EndFrame(hmd, eyePoses, eyeTextures);
}
每帧只渲染一只眼睛对于提高性能和减少延迟很有效,但会牺牲平滑的视差 VR 效果。它对于调试 VR 着色器场景非常有用。不过,每帧单眼模式并非适用于所有应用程序,特别适用于相对于观察者运动较少或深度信息较少的应用程序。例如,一个让你观察天空并放大特定区域的天文学程序就是很好的候选者,因为场景本质上是在无限远处,通常不会有每只眼睛的视差。SDK 提供的时间扭曲功能可以补偿给定眼睛渲染时间和显示时间之间头部旋转的任何变化。在这个应用程序中,由于场景可能包含或不包含相当于视点运动的内容,可能有或没有任何深度,我们为用户提供了根据着色器和舒适度水平打开或关闭每帧单眼模式的选项。对于没有深度和运动的着色器,每帧渲染两只眼睛的好处不大,用户可以增加着色器每帧能做的工作量。
4. 将 Rift 代码绑定到 Qt 实现
Qt 有多种表示屏幕窗口的方式,即使对于 OpenGL 窗口,也至少有三个可用的类:QGLWidget、QOpenGLWindow 和 QWindow。虽然名称中直接包含 GL 的类看起来很有吸引力,但对于我们的目的来说,QWindow 比它们更容易使用。
QGLWidget 和 QOpenGLWindow 都是便利类,旨在减轻用户处理 OpenGL 的负担,但它们的实现方式会干扰我们与 Oculus SDK 的交互方式。可以使用 QGLWidget 作为 Rift 输出窗口,但需要重写一些事件处理程序并手动禁用缓冲区交换。QOpenGLWindow 本应是 QGLWidget 更现代的替代品,但它对与 GL 交互的限制更多,例如无法禁用缓冲区交换。
QWindow 是将 Rift 功能与 Qt 集成的最佳选择。QWindow 没有用于窗口渲染的事件,而是让开发者直接控制何时以及如何将任何内容渲染到窗口表面。鉴于 Oculus SDK 要控制缓冲区交换,并且我们想在一个独立的线程中进行渲染,这对我们的示例非常理想。我们得到的类如下:
class QRiftWindow : public QWindow,
protected RiftRenderingApp {
Q_OBJECT
QOpenGLContext * m_context;
bool shuttingDown { false };
LambdaThread renderThread;
TaskQueueWrapper tasks;
public:
QRiftWindow();
virtual ~QRiftWindow();
void start();
void stop();
void queueRenderThreadTask(Lambda task);
void * getNativeWindow() {
return (void*)winId();
}
private:
virtual void renderLoop();
protected:
virtual void setup();
};
在这个类中,与线程处理相关的代码值得特别注意。每个基于 Qt 的应用程序(有某种 UI)都有一个 QGuiApplication 类型或其派生类型的单个对象。这个类不代表任何屏幕上的 UI,但负责处理来自底层操作系统的所有输入事件,以及在 Qt 组件之间传递消息。所有这些都发生在创建和执行 QGuiApplication 实例的线程上。如果不先调用特殊函数将对象的“所有权”转移到目标线程,就尝试在一个线程中使用另一个线程的对象,Qt 会出现问题。
为了确保最佳渲染性能,不受主线程上可能发生的事情影响,我们的渲染循环需要在自己的线程中运行。
LambdaThread
通过在我们的实例上调用
renderLoop()
封装了渲染线程。
start()
和
stop()
方法允许应用程序代码控制该线程的生命周期。
当我们编写一个从
QRiftWindow
派生的新类并处理键盘或鼠标事件等事件时,这些事件将由主线程处理,也就是 QGuiApplication 运行其事件处理循环的线程。例如,在更改渲染分辨率时,我们需要确保这发生在帧与帧之间。因此,我们有一个
tasks
容器用于存储要在渲染线程上执行的操作,以及一个
queueRenderThreadTask
方法,用于从其他线程(通常是主事件处理线程)将操作放入该任务队列。
选择简单或高级的线程模型取决于软件的性能需求。虽然不是严格要求在自己的线程上进行渲染,也可以直接在主线程上响应给定的定时器事件进行渲染,但这样有潜在的缺点。因为其他正在处理的事件可能会阻塞渲染,导致错过下一帧,从而在头戴设备中出现闪烁和跳动。我们不希望一个特别复杂的软件效果(如大爆炸这样微妙的效果)因为渲染和事件在同一个线程上竞争而影响渲染速率。
由于不能在主线程上运行紧密的事件循环(Qt 应用程序已经在这样做了),我们需要处理不同类型的复杂性,以确保足够频繁地接收绘制事件以满足所需的帧率,同时确保不会影响普通事件处理。使用 Oculus SDK 时尤其如此,因为帧结束调用会阻塞,直到完成缓冲区交换调用。
下面我们来看这个类实现中最重要的部分:窗口的创建和渲染循环。
5. 创建 Rift 窗口
窗口及其 OpenGL 上下文的创建,以及将窗口附加到 Rift 的操作都可以在构造函数中完成,代码如下:
QRiftWindow::QRiftWindow() {
setSurfaceType(QSurface::OpenGLSurface);
QSurfaceFormat format;
format.setDepthBufferSize(16);
format.setStencilBufferSize(8);
format.setVersion(3, 3);
format.setProfile(
QSurfaceFormat::OpenGLContextProfile::CoreProfile);
setFormat(format);
m_context = new QOpenGLContext;
m_context->setFormat(format);
m_context->create();
renderThread.setLambda([&] { renderLoop(); });
bool directHmdMode = false;
ON_WINDOWS([&] {
directHmdMode = (0 ==
(ovrHmdCap_ExtendDesktop & hmd->HmdCaps));
});
setFlags(Qt::FramelessWindowHint);
show();
if (directHmdMode) {
QRect geometry = getSecondaryScreenGeometry(
ovr::toGlm(hmd->Resolution));
setFramePosition(geometry.topLeft());
} else {
setFramePosition(
QPoint(hmd->WindowsPos.x, hmd->WindowsPos.y));
}
resize(hmd->Resolution.w, hmd->Resolution.h);
if (directHmdMode) {
void * nativeWindowHandle = (void*)(size_t)winId();
if (nullptr != nativeWindowHandle) {
ovrHmd_AttachToWindow(hmd, nativeWindowHandle,
nullptr, nullptr);
}
}
}
大部分代码是常见的 Rift 设置(就像 GLFW 版本一样)或 Qt 准备工作。
getSecondaryScreenGeometry()
函数是我们创建的一个辅助函数,用于遍历所有窗口。如果处于直接模式,它会尝试找到一个合适的非主窗口来放置输出,如果没有这样的窗口,则回退到主监视器。在开发过程中,这很有用,因为 Rift 窗口不会遮挡开发环境。
在早期的应用程序中,屏幕窗口的位置可能并不重要,但在 ShadertoyVR 中却至关重要。最终,我们会将屏幕窗口接收到的鼠标事件转发到离屏 UI。因此,整个屏幕窗口必须完全在屏幕上。如果窗口一半在屏幕内一半在屏幕外,虽然在 Rift 中能看到整个窗口,但无法使用与屏幕外部分物理窗口对应的任何虚拟 UI 元素,因为鼠标无法触及这些部分。
最后需要注意的是
QWindow::winId()
成员的使用。Oculus SDK 在直接模式下需要原生窗口句柄才能正常工作,大多数窗口抽象库都有获取原生标识符的函数。使用 GLFW 时,需要使用特殊头文件和预处理器定义来启用此功能,而在 Qt 中,
QWindow
的
winId()
是一个基本成员,非常方便。其他部分要么在 Qt 文档中有详细介绍,要么在之前的相关内容中已经涉及。
6. 渲染循环
渲染循环以及用于控制渲染线程的函数如下:
void QRiftWindow::start() {
m_context->doneCurrent();
m_context->moveToThread(&renderThread);
renderThread.start();
renderThread.setPriority(QThread::HighestPriority);
}
void QRiftWindow::renderLoop() {
m_context->makeCurrent(this);
setup();
while (!shuttingDown) {
if (QCoreApplication::hasPendingEvents())
QCoreApplication::processEvents();
tasks.drainTaskQueue();
m_context->makeCurrent(this);
drawRiftFrame();
}
m_context->doneCurrent();
m_context->moveToThread(QApplication::instance()->thread());
}
start()
方法由主线程调用,用于控制渲染循环的生命周期。它将 OpenGL 上下文移动到渲染线程(这是 Qt 的要求),启动线程并设置其优先级。
renderLoop()
方法将 OpenGL 上下文设置为当前线程,初始化 Rift,然后进入一个循环。在循环中,它处理该线程上发生的事件,执行必须在渲染线程上完成的任何工作,然后调用
drawRiftFrame()
方法进行 Rift 帧的渲染。当关闭标志
shuttingDown
为真时,循环结束,将 OpenGL 上下文移回主线程。
这个渲染循环与我们之前
GlfwApp
类的
run()
方法非常相似,通过合理的线程管理和事件处理,确保了 ShadertoyVR 应用程序在 Qt 环境下的稳定渲染。
综上所述,通过选择合适的 UI 库(Qt),将 Rift 功能与 Qt 有效集成,处理好线程管理、窗口创建和渲染循环等关键问题,我们逐步实现了一个功能较为完善的 VR 着色器编辑器 ShadertoyVR。在开发过程中,我们不断探索和尝试,解决了从 2D 到 3D 转换、Rift 支持、UI 实现等一系列挑战,为后续的 VR 应用开发积累了宝贵的经验。
VR 着色器编辑器案例研究(续)
7. 线程管理与性能优化
在 ShadertoyVR 的开发中,线程管理是确保性能和响应性的关键因素。我们采用的线程模型旨在将渲染工作与主线程的事件处理分离,以避免渲染阻塞和帧率下降。
7.1 线程分离的必要性
如前文所述,Qt 应用程序的主线程负责处理来自底层操作系统的输入事件和组件间的消息传递。如果将渲染工作也放在主线程中,可能会导致渲染被其他事件阻塞,从而影响帧率和用户体验。例如,当处理一个复杂的软件效果时,渲染和事件在同一线程上竞争资源,可能会导致画面出现闪烁和跳动。
为了避免这种情况,我们将渲染循环放在一个独立的线程中运行。通过
LambdaThread
封装渲染线程,并使用
start()
和
stop()
方法控制其生命周期,确保渲染工作不受主线程的干扰。
7.2 任务队列的作用
为了在渲染线程和主线程之间进行有效的通信,我们引入了任务队列
TaskQueueWrapper tasks
。当主线程接收到需要在渲染线程上执行的任务时,例如更改渲染分辨率,它可以通过
queueRenderThreadTask
方法将任务放入任务队列中。渲染线程在每次循环中调用
tasks.drainTaskQueue()
方法,执行队列中的任务,从而确保这些任务在帧与帧之间完成。
以下是线程管理和任务队列的交互流程:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px;
A(主线程):::process -->|queueRenderThreadTask| B(任务队列):::process
C(渲染线程):::process -->|drainTaskQueue| B
C -->|执行任务| D(渲染操作):::process
8. 性能测试与调优
在开发过程中,我们对 ShadertoyVR 进行了性能测试,以评估其在不同场景下的表现,并进行相应的调优。
8.1 性能指标
我们关注的主要性能指标包括帧率、响应时间和资源利用率。帧率是衡量渲染性能的重要指标,较高的帧率可以提供更流畅的视觉体验。响应时间则反映了用户操作与界面反馈之间的延迟,低延迟可以提高用户的交互体验。资源利用率包括 CPU、GPU 和内存的使用情况,合理的资源利用可以确保应用程序在不同硬件环境下的稳定性。
8.2 测试场景
我们设计了多种测试场景,包括简单的着色器渲染、复杂的场景渲染和多线程并发操作。在每个场景下,我们记录帧率、响应时间和资源利用率,并进行分析。
| 测试场景 | 描述 | 预期结果 |
|---|---|---|
| 简单着色器渲染 | 加载一个简单的片段着色器进行渲染 | 高帧率、低延迟、低资源利用率 |
| 复杂场景渲染 | 加载一个包含多个着色器和复杂模型的场景进行渲染 | 帧率可能下降,但仍保持流畅,响应时间合理 |
| 多线程并发操作 | 在渲染的同时进行其他操作,如更改分辨率、切换着色器 | 帧率和响应时间不受明显影响 |
8.3 调优策略
根据测试结果,我们采取了以下调优策略:
-
优化着色器代码
:通过减少着色器中的计算量和内存访问,提高渲染性能。例如,避免使用复杂的循环和递归,优化纹理采样和光照计算。
-
调整线程优先级
:将渲染线程的优先级设置为最高,确保渲染工作得到优先处理。
-
优化资源管理
:合理分配和释放内存,避免内存泄漏和资源浪费。例如,及时释放不再使用的纹理和缓冲区。
9. 未来展望
虽然我们已经实现了一个功能较为完善的 VR 着色器编辑器 ShadertoyVR,但仍有许多可以改进和扩展的地方。
9.1 功能扩展
- 支持更多的着色器类型 :目前,ShadertoyVR 主要支持片段着色器,未来可以考虑支持顶点着色器、几何着色器等其他类型的着色器,以实现更复杂的渲染效果。
- 增加交互功能 :例如,支持用户通过手势或语音控制着色器的参数,提高用户的交互体验。
- 集成更多的资源库 :提供更多的纹理、模型和材质资源,方便用户创建更加丰富的场景。
9.2 性能优化
- 进一步优化线程管理 :探索更高效的线程模型,减少线程间的同步开销,提高渲染性能。
- 利用硬件加速 :结合 GPU 的并行计算能力,进一步优化着色器的执行效率。
9.3 跨平台支持
目前,ShadertoyVR 主要基于 Qt 和 Oculus Rift 开发,未来可以考虑将其移植到其他平台,如 Android、iOS 等,以扩大其应用范围。
总结
通过本次 VR 着色器编辑器的开发案例,我们深入探讨了从 2D 到 3D 转换、UI 设计、Rift 支持、线程管理、窗口创建和渲染循环等关键技术。通过选择合适的 UI 库(Qt),将 Rift 功能与 Qt 有效集成,处理好线程管理、窗口创建和渲染循环等关键问题,我们成功实现了一个功能较为完善的 VR 着色器编辑器 ShadertoyVR。
在开发过程中,我们不断探索和尝试,解决了一系列挑战,为后续的 VR 应用开发积累了宝贵的经验。未来,我们将继续优化和扩展 ShadertoyVR 的功能,提高其性能和用户体验,为 VR 开发领域做出更多的贡献。
超级会员免费看
796

被折叠的 条评论
为什么被折叠?



