DirectX11 With Windows SDK--23 立方体映射:动态天空盒的实现
前言
上一章的静态天空盒已经可以满足绝大部分日常使用了。但对于自带反射/折射属性的物体来说,它需要依赖天空盒进行绘制,但静态天空盒并不会记录周边的物体,更不用说正在其周围运动的物体了。因此我们需要在运行期间构建动态天空盒,将周边物体绘制入当前的动态天空盒。
没了解过静态天空盒的读者请先移步到下面的链接:
章节回顾 |
---|
22 立方体映射:静态天空盒的读取与实现 |
directx11 with windows sdk完整目录
欢迎加入qq群: 727623616 可以一起探讨dx11,以及有什么问题也可以在这里汇报。
动态天空盒
现在如果我们要让拥有反射/折射属性的物体映射其周围的物体和天空盒的话,就需要在每一帧重建动态天空盒,具体做法为:在每一帧将摄像机放置在待反射/折射物体中心,然后沿着各个坐标轴渲染除了自己以外的所有物体及静态天空盒共六次,一次对应纹理立方体的一个面。这样绘制好的动态天空盒就会记录下当前帧各物体所在的位置了。
但是这样做会带来非常大的性能开销,加上动态天空盒后,现在一个场景就要渲染七次,对应七个不同的渲染目标!如果要使用的话,尽可能减少所需要用到的动态天空盒数目。对于多个物体来说,你可以只对比较重要,关注度较高的反射/折射物体使用动态天空盒,其余的仍使用静态天空盒,甚至不用。毕竟动态天空盒也不是用在场景绘制,而是在物体上,可以不需要跟静态天空盒那样大的分辨率,通常情况下设置到256x256即可.
资源视图(resource views)回顾
由于动态天空盒的实现同时要用到渲染目标视图(render target view)、深度模板视图(depth stencil view)和着色器资源视图(shader resource view),这里再进行一次回顾。
由于资源(id3d11resource
)本身的类型十分复杂,比如一个id3d11texture2d
本身既可以是一个纹理,也可以是一个纹理数组,但纹理数组在元素个数为6时有可能会被用作立方体纹理,就这样直接绑定到渲染管线上是无法确定它本身究竟要被用作什么样的类型的。比如说作为着色器资源,它可以是texture2d
, texture2darray
, texturecube
的任意一种。
因此,我们需要用到一种叫资源视图(resource views)的类型,它主要有下面4种功能:
- 绑定要使用的资源
- 解释该资源具体会被用作什么类型
- 指定该资源的元素范围,以及纹理的子资源范围
- 说明该资源最终在渲染管线上的用途
渲染目标视图用于将渲染管线的运行结果输出给其绑定的资源,即仅能设置给输出合并阶段。这意味着该资源主要用于写入,但是在进行混合操作时还需要读取该资源。通常渲染目标是一个二维的纹理,但它依旧可能会绑定其余类型的资源。这里不做讨论。
深度/模板视图同样用于设置给输出合并阶段,但是它用于深度测试和模板测试,决定了当前像素是通过还是会被抛弃,并更新深度/模板值。它允许一个资源同时绑定到深度模板视图和着色器资源视图,但是两个资源视图此时都是只读的,深度/模板视图也无法对其进行修改,这样该纹理就还可以绑定到任意允许的可编程着色器阶段上。如果要允许深度/模板缓冲区进行写入,则应该取消绑定在着色器的资源视图。
着色器资源视图提供了资源的读取权限,可以用于渲染管线的所有可编程着色器阶段中。通常该视图多用于像素着色器阶段,但要注意无法通过着色器写入该资源。
dynamicskyrender类
该类继承自上一章的skyrender类,用以支持动态天空盒的相关操作。
class dynamicskyrender : public skyrender { public: dynamicskyrender(comptr<id3d11device> device, comptr<id3d11devicecontext> devicecontext, const std::wstring& cubemapfilename, float skysphereradius, // 天空球半径 int dynamiccubesize, // 立方体棱长 bool generatemips = false); // 默认不为静态天空盒生成mipmaps // 动态天空盒必然生成mipmaps dynamicskyrender(comptr<id3d11device> device, comptr<id3d11devicecontext> devicecontext, const std::vector<std::wstring>& cubemapfilenames, float skysphereradius, // 天空球半径 int dynamiccubesize, // 立方体棱长 bool generatemips = false); // 默认不为静态天空盒生成mipmaps // 动态天空盒必然生成mipmaps // 缓存当前渲染目标视图 void cache(comptr<id3d11devicecontext> devicecontext, basiceffect& effect); // 指定天空盒某一面开始绘制,需要先调用cache方法 void begincapture(comptr<id3d11devicecontext> devicecontext, basiceffect& effect, d3d11_texturecube_face face, const directx::xmfloat3& pos, float nearz = 1e-3f, float farz = 1e3f); // 恢复渲染目标视图及摄像机,并绑定当前动态天空盒 void restore(comptr<id3d11devicecontext> devicecontext, basiceffect& effect, const camera& camera); // 获取动态天空盒 // 注意:该方法只能在 comptr<id3d11shaderresourceview> getdynamictexturecube(); // 获取当前用于捕获的天空盒 const camera& getcamera() const; private: void initresource(comptr<id3d11device> device, int dynamiccubesize); private: comptr<id3d11rendertargetview> mcachertv; // 临时缓存的后备缓冲区 comptr<id3d11depthstencilview> mcachedsv; // 临时缓存的深度/模板缓冲区 firstpersoncamera mcamera; // 捕获当前天空盒其中一面的摄像机 comptr<id3d11depthstencilview> mdynamiccubemapdsv; // 动态天空盒渲染对应的深度/模板视图 comptr<id3d11shaderresourceview> mdynamiccubemapsrv; // 动态天空盒对应的着色器资源视图 comptr<id3d11rendertargetview> mdynamiccubemaprtvs[6]; // 动态天空盒每个面对应的渲染目标视图 };
构造函数在完成静态天空盒的初始化后,就会调用dynamicskyrender::initresource
方法来初始化动态天空盒。
render-to-texture 技术
因为之前的个人教程把计算着色器给跳过了,render-to-texture
刚好又在龙书里的这章,只好把它带到这里来讲了。
在我们之前的程序中,我们都是渲染到后备缓冲区里。经过了这么多的章节,应该可以知道它的类型是id3d11texture2d
,仅仅是一个2d纹理罢了。在d3dapp
类里可以看到这部分的代码:
// 重设交换链并且重新创建渲染目标视图 comptr<id3d11texture2d> backbuffer; hr(mswapchain->resizebuffers(1, mclientwidth, mclientheight, dxgi_format_r8g8b8a8_unorm, 0)); // 使用了direcr2d交互时则为dxgi_format_b8g8r8a8_unorm hr(mswapchain->getbuffer(0, __uuidof(id3d11texture2d), reinterpret_cast<void**>(backbuffer.getaddressof()))); hr(md3ddevice->createrendertargetview(backbuffer.get(), 0, mrendertargetview.getaddressof())); backbuffer.reset();
这里渲染目标视图绑定的是重新调整过大小的后备缓冲区。然后把该视图交给输出合并阶段:
// 将渲染目标视图和深度/模板缓冲区结合到管线 md3dimmediatecontext->omsetrendertargets(1, mrendertargetview.getaddressof(), mdepthstencilview.get());
这样经过一次绘制指令后就会将管线的运行结果输出到该视图绑定的后备缓冲区上,待所有绘制完成后,再调用idxgiswapchain::present
方法来交换前/后台以达到画面更新的效果。
如果渲染目标视图绑定的是新建的2d纹理,而非后备缓冲区的话,那么渲染结果将会输出到该纹理上,并且不会直接在屏幕上显示出来。然后我们就可以使用该纹理做一些别的事情,比如绑定到着色器资源视图供可编程着色器使用,又或者将结果保存到文件等等。
虽然这个技术并不高深,但它的应用非常广泛:
- 小地图的实现
- 阴影映射(shadow mapping)
- 屏幕空间环境光遮蔽(screen space ambient occlusion)
- 利用天空盒实现动态反射/折射(dynamic reflections/refractions with cube maps)
dynamicskyrender::initresource方法--初始化动态纹理立方体资源
创建动态纹理立方体和对应渲染目标视图、着色器资源视图
在更新动态天空盒的时候,该纹理将会被用做渲染目标;而完成渲染后,它将用作着色器资源视图用于球体反射/折射的渲染。因此它需要在bindflag
设置d3d11_bind_render_target
和d3d11_bind_shader_resource
。
void dynamicskyrender::initresource(comptr<id3d11device> device, int dynamiccubesize) { // // 1. 创建纹理数组 // comptr<id3d11texture2d> texcube; d3d11_texture2d_desc texdesc; texdesc.width = dynamiccubesize; texdesc.height = dynamiccubesize; texdesc.miplevels = 0; texdesc.arraysize = 6; texdesc.sampledesc.count = 1; texdesc.sampledesc.quality = 0; texdesc.format = dxgi_format_r8g8b8a8_unorm; texdesc.usage = d3d11_usage_default; texdesc.bindflags = d3d11_bind_render_target | d3d11_bind_shader_resource; texdesc.cpuaccessflags = 0; texdesc.miscflags = d3d11_resource_misc_generate_mips | d3d11_resource_misc_texturecube; // 现在texcube用于新建纹理 hr(device->createtexture2d(&texdesc, nullptr, texcube.releaseandgetaddressof())); // ...
把miplevels
设置为0是要说明该纹理将会在后面生成完整的mipmap链,但不代表创建纹理后立即就会生成,需要在后续通过generatemips
方法才会生成出来。为此,还需要在miscflags
设置d3d11_resource_misc_generate_mips
。当然,把该纹理用作天空盒的d3d11_resource_misc_texturecube
标签也不能漏掉。
接下来就是创建渲染目标视图的部分,纹理数组中的每个纹理都需要绑定一个渲染目标视图:
// // 2. 创建渲染目标视图 // d3d11_render_target_view_desc rtvdesc; rtvdesc.format = texdesc.format; rtvdesc.viewdimension = d3d11_rtv_dimension_texture2darray; rtvdesc.texture2darray.mipslice = 0; // 一个视图只对应一个纹理数组元素 rtvdesc.texture2darray.arraysize = 1; // 每个元素创建一个渲染目标视图 for (int i = 0; i < 6; ++i) { rtvdesc.texture2darray.firstarrayslice = i; hr(device->createrendertargetview( texcube.get(), &rtvdesc, mdynamiccubemaprtvs[i].getaddressof())); } // ...
最后就是为整个纹理数组以天空盒的形式创建着色器资源视图:
// // 3. 创建着色器目标视图 // d3d11_shader_resource_view_desc srvdesc; srvdesc.format = texdesc.format; srvdesc.viewdimension = d3d11_srv_dimension_texturecube; srvdesc.texturecube.mostdetailedmip = 0; srvdesc.texturecube.miplevels = -1; // 使用所有的mip等级 hr(device->createshaderresourceview( texcube.get(), &srvdesc, mdynamiccubemapsrv.getaddressof()));
到这里还没有结束。
为动态天空盒创建深度缓冲区和视口
通常天空盒的面分辨率和后备缓冲区的分辨率不一致,这意味着我们还需要创建一个和天空盒表面分辨率一致的深度缓冲区(无模板测试):
// // 4. 创建深度/模板缓冲区与对应的视图 // texdesc.width = dynamiccubesize; texdesc.height = dynamiccubesize; texdesc.miplevels = 0; texdesc.arraysize = 1; texdesc.sampledesc.count = 1; texdesc.sampledesc.quality = 0; texdesc.format = dxgi_format_d32_float; texdesc.usage = d3d11_usage_default; texdesc.bindflags = d3d11_bind_depth_stencil; texdesc.cpuaccessflags = 0; texdesc.miscflags = 0; comptr<id3d11texture2d> depthtex; device->createtexture2d(&texdesc, nullptr, depthtex.getaddressof()); d3d11_depth_stencil_view_desc dsvdesc; dsvdesc.format = texdesc.format; dsvdesc.flags = 0; dsvdesc.viewdimension = d3d11_dsv_dimension_texture2d; dsvdesc.texture2d.mipslice = 0; hr(device->createdepthstencilview( depthtex.get(), &dsvdesc, mdynamiccubemapdsv.getaddressof()));
同样,视口也需要经过适配。不过之前的摄像机类可以帮我们简化一下:
// // 5. 初始化视口 // mcamera.setviewport(0.0f, 0.0f, static_cast<float>(dynamiccubesize), static_cast<float>(dynamiccubesize)); }
动态天空盒的绘制
讲完了初始化的事,就要开始留意帧与帧之间的动态天空盒渲染操作了。除了绘制部分以外的操作都交给了dynamicskyrender
类来完成。总结如下(粗体部分为该方法完成的任务):
- 缓存设备上下文绑定的后备缓冲区、深度/模板缓冲区
- 清空设置在像素着色器的着色器资源视图(绑定了动态天空盒资源)
- 对准某一个坐标轴,以90度垂直视野(fov),1.0f的宽高比架设摄像机,并调整视口
- 清理当前天空盒面对应的纹理和深度缓冲区,并绑定到设备上下文
- 和往常一样绘制物体和静态天空盒
- 回到步骤3,继续下一个面的绘制,直到6个面都完成渲染
- 为设备上下文恢复后备缓冲区、深度/模板缓冲区并释放内部缓存(防止交换链
resizebuffer
时因为引用的遗留出现问题) - 让动态天空盒生成mipmap链,并将其绑定到着色器
- 利用动态天空盒绘制反射/折射物体,绘制剩余物体,并用静态天空盒绘制天空
dynamicskyrender::cache方法--缓存渲染目标视图
该方法对应上面所说的第1,2步:
void dynamicskyrender::cache(comptr<id3d11devicecontext> devicecontext, basiceffect& effect) { devicecontext->omgetrendertargets(1, mcachertv.getaddressof(), mcachedsv.getaddressof()); // 清掉绑定在着色器的动态天空盒,需要立即生效 effect.settexturecube(nullptr); effect.apply(devicecontext); }
dynamicskyrender::begincapture方法--指定天空盒某一面开始绘制
该方法对应上面所说的第3,4步:
void dynamicskyrender::begincapture(comptr<id3d11devicecontext> devicecontext, basiceffect& effect, d3d11_texturecube_face face, const xmfloat3& pos, float nearz, float farz) { static xmvectorf32 ups[6] = { {{ 0.0f, 1.0f, 0.0f, 0.0f }}, // +x {{ 0.0f, 1.0f, 0.0f, 0.0f }}, // -x {{ 0.0f, 0.0f, -1.0f, 0.0f }}, // +y {{ 0.0f, 0.0f, 1.0f, 0.0f }}, // -y {{ 0.0f, 1.0f, 0.0f, 0.0f }}, // +z {{ 0.0f, 1.0f, 0.0f, 0.0f }} // -z }; static xmvectorf32 looks[6] = { {{ 1.0f, 0.0f, 0.0f, 0.0f }}, // +x {{ -1.0f, 0.0f, 0.0f, 0.0f }}, // -x {{ 0.0f, 1.0f, 0.0f, 0.0f }}, // +y {{ 0.0f, -1.0f, 0.0f, 0.0f }}, // -y {{ 0.0f, 0.0f, 1.0f, 0.0f }}, // +z {{ 0.0f, 0.0f, -1.0f, 0.0f }}, // -z }; // 设置天空盒摄像机 mcamera.lookto(xmloadfloat3(&pos) , looks[face].v, ups[face].v); mcamera.updateviewmatrix(); // 这里尽可能捕获近距离物体 mcamera.setfrustum(xm_pidiv2, 1.0f, nearz, farz); // 应用观察矩阵、投影矩阵 effect.setviewmatrix(mcamera.getviewxm()); effect.setprojmatrix(mcamera.getprojxm()); // 清空缓冲区 devicecontext->clearrendertargetview(mdynamiccubemaprtvs[face].get(), reinterpret_cast<const float*>(&colors::black)); devicecontext->cleardepthstencilview(mdynamiccubemapdsv.get(), d3d11_clear_depth | d3d11_clear_stencil, 1.0f, 0); // 设置渲染目标和深度模板视图 devicecontext->omsetrendertargets(1, mdynamiccubemaprtvs[face].getaddressof(), mdynamiccubemapdsv.get()); // 设置视口 devicecontext->rssetviewports(1, &mcamera.getviewport()); }
在调用该方法后,就可以开始绘制到天空盒的指定面了,直到下一次dynamicskyrender::begincapture
或dynamicskyrender::restore
被调用。
dynamicskyrender::restore方法--恢复之前绑定的资源并清空缓存
该方法对应上面所说的第7,8步:
void dynamicskyrender::restore(comptr<id3d11devicecontext> devicecontext, basiceffect& effect, const camera & camera) { // 恢复默认设定 devicecontext->rssetviewports(1, &camera.getviewport()); devicecontext->omsetrendertargets(1, mcachertv.getaddressof(), mcachedsv.get()); // 生成动态天空盒后必须要生成mipmap链 devicecontext->generatemips(mdynamiccubemapsrv.get()); effect.setviewmatrix(camera.getviewxm()); effect.setprojmatrix(camera.getprojxm()); // 恢复绑定的动态天空盒 effect.settexturecube(mdynamiccubemapsrv); // 清空临时缓存的渲染目标视图和深度模板视图 mcachedsv.reset(); mcachertv.reset(); }
gameapp::drawscene方法
在gameapp类多了这样一个重载的成员函数:
void gameapp::drawscene(bool drawcentersphere);
该方法额外添加了一个参数,仅用于控制中心球是否要绘制,而其余的物体不管怎样都是要绘制出来的。使用该重载方法有利于减少代码重复,这里面的大部分物体都需要绘制7次。
假如只考虑daylight
天空盒的话,无形参的gameapp::drawscene
方法关于3d场景的绘制可以简化成这样:
void gameapp::drawscene() { // ****************** // 生成动态天空盒 // // 保留当前绘制的渲染目标视图和深度模板视图 mdaylight->cache(md3dimmediatecontext, mbasiceffect); // 绘制动态天空盒的每个面(以球体为中心) for (int i = 0; i < 6; ++i) { mdaylight->begincapture(md3dimmediatecontext, mbasiceffect, xmfloat3(), static_cast<d3d11_texturecube_face>(i)); // 不绘制中心球 drawscene(false); } // 恢复之前的绘制设定 mdaylight->restore(md3dimmediatecontext, mbasiceffect, *mcamera); // ****************** // 绘制场景 // // 预先清空 md3dimmediatecontext->clearrendertargetview(mrendertargetview.get(), reinterpret_cast<const float*>(&colors::black)); md3dimmediatecontext->cleardepthstencilview(mdepthstencilview.get(), d3d11_clear_depth | d3d11_clear_stencil, 1.0f, 0); // 绘制中心球 drawscene(true); // 省略文字绘制部分... }
至于有形参的gameapp::drawscene
方法就不在这里给出,可以在项目源码看到。
使用几何着色器的动态天空盒
这部分内容并没有融入到项目中,因此只是简单地提及一下。
在上面的内容中,我们对一个场景绘制了6次,从而生成动态天空盒。为了减少绘制调用,这里可以使用几何着色器来使得只需要进行1次绘制调用就可以生成整个动态天空盒。
首先,创建一个渲染目标视图绑定整个纹理数组:
d3d11_render_target_view_desc rtvdesc; rtvdesc.format = texdesc.format; rtvdesc.viewdimension = d3d11_rtv_dimension_texture2darray; rtvdesc.texture2darray.firstarrayslice = 0; rtvdesc.texture2darray.arraysize = 6; rtvdesc.texture2darray.mipslice = 0; hr(device->createrendertargetview( texcube.get(), &rtvdesc, mdynamiccubemaprtv.getaddressof())); rtvdesc.
紧接着,就是要创建一个深度缓冲区数组(一个对应立方体面,元素个数为6):
d3d11_depth_stencil_view_desc dsvdesc; dsvdesc.format = dxgi_format_d32_float; dsvdesc.viewdimension = d3d11_dsv_dimension_texture2darray; dsvdesc.texture2darray.firstarrayslice = 0; dsvdesc.texture2darray.arraysize = 6; dsvdesc.texture2darray.mipslice = 0; hr(device->createdepthstencilview( depthtexarray.get(), &dsvdesc, mdynamiccubemapdsv.getaddressof()));
在输出合并阶段这样绑定到渲染管线:
devicecontext->omsetrendertargets(1, mdynamiccubemaprtv.get(), mdynamiccubemapdsv.get());
这样做会使得一次调用绘制可以同时向该渲染目标视图对应的六个纹理进行渲染。
在hlsl,现在需要同时在常量缓冲区提供6个观察矩阵。顶点着色阶段将顶点直接传递给几何着色器,然后几何着色器重复传递一个顶点六次,但区别在于每次将会传递给不同的渲染目标。这需要依赖系统值sv_rendertargetarrayindex
来实现,它是一个整型索引值,并且只能由几何着色器写入来指定当前需要往渲染目标视图所绑定的纹理数组中的哪一个纹理。该系统值只能用于绑定了纹理数组的视图。
struct vertexpostex { float3 posl : position; float2 tex : texcoord; }; struct vertexposhtexrt { float3 posh : sv_position; float2 tex : texcoord; uint rtindex : sv_rendertargetarrayindex; }; [maxvertexcount(18)] void gs(trangle vertexpostex input[3], inout trianglestream<vertexpostexrt> output) { for (int i = 0; i < 6; ++i) { vertexpostexrt vertex; // 指定该三角形到第i个渲染目标 vertex.rtindex = i; for (int j = 0; j < 3; ++j) { vertex.posh = mul(input[j].posl, mul(gviews[i], gproj)); vertex.tex = input[j].tex; output.append(vertex); } output.restartstrip(); } }
上面的代码是经过魔改的,至于与它相关的示例项目cubemapgs
只能在旧版的microsoft directx sdk的samples中看到了。
这种方法有两点不那么吸引人的原因:
- 它使用几何着色器来输出大量的数据。不过放眼现在的显卡应该不会损失多大的性能。
- 在一个典型的场景中,一个三角形不会出现在两个或以上的立方体表面,不管怎样,这5次绘制都没法通过裁剪,显得十分浪费。虽然在我们的项目中,一开始的做法也是将整个场景绘制到天空盒的一面,但是我们还可以使用视锥体裁剪技术来剔除掉那些不在视锥体的物体。使用几何着色器的方法不能进行提前的裁剪。
但还有一种情况它的表现还算不俗。假如你现在有一个动态天空系统,这些云层会移动,并且颜色随着时间变化。因为天空正在实时变化,我们不能使用预先烘焙的天空盒纹理来进行反射/折射。使用几何着色器绘制天空盒的方法在性能上不会损失太大。
模型的折射
dielectric(绝缘体?)是指能够折射光线的透明材料,如下图。当光束射到绝缘体表面时,一部分光会被反射,还有一部分光会基于斯涅尔定律进行折射。公式如下:
\[n_{1}sinθ_{1} = n_{2}sinθ_{2}\]
其中n1和n2分半是两个介质的折射率,θ1和θ2分别是入射光、折射光与界面法线的夹角,叫做入射角和折射角。
当n1 = n2
时,θ1 = θ2
(无折射)
当n2 > n1
时,θ2 < θ1
(光线向内弯折)
当n1 > n2
时,θ2 > θ1
(光线向外弯折)
在物理上,光线在从绝缘体出来后还会进行一次弯折。但是在实时渲染中,通常只考虑第一次折射的情况。
hlsl提供了固有函数refract
来帮助我们计算折射向量:
float3 refract(float3 incident, float3 normal, float eta);
incident
指的是入射光向量normal
指的是交界面处的法向量(与入射光点乘的结果为负值)eta
指的是n1/n2
,即介质之间的折射比
通常,空气的折射率为1.0
,水的折射率为1.33
,玻璃的折射率为1.51
.
之前的项目中material::reflect
来调整反射颜色,现在你可以拿它来调整折射颜色。
在hlsl里,你只需要在像素着色器中加上这部分代码,就可以实现折射效果了(geta
出现在常量缓冲区中):
// 折射 if (grefractionenabled) { float3 incident = -toeyew; float3 refractionvector = refract(incident, pin.normalw, geta); float4 refractioncolor = texcube.sample(sam, refractionvector); litcolor += gmaterial.reflect * refractioncolor; }
项目演示
该项目实现了反射和折射
directx11 with windows sdk完整目录
欢迎加入qq群: 727623616 可以一起探讨dx11,以及有什么问题也可以在这里汇报。