「游戏引擎 浅入浅出」是一本开源电子书,PDF/随书代码/资源下载: https://github.com/ThisisGame/cpp-game-engine-book
CLion项目文件位于 samples\engine_editor\draw_rtt
原神游戏画面为何变糊,手机厂商宣称的GameTurbo又是如何提升帧率?
这到底是米哈游的技术优化,还是手机厂商的丧心病狂?
DLSS/FSR又是何方神圣,与GameTurbo有何区别?
参考:https://www.khronos.org/opengl/wiki/Rendering_Pipeline_Overview
在OpenGL里,在顶点着色器计算好顶点在屏幕坐标,在片段着色器计算好像素颜色之后,还需要经过下列测试:
在 14.3 UIMask
介绍了模板测试,这里就不再重复。
和模板测试一样,GPU中有一个二维数组缓冲区,尺寸等于屏幕尺寸,每个像素对应数组的一个值,这个值就是当前像素记录的深度,这个二维数组缓冲区就是深度缓冲区。
垂直于屏幕的一条直线上,可能有多个片段。
OpenGL每处理一个片段,就把它的深度记录到深度缓冲区,当处理下一个片段的深度比这个大,那就抛弃这个片段,否则就更新深度值。
就是通过这个处理,实现了近处物体遮挡远处物体。
经过深度测试后,还需要进行颜色混合运算、逻辑运算、ColorMask运算,然后可以确定了要显示的颜色。
每个像素点都有一个颜色值,那么也需要一个二维数组,记录整个屏幕的颜色值,这个二维数组就是颜色缓冲区。
这一系列的测试所需要的缓冲区,每一帧它们都会刷新重置,它们都归属于帧缓冲区。
默认情况下,是由操作系统提供的帧缓冲区。
操作系统提供的默认帧缓冲区,是直接输出到显示器上的,应用程序没法对其进行修改。
所以OpenGL提供了帧缓冲区对象(OpenGL FrameBuffer Object),简称FBO,将模板、深度、颜色先存储到FBO中,然后再输出到系统提供的默认帧缓冲区中。
有了FBO这个中间对象后,我们就可以对FBO进行修改,例如修改色调、加后期等。
OpenGL的FBO自身并不存储数据,它记录着颜色缓冲区、深度缓冲区的指针。
也就是说,我们需要创建颜色缓冲区、深度缓冲区,然后指定到FBO里。
这个指针,叫做附着点。
FBO包括一个模板附着点、一个深度附着点、多个颜色附着点。
int二维数组,其实就是单通道纹理。
vec3二维数组,就是3通道纹理。
所以,3种附着点,都可以设置为Texture,就是模板贴图、深度贴图、颜色贴图,也就是RenderTexture。
而我们日常所说的RenderTexture,一般是指颜色贴图。
当启用了模板测试,则必须设置模板附着点,不然模板数据没有地方存储,就不会进行模板测试。
当启用了深度测试,则必须设置深度附着点,不然深度数据没有地方存储,就不会进行深度测试。
在OpenGL中使用FBO特别简单。
当创建了FBO对象,并且指定了使用它,那么OpenGL就会渲染到FBO里。
创建FBO之前,先来创建深度附着点、颜色附着点所需的Texture。
这里新建RenderTexture
类,来创建Texture与FBO。
//file:source/renderer/render_texture.h
class Texture2D;
class RenderTexture {
public:
RenderTexture();
~RenderTexture();
/// 初始化RenderTexture,在GPU生成帧缓冲区对象(FrameBufferObject)
/// \param width
/// \param height
void Init(unsigned short width,unsigned short height);
unsigned short width(){
return width_;
}
void set_width(unsigned short width){
width_=width;
}
unsigned short height(){
return height_;
}
void set_height(unsigned short height){
height_=height;
}
unsigned int frame_buffer_object_handle(){
return frame_buffer_object_handle_;
}
/// 是否正在被使用
bool in_use(){
return in_use_;
}
void set_in_use(bool in_use){
in_use_=in_use;
}
Texture2D* color_texture_2d(){
return color_texture_2d_;
}
Texture2D* depth_texture_2d(){
return depth_texture_2d_;
}
private:
unsigned short width_;
unsigned short height_;
unsigned int frame_buffer_object_handle_;//关联的FBO Handle
Texture2D* color_texture_2d_;//FBO颜色附着点关联的颜色纹理
Texture2D* depth_texture_2d_;//FBO深度附着点关联的深度纹理
bool in_use_;//正在被使用
};
//file:source/renderer/render_texture.cpp
RenderTexture::RenderTexture(): width_(128), height_(128), frame_buffer_object_handle_(0),in_use_(false),
color_texture_2d_(nullptr),depth_texture_2d_(nullptr) {
}
RenderTexture::~RenderTexture() {
if(frame_buffer_object_handle_>0){
RenderTaskProducer::ProduceRenderTaskDeleteFBO(frame_buffer_object_handle_);
}
//删除Texture2D
if(color_texture_2d_!= nullptr){
delete color_texture_2d_;
}
if(depth_texture_2d_!= nullptr){
delete depth_texture_2d_;
}
}
void RenderTexture::Init(unsigned short width, unsigned short height) {
width_=width;
height_=height;
color_texture_2d_=Texture2D::Create(width_,height_,GL_RGB,GL_RGB,GL_UNSIGNED_SHORT_5_6_5, nullptr,0);
depth_texture_2d_=Texture2D::Create(width_,height_,GL_DEPTH_COMPONENT,GL_DEPTH_COMPONENT,GL_UNSIGNED_SHORT, nullptr,0);
//创建FBO任务
frame_buffer_object_handle_ = GPUResourceMapper::GenerateFBOHandle();
RenderTaskProducer::ProduceRenderTaskCreateFBO(frame_buffer_object_handle_,width_,height_,color_texture_2d_->texture_handle(),depth_texture_2d_->texture_handle());
}
在RenderTexture::Init
里,发出了创建Texture与FBO的任务。
在渲染线程中,执行创建FBO的逻辑。
//file:source/render_device/render_task_consumer.cpp line:423
/// 创建FBO任务
void RenderTaskConsumer::CreateFBO(RenderTaskBase* task_base){
RenderTaskCreateFBO* task=dynamic_cast<RenderTaskCreateFBO*>(task_base);
//查询当前GL实现所支持的最大的RenderBufferSize,就是尺寸
GLint support_size=0;
glGetIntegerv(GL_MAX_RENDERBUFFER_SIZE, &support_size);
//如果我们设定的尺寸超过了所支持的尺寸,就抛出错误
if (support_size <= task->width_ || support_size <= task->height_) {
DEBUG_LOG_ERROR("CreateFBO FBO Size Too Large!Not Support!");
return;
}
//创建FBO
GLuint frame_buffer_object_id=0;
glGenFramebuffers(1, &frame_buffer_object_id);__CHECK_GL_ERROR__
if(frame_buffer_object_id==0){
DEBUG_LOG_ERROR("CreateFBO FBO Error!");
return;
}
GPUResourceMapper::MapFBO(task->fbo_handle_, frame_buffer_object_id);
glBindFramebuffer(GL_FRAMEBUFFER, frame_buffer_object_id);__CHECK_GL_ERROR__
//将颜色纹理绑定到FBO颜色附着点
GLuint color_texture=GPUResourceMapper::GetTexture(task->color_texture_handle_);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, color_texture, 0);__CHECK_GL_ERROR__
//将深度纹理绑定到FBO深度附着点
GLuint depth_texture=GPUResourceMapper::GetTexture(task->depth_texture_handle_);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_TEXTURE_2D, depth_texture, 0);__CHECK_GL_ERROR__
glBindFramebuffer(GL_FRAMEBUFFER, 0);__CHECK_GL_ERROR__
}
创建了FBO之后,将创建的颜色纹理和深度纹理,分别绑定到FBO的颜色附着点、深度附着点上。
创建RenderTexture实例,并调用RenderTexture::Init
初始化之后,FBO也就创建好了。
接下来在每一帧开始渲染时,设置将场景渲染到RenderTexture。
实例框架的渲染,是遍历所有Camera,然后在Camera下遍历所有MeshRenderer。
那么只要在Camera遍历MeshRenderer之前,设置好RenderTexture,就可以使用FBO了。
在Camera
添加RenderTexture
成员变量。
//file:source/renderer/camera.h
class Camera: public Component {
public:
......
/// 检查target_render_texture_是否设置,是则使用FBO,渲染到RenderTexture。
void CheckRenderToTexture();
/// 检查是否要取消使用RenderTexture.
void CheckCancelRenderToTexture();
/// 设置渲染目标RenderTexture
/// \param render_texture
void set_target_render_texture(RenderTexture* render_texture);
/// 清空渲染目标RenderTexture
void clear_target_render_texture();
protected:
......
RenderTexture* target_render_texture_;//渲染目标RenderTexture
......
};
在每个Camera
的每一帧渲染之前,都检查是否指定了RenderTexture,如果指定了,就使用FBO,将游戏画面渲染到FBO。
//file:source/renderer/camera.cpp line:71
/// 检查target_render_texture_是否设置,是则使用FBO,渲染到RenderTexture。
void Camera::CheckRenderToTexture(){
if(target_render_texture_== nullptr){//没有设置目标RenderTexture
return;
}
if(target_render_texture_->in_use()){
return;
}
if(target_render_texture_->frame_buffer_object_handle() == 0){//还没有初始化,没有生成FBO。
return;
}
RenderTaskProducer::ProduceRenderTaskSetViewportSize(target_render_texture_->width(),target_render_texture_->height());
RenderTaskProducer::ProduceRenderTaskBindFBO(target_render_texture_->frame_buffer_object_handle());
target_render_texture_->set_in_use(true);
}
/// 检查是否要取消使用RenderTexture.
void Camera::CheckCancelRenderToTexture(){
if(target_render_texture_== nullptr){//没有设置目标RenderTexture
return;
}
if(target_render_texture_->in_use()==false){
return;
}
if(target_render_texture_->frame_buffer_object_handle() == 0){//还没有初始化,没有生成FBO。
return;
}
//更新ViewPort的尺寸
RenderTaskProducer::ProduceRenderTaskSetViewportSize(Screen::width(),Screen::height());
RenderTaskProducer::ProduceRenderTaskUnBindFBO(target_render_texture_->frame_buffer_object_handle());
target_render_texture_->set_in_use(false);
}
/// 设置渲染目标RenderTexture
/// \param render_texture
void Camera::set_target_render_texture(RenderTexture* render_texture){
if(render_texture== nullptr){
clear_target_render_texture();
}
target_render_texture_=render_texture;
}
/// 清空渲染目标RenderTexture
void Camera::clear_target_render_texture() {
if(target_render_texture_== nullptr){//没有设置目标RenderTexture
return;
}
if(target_render_texture_->in_use()== false){
return;
}
RenderTaskProducer::ProduceRenderTaskUnBindFBO(target_render_texture_->frame_buffer_object_handle());
target_render_texture_->set_in_use(false);
}
......
void Camera::Foreach(std::function<void()> func) {
for (auto iter=all_camera_.begin();iter!=all_camera_.end();iter++){
current_camera_=*iter;
current_camera_->CheckRenderToTexture();
current_camera_->Clear();
func();
current_camera_->CheckCancelRenderToTexture();
}
}
要注意的是,当设置了FBO之后,所有的物体就会被渲染到FBO上,FBO顶替了系统默认帧缓冲区。
我们可以简单的认为,FBO就是一个看不见的屏幕,所以使用FBO也叫做离屏渲染。
由于FBO附着点上的Texture尺寸,并不一定等于窗口尺寸,所以设置FBO时,需要更新ViewPort的尺寸:
//file:source/renderer/camera.cpp line:96
/// 检查是否要取消使用RenderTexture.
void Camera::CheckCancelRenderToTexture(){
......
//更新ViewPort的尺寸
RenderTaskProducer::ProduceRenderTaskSetViewportSize(Screen::width(),Screen::height());
RenderTaskProducer::ProduceRenderTaskUnBindFBO(target_render_texture_->frame_buffer_object_handle());
......
}
在之前的Lua代码中,渲染了一个飞机。
现在我们创建好RenderTexture,然后设置到3D Camera上,将飞机渲染到RenderTexture上。
然后再将RenderTexture以UI的形式显示出来。
--file:example/login_scene.lua
......
--- 创建主相机
function LoginScene:CreateMainCamera()
--创建相机1 GameObject
self.go_camera_= GameObject.new("main_camera")
--挂上 Transform 组件
self.go_camera_:AddComponent(Transform):set_position(glm.vec3(0, 0, 10))
self.go_camera_:GetComponent(Transform):set_rotation(glm.vec3(0, 0, 0))
--挂上 Camera 组件
self.camera_=self.go_camera_:AddComponent(Camera)
--设置为黑色背景
self.camera_:set_clear_color(0,0,0,1)
self.camera_:set_depth(0)
self.camera_:set_culling_mask(1)
self.camera_:SetView(glm.vec3(0.0,0.0,0.0), glm.vec3(0.0,1.0,0.0))
self.camera_:SetPerspective(60, Screen:aspect_ratio(), 1, 1000)
--设置RenderTexture
self.render_texture_ = RenderTexture.new()
self.render_texture_:Init(960,640)
self.camera_:set_target_render_texture(self.render_texture_)
end
......
function LoginScene:CreateUI()
-- 创建UI相机 GameObject
self.go_camera_ui_=GameObject.new("ui_camera")
-- 挂上 Transform 组件
local transform_camera_ui=self.go_camera_ui_:AddComponent(Transform)
transform_camera_ui:set_position(glm.vec3(0, 0, 10))
-- 挂上 Camera 组件
local camera_ui=self.go_camera_ui_:AddComponent(UICamera)
camera_ui:set_culling_mask(2)
-- 设置正交相机
camera_ui:SetView(glm.vec3(0, 0, 0), glm.vec3(0, 1, 0))
camera_ui:SetOrthographic(-Screen.width()/2,Screen.width()/2,-Screen.height()/2,Screen.height()/2,-100,100)
-- 创建 UIImage
self.go_ui_image_rtt_=GameObject.new("draw render texture")
self.go_ui_image_rtt_:set_layer(2)
-- 设置尺寸
local rect_transform=self.go_ui_image_rtt_:AddComponent(RectTransform)
rect_transform:set_rect(glm.vec2(960,640))
-- 挂上 UIImage 组件,将RenderTexture绘制出来
local ui_image_mod_bag=self.go_ui_image_rtt_:AddComponent(UIImage)
ui_image_mod_bag:set_texture(self.render_texture_:color_texture_2d())
end
......
运行后效果:
和之前不使用RenderTexture的效果一致。
效果一致的原因是,本质上FBO可以看做是一块临时的软屏幕,将原本要输出到系统屏幕缓冲区的颜色数据,输出到了FBO。
另外一个原因就是,我们上面的代码,创建的RenderTexture尺寸,和实际系统屏幕缓冲区尺寸,也就是窗口尺寸是一致的。
这里有个有意思的是,RenderTexture的宽高。
在Unity创建RenderTexture,也是需要指定宽高的。
这或许是句废话,创建Texture,当然需要指定宽高。
那这个宽高会影响到什么?
默认情况下,OpenGL是使用系统提供的默认帧缓冲区,而这个默认帧缓冲区,就等于屏幕像素值,当你的手机是960x640,那就是960x640。
这就意味着,每一帧,片段处理器要执行960x640次。
那现在使用了OpenGL的FBO,创建了480x320大小的RenderTexture,那么片段处理器就只需要处理480x320次了。
这个性能提升怎么样呢?
下图是960x640的原生分辨率RenderTexture下,GPU在5.4&-6.2%之间浮动。
下图是480x320的低分辨率RenderTexture下,GPU在4.6&-5.4%之间浮动。
提升巨大!
当然了,FBO的颜色数据最终还是要送到系统默认的帧缓冲区去的,要呈现在显示器中。
这个时候480x320的数据,要显示到960x640的屏幕上,就会模糊了。
而红色的关机按钮,是作为UI渲染,并没有使用FBO。
很多大型手游为了保证流畅度,都会选中这种,降低场景FBO分辨率、保证UI原生分辨率的方式。
下面是我在原神里,截取的极高、极低画面效果对比,设备为一加Ace Pro。
可以看到场景人物分辨率差距很大,而UI并没有变化。
现在各品牌手机也有类似功能,智能分辨率,就是检测到游戏负载压力大时,直接就把游戏的默认帧缓冲区分辨率给降低。
好处都不用游戏自己来写逻辑,带来的问题就是,不仅场景人物模糊了,UI都给模糊了……
所以玩家们还是要关闭这种系统优化,在游戏自带的设置里调整比较好。
上面我们将游戏场景渲染到480x320的RenderTexture上,然后放到到960x640,性能得到极大提升,但是画面变得特别模糊。
我之前用过一些人工智能放大图片的软件,可以将低分辨率图片,放到至2x、4x大小,而保持一定的清晰度,这里以waifu2x
举例。
https://github.com/nagadomi/waifu2x/
先在RenderDoc中,将480x320的RenderTexture保存为图片。
然后用waifu2x
进行放大。
实际游戏画面和waifu2x使用AI放大的对比。
效果不是很好,毕竟只是固定的AI模型,没有针对我们的项目进行训练。
那如果有一套AI,针对我们的项目进行大量训练,然后在游戏以低分辨率运行时,用AI实时将低分辨率画面进行2x的提升,这样既能降低GPU消耗的同时,又能保证一定的画质,岂不是很美好!
这就是NVIDIA DLSS的功能了。
从网上摘抄一段DLSS简介:
NVIDIA DLSS是唯一由AI驱动的超级分辨率技术,这一优势可转化为最高2倍的游戏性能提升。
同时它也是唯一可用的缩放技术,借助深度学习神经网络,确保图像质量媲美原生分辨率。
图像缩放技术若没有AI支持的时间性缩放,生成的图像会产生难看的伪影,如运动伪影、闪烁图案和暗淡、模糊的纹理。
简单来说DLSS就是利用AI深度学习神经网络在不影响图像质量的同时提升性能,带给玩家最好的画质和游戏体验。
DLSS是一种超分辨率技术,就是将低分辨率图片,借助AI技术,将其放大至2X,4X倍数,而不会变模糊。
就是说DLSS可以将480x320的RenderTexture,借助AI技术,实时将其放大到960x640,甚至1920x1080。
FSR的作用也差不多,只不过DLSS是NVIDIA的,而FSR是AMD家开源的。
后续有希望在手机上使用到FSR,届时手游性能会有一个新的提升,这不是GameTurbo这种伪技术可以比拟的。