分类目录归档:图形学

OpenShader月(nian)报 #2

咕咕,咕咕咕
(ge le, qi keng le)


时隔了很久之后...终于有了第三篇月报,在上一篇中我说到5月要肝毕设,那么,之后的时间? 之后的时间确实没有认真肝OpenShader,有很长一段时间在摸鱼,所以这段时间的进度怎么看都不像半年时间该做出来的...

  • Shadow Caster的裁剪
    在之前,阴影渲染也占据了不少性能开销,就像以前我提到在早期版本测试时使用3个阴影图的CSM时性能就急剧下降了,虽然池化VBO有效降低了渲染时的Drawcall开销,但在MC准备渲染数据时的开销依然不少,因此最好的办法就是在Shadow Caster中也进行一次裁剪,去除掉不能对玩家的视角形成阴影的物体和地形.

    关于裁剪体的计算,先将玩家镜头的视锥体朝向光源镜头的近裁面做一次投影,在投影得到的二维图形中找出最外层的边,每一个边沿着光源镜头的视线方向延伸得到面后,这些面就成了构成裁剪体的裁面.在这个裁剪体范围之内的物体大部分都能对玩家视角内行成阴影.如果说还有什么不完善的话,主要是刚才得到的裁剪体不包括近裁面和远裁面,对太阳光来说近裁面是没有必要的,而不计算远裁面就导致了一些本来不会贡献阴影的物体也被渲染了,会增加一些不必要的开销,一个未来的改进是选择玩家镜头的视锥体中,正面朝向光源的那些裁剪面,用这些裁面再做一次裁剪,就能去除掉那些false positive了.


    举例,比如如果这张图是一个渲染场景,中间高亮的橙色椎体是玩家镜头的视锥体,左上方为环境光源及其方向,圆锥、圆柱、棱角球和方块分别是4个可渲染的物体,那么在无Shadow Caster的裁剪时,这4个物体全部需要渲染.

    使用现在的视锥构建方法时,圆柱被剔除掉了,因为它即不在玩家视线内,也不会给玩家视线内的物体贡献阴影,然而方块仍然会被渲染,尽管它不会贡献阴影.

    最终目标是制作一个这样的裁剪体,保留下的物体只有圆锥 - 它会给玩家视线内贡献阴影,以及棱角球 - 它在玩家视线内.


  • 重新设计的多线程渲染流程
    显然,在OpenGL中不会有真正意义上的高性能多线程渲染(疯狂切context确实可以多线程,但它不够高性能),因此我们设计的多线程渲染流程也和其他的同类产品一样,努力压榨出更多的可在CPU端并行处理的东西,比如裁剪、收集渲染信息等,然后将它们分派到子线程上执行,主线程只保留最必要的工作:执行渲染指令.
    那么,我们先来看看上次月报(17年5月,应该叫半年报了?)时的渲染流程:


    在旧的渲染流程中,整个渲染被分为准备阶段和渲染阶段,在准备阶段程序会收集并配置渲染信息,主要工作分为镜头(计算每一个摄像机的位置和MVP矩阵)、地形(统计哪些RenderChunk需要被渲染)和实体(统计哪些实体和TileEntity需要被渲染)这三部分,此三部分有顺序依赖,不可调换顺序(地形需要镜头的信息来进行视锥裁剪,实体需要知道哪些区块要被渲染). 在旧设计中这三部分都会在准备阶段进行,尽管主线程在处理到每个部分时都会将工作分发给工作线程来进行,从而实现了并行化,但这里还有一个问题 - 主线程被完全浪费了,事实上渲染阶段其实无需等待准备工作全部完成时才进行,有些渲染操作并不涉及到地形和实体,比如天空背景和云朵;此外渲染阶段多线程的利用率非常低 - 只用到了两个线程. 因此我们重构的目标时尽可能缩短准备阶段,并尽量提高渲染阶段的多线程化程度,最终重新设计的渲染流程是这样的:

    在新的渲染流程中,只有镜头配置必须在准备阶段进行,其余的工作都是在渲染阶段进行,在启动渲染阶段时会有三个线程开始工作,主线程等待并处理渲染指令,一个工作线程生成渲染指令,另一个工作线程会串行处理地形的可见性判断,为什么这一步要单线程处理? 这涉及到它的工作方式,地形的可见性判断是用来找出哪些RenderChunk需要被渲染,主要的手段一是视锥裁剪,二是以玩家所在的RenderChunk为起点做一次广度优先搜索,为了防止搜索时重复搜索已处理过的节点,MC会在RenderChunk中标记一个时间戳,每次搜索到节点时都会尝试更新时间戳,如果更新成功(节点时戳比系统时戳旧)那么这个RenderChunk就是未被处理过的,如果更新失败(节点时戳与系统时戳一致)那么这个节点就是已经被处理过的节点,在多遍渲染时程序需要多次进行可见性判断,比如在渲染阴影时,需要有一个pass处理ShadowMap绘制的地形,一个pass处理玩家视角绘制的地形,如果想在此步骤并行处理的话,就需要把时戳换成一个原子整数,然后为每个pass分配一个bitflag,当此pass处理完一个节点时,就给那个原子整数标记上bitflag,显然,当所有的pass处理完毕之后还得再将那个原子整数清零,在旧的渲染流程中我就是这样做的,其结果呢? 嗯...确实是并行化了,但是在性能测试中发现更新原子变量的操作非常的慢,特别是最后重新清零的那个步骤,相比之下,之前串行处理时就不需要这个步骤,因为时戳始终是递增的,在处理完一个pass后,只要将系统时戳再喜+1就能继续处理下一个pass. 因此再三斟酌后,我将这一步换回了由一个线程串行处理所有pass的可见性判断. 但是之后的工作依然是并行处理,每当一个pass完成可见性判断后,程序就会再给线程池分配两个新任务 - 生成这个pass的地形渲染指令,以及准备实体的渲染信息.

  • 帧缓冲的Pingpong
    以前我在Shadersmod教程中提到过不要在一个着色器中对一个RenderTarget即读即写,因为在Texture Barrier出现之前对一个纹理在读出的同时进行写入属于未定义的行为,其结果是不可预料的,因此一般采用的策略是为一个需要即读即写的RenderTarget准备两个纹理,一个纹理作为读纹理,一个作为写纹理,每次运行完渲染后交换两个纹理的职责,原本读的下次变成写的,原本写的下次变成读的,这个方法被称为Pingpong. Shadersmod没有提供Pingpong功能,因此我们只能自行回避即读即写操作(其实回避的是异处读写,在片元着色器中原地读写的话在大部分显卡上实际上是允许的). 而OpenShader提供了Pingpong功能,在默认配置下程序会自动检测每个stage的帧缓冲和绑定的纹理,如果检测到一个纹理即被绑定到输入又被绑定到输出的话,就会自动启用Pingpong.

  • 图像统计 (WIP)
    图像统计之前我们已经介绍过了,主要是给像HDR之类的后处理特效用,不过...现在的OpenShader的图像统计功能非常菜鸡,仅仅只是达到了Shadersmod的水平,换句话说,就是只提供了获取屏幕中央深度的功能... 这个功能其实很鸡肋,想知道深度缓冲中央的值,直接给我texture(gdepth, vec(0.5, 0.5)).r不就得了嘛! 将这个值做成一个uniform除了卡流水线以外没任何用处,尽管中央深度可以通过将其替换为纹理采样来规避(怎么替换将是下一期月报的内容(豹笑)),但像统计全屏幕平均亮度这样的操作就只能靠实打实的图像统计来实现了,因此图像统计这方面还有不少工作要做...

  • RenderChunk渲染参数缓存 (WIP)
    这个...应该是最让我崩溃的部分,大部分功能在去年8月末就完成了,但由于两个折磨人的Bug导致它一直到现在都没彻底完工.

    简单地说,在之前准备渲染参数时需要程序逐一获取每一个RenderChunk的参数,这涉及到了大量的内存访问,以及随之而来的cache miss,即使是原本很简单的操作也会花费大量的时间,渲染参数缓存就是提前将这些参数缓存在一个整数数组中,将所有操作都简化成数组的复制和整数操作 -- 利用了OpenGL所有对象都以整数句柄的形式存在的特点. 比如渲染一个池化的RenderChunk时需要知道它在哪个VBO中、它的首地址偏移量和顶点数量,这些参数都可以缓存在整数数组中.

    这个优化被证明为是成功的,确实能提高不少性能,然而却带来了两个新bug.

    第一个bug是当玩家向任意一个方向移动一定距离时,就会发现反方向消失的区块会在面前出现...事先说明,这个bug已经被解决了,某天在我肝了几宿HOI4后决定再看看能不能解决这个让我崩溃了两个多月的bug,结果在灵光一现后(我一直笃信debug的时候灵感是最重要的 233)几个骚断点找出原因了,原因是我对MC自带的RenderChunk表(我对它的称呼,MCP把那个类称为ViewFrustum...咳)的理解不充分,我原本以为它会完整地利用所有空间,也就是表中每一项都会是一个可见的RenderChunk,然而实际上MC并没有充分利用每一个空间,在某些情况下,比如游戏载入之后玩家向某一个方向持续移动时,会出现某些项中的RenderChunk存在但却不该被渲染的情况. 原版MC中通过一个RenderChunk中的标记来判断是否该被渲染,而我在编写渲染参数缓存时没能正确缓存这个属性,结果导致了这个问题.

    远处那一排非常突兀的地形就是bug所导致的情况,实际上它们应该是在我背后过来的地方的某排区块.

    而第二个bug...则把我困惑到怀疑人生. 简单地说,在设计上,当内存池发生变化时,变化的内容(VBO的id、指针偏移量、数据长度)会被立刻刷新入缓存,然而现在存在的问题是在罕见的情况下缓存和实际内容会有一帧的不同步,结果导致因使用了错误的参数而渲染出错.

    出错的一帧的截屏

    这个bug一直到现在我都没能解决...最近倒是想出了一个workaround,暂时还没有实现,看看下一次更新前能不能做完吧 (鸽?)

  • 一些...杂七杂八的东西
    因为没有changelog,所以更新了什么东西都是靠翻git的变更记录来一点点查的(笑),主要是修bug和一些优化,其实现在OpenShader的状态是三十六拜后就差一哆嗦,然而这一哆嗦就是几个月都没哆嗦出来 ? 这段时间一直都在忙别的,看看这俩月能不能把最后一点肝出来吧,下一篇日志我们会介绍一些native方面的工作--
阅读全文 [...]

OpenShader月报 #1

TL;DR: 弃坑啦!!!

好吧这是开玩笑的,虽然沉寂了很长时间,怎么看都像是"好难啊不做了"的样子,但项目确实还在继续开发中.本来说好1月或2月发一个预览版本,然而实际上...实际上从1月初到3月初这段时间我基本没怎么碰OpenShader的代码,一方面是家里有事,另一方面当时我在摸鱼两个即兴项目(还特么是两个??),等想起来"哟我还有一个大坑没填"时已经是3月中旬了,所以说现在写一篇月报其实挺符合现实情况.

那么,首先是关于之前开的那些坑填的状况:

  • 池化VBO&MultiDraw (90%)
    池化VBO和MultiDraw已经基本完成了,目前现存的问题是内存占用稍微有点大,似乎还存在内存颠簸的问题,不过这些都是可以优化的. 从表现来看,池化VBO是相当成功的,砖块渲染的开销一下子从之前的第一骤降到了第三第四,甚至低于天空渲染和云渲染的开销...(谁想得到天空渲染怎么会有那么大的开销??),其实这也不意外,一个池化的VBO能承载16个Chunk(是的,16x256x16的那玩意,不是16x16x16的RenderChunk)的顶点数据,并用1次绑定加1到4次DrawCall(取决于这些Chunk的位置,大多数情况下是1~2次,极端情况会是4次)将它们全部渲染出来,在过去这需要16次绑定和16次DrawCall,而且实际情况会比这还要多 --- RenderChunk只有16x16x16,在山地之类的立体地形中渲染1个Chunk需要渲染多个RenderChunk --- 不过也有可能比这少,毕竟有些RenderChunk可能不可见.但无论如何,总体开销都不可能比池化VBO+MultiDraw要低. 在实现VBO内存池时我遇到了个吔屎的问题,我忘了OpenGL中大部分DrawCall类指令的偏移量单位是顶点而不是字节...这意味着顶点大小不同的顶点数据不能存入同一个内存池,因为数据的偏移量必须是顶点大小的整倍数,否则在DrawCall中没法指定偏移量,而顶点大小不同的数据存在一起时对齐会变得非常麻烦,因此最后我设计成使用多个内存池来存储16个Chunk的数据,每个内存池只存储特定一种大小的数据,问题解决了,虽然浪费的内存变得更多了... 不过还有一个小问题是现在KHR_Debug_Callback返回的Debug信息中经常会狂刷"Pixel-path performance warning: Pixel transfer is synchronized with 3D rendering." 主要发生在内存池扩容删除旧缓存时,这是什么鬼...
  • 指令队列 (100%)
    指令队列顺利完工了,但是表现的却不是很好,性能感觉提升了不到10%...而且Debug变得异常艰难,因为报错后只知道是在哪种指令中出错了,甚至都不知道是哪个Stage提交的哪个指令...不过也不意外,Nvidia在AZDO(Approaching Zero Driver Overhead)那篇presentation中提到他们用软件实现的指令队列的性能提升也只有14%. 现在只能期待后继的优化中将尽可能多的工作塞到指令准备时并行执行了,毕竟目前渲染准备阶段占用的时间也挺多的,如果能砍掉这部分时间(无论是优化还是并行执行)那对性能也是有很大的提升.
  • Early-Clear (0% 蛤蛤蛤)
    这一个完全没弄,主要是暂时没有特别大的需求,但今后肯定会有的.

正在进行的工作/已完成的其他工作:

  • 清理临时代码 (90%)
    花了几天清理了很多临时代码,主要是ASM那方面,之前在开发时遇到需要修改MC代码的情况时,就把MC代码复制到项目中的同名类里,利用Java类加载顺序的特性来实现覆盖原始代码.现在用ASM彻底重写了这部分了,为即将到来的开源做好了准备. 至于其他方面的临时代码(大量的TODO) 慢慢解决吧...
  • 采样器 (100%)
    采样器决定了着色器如何从纹理中获取纹素,在过去(去年12月的版本)其实已经有采样器的原型了,但是只能用来控制着色器读取哪些纹理,而不能控制着色器怎么读取,现在开发者可以设置采样器的参数了,包括纹理过滤、各向异性过滤、Wrap、Mipmap以及是否开启比较模式.采样器有什么用呢? Shadersmod中启动shadowHardwareFiltering其实就是开启Shadow map的比较模式. 采样器还有两个版本的实现,一个是针对低版本的传统模式,通过glTexParameter修改纹理的采样参数来实现;另一个是针对拥有ARB_sampler_object扩展的高版本,通过采样器对象来实现.
  • 自定义Uniform (50%)
    虽然OpenShader内置了不少Uniform参数,但毕竟总有我想不到的时候,因此总得有一种方法能让光影包开发者自行设定Uniform,目前的解决方案是让开发者可以添加一个自定义Uniform,然后监听更新事件并手动更新Uniform内容,这样的设计有两个缺陷,一个是只有Mod式的光影包能写代码来处理事件监听,另一个是MinecraftForge的居然要求事件总线的注册要在Mod初始化中进行,否则就会抛一个警告信息...我在考虑要不要设计一种DSL让开发者通过简单的表达式来描述需要获取的内容;或者利用Java的ScriptEngine内置JavaScript的特性,让开发者写JS脚本去获取数据.不过这些都无法回避一个问题,就是运行时Minecraft的字段都是混淆的...如何访问这些被混淆的字段还是个问题.
  • 图像统计 (25%)
    简单地说,图像统计是用来获取渲染结果中的某些信息,比如平均亮度(用于HDR)或中央深度(用于DOF)什么的,这个操作主要面临的问题是如何将获取到的结果再递交回去,比如说将统计到的结果再注入到一个Uniform里,这是最简单的方式,不过会涉及到CPU-GPU同步的停顿,对于这个问题,国产的开源引擎KlayGE的解决方案是使用计算着色器来统计亮度,然后将结果写入一个纹理的固定位置(DX的左上角(0,0)),后续处理时就直接从此纹理的固定位置读数据就行了,整个操作可以一气呵成,无需CPU从GPU那里读回数据,顶多有必要的话加一个屏障保证HDR发生在亮度统计之后即可,写入纹理在OpenGL中对应的操作是Image Load Store,不过假如让我来实现这个的话,我可能会选择SSBO,因为Image Load Store到4.2才进入核心扩展,而4.3就有了SSBO了... 嗯,然后你问没有计算着色器的辣鸡该怎么办? 把纹理慢慢读回内存然后让CPU慢慢跑吧,同步的停顿想着就感人...
  • 兼容Shadersmod光影包 (60%)
    听上去像是一个很不可思议的事情,但考虑到OpenShader在设计时就是具有高度可配置性,那么Shadersmod的光影包不就是一种特殊格式的OpenShader光影包吗(笑),这个月我主要就是在弄这个,期间也找出并解决了OpenShader不少问题.目前的情况是已经能跑我在光影包教程中写的那个MyFirstShader了(不过还存在一些小问题),至于别的...我试了一下SEUS,看上去能跑,但是总感觉有些差别,特别是炫光效果经常会闪烁,估计是纹理采样参数有差别;体积云也会有断层,估计是噪音图的Wrap没有设置成REPEAT而导致;流体砖块的Attribute注入也跪了,滑稽 23333
  • 无尽的Bug... (1%)
    还有很多问题需要修复,有些是缺乏边界条件时的处理,正常使用没问题,但如果使用姿势稍有不对就 --- DUANG! 还有一些就是彻头彻尾的Bug,比如原版MC的RenderChunk载入是使用一个优先队列,距离玩家最近的未加载RenderChunk永远是会被最优先处理的,这保证了玩家在移动时自己身旁的RenderChunk总是被加载出来的,而现在这个设定貌似坏掉了,变成了一个先入先出队列,结果就是当玩家都已经跑进未加载区域了,那边还在吭哧吭哧地加载旧区块...
  • 开源! \o/ \o/ \o/
    说好了的开源项目,怎么迟迟没有开源呢? 最大的阻力:临时代码已经被消灭了,我打算等Shadersmod兼容差不多实现了后就传到Github上,啊当然是创一个新仓库了,我怎么会把现在的私有仓库中羞耻的旧代码亮出来! (笑)

差不多就是这些,我去肝毕设去了(又是弃坑一段时间的节奏?),学校突然把毕设报告从6月提到了5月,药丸药丸啊 ?

4.26更新

基本能跑SEUS 10.1了,除了没有DOF (因为还没实现图像统计) 但是有个奇怪的问题就是亮的吃屎...想不出这是什么原因啊...

4.28更新

能跑SEUS10.2了,SEUS11.0也差不多不过水面有些问题.10.2屏幕过亮的问题是eyeBrightness弄错了,此外还修掉了一些奇怪的问题,不过依然有一些光影会跪的比较惨,群众喜闻乐见的Chocapic13 V6天空渲染会莫名其妙地坏掉.

另外还有一个问题可能要在很久之后修复,甚至是不修复...就是矩阵精度问题,Minecraft一直是直接用glTranslate、glRotate之类的GL函数直接操作OpenGL矩阵,而OpenShader则提供了一个封装(通过ASM魔改1.8新增的GlStateManager来实现,具体原理篇幅有限先跳过了 233),对矩阵的操作会先缓存在应用层,到下一次DrawCall之前再通过glLoadMatrix更新到驱动,这个可以降低GL函数调用的开销,然而带来的问题是OpenShader的矩阵精度好像差了一点点...

这"一点点"对常见的矩阵(模型视图矩阵和投影矩阵)没有影响,但对法线矩阵(gl_NormalMatrix,还记得我在Shadersmod教程的附录里提到的计算方法吗?模型视图矩阵的左上3x3的逆的转置)有着微妙的影响,由GL函数得到的模型视图矩阵算出的法线矩阵在乘完法线后,似乎能近乎神奇地保证法线的单位长度依然保持在1左右,至少是小于等于1;而OpenShader的矩阵呢,乘完后长度稍微大了那么一丁点...非常非常小的一丁点,但在某些鲁棒性较差的算法里就会跪了,比如说我的那个Shadersmod教程里的MyFirstShader...

MyFirstShader里用到了一个将vec3格式的法线编码成vec2格式的算法:

vec2 normalEncode(vec3 n) {
vec2 enc = normalize(n.xy) * (sqrt(-n.z*0.5+0.5));
enc = enc*0.5+0.5;
return enc;
}

...

vec2 normal = normalEncode(gl_NormalMatrix * gl_Normal);

最要命的地方在于那个sqrt(-n.z*0.5+0.5),它假定法线的长度一定不超过1,因此z一定是介于[-1,1]的,由此它才可以放心大胆地计算sqrt(-n.z*0.5+0.5)而不用担心-n.z*0.5+0.5小于0时sqrt蹦个NaN,但是由于矩阵精度问题,这里的法线长度超过1了,于是n.z=1.00001, -n.z*0.5+0.5=-0.000005, sqrt(-0.000005)=NAN,完.IEEE754规定NaN参与的运算都会变成NaN,就像JS中的undefined那样毒性十足,于是整个算法就跪了,编码出的法线在特定的角度(一般是平面垂直玩家视线时)会毫无征兆地突然崩掉.解决的方法其实非常简单,给gl_NormalMatrix * gl_Normal套个normalize就行了,幸运的是市面上几乎所有光影包在获取法线时都是用normalize(gl_NormalMatrix * gl_Normal) (你问我的那个教程光影? 它可不算"流入市面"哦?),因此基本不用担心这个问题,所以我也不太急着修复它...(更何况我也不知道如何修复 hehe)

4.29更新

试了一下发现Chocapic13 V3也会跪掉,原因是Chocapic13 V3使用了一个Shadersmod非常罕见的特性:DRAWBUFFERS中允许空白占位符,比如DRAWBUFFERS:NNN1N2就是gl_FragData[3]向colortex1输出颜色,gl_FragData[5]向colortex2输出颜色,而gl_FragData[0]、[1]、[2]和[4]不向任何RenderTarget输出.我当时没料到还有这种操作 233 V6跪掉的原因是Optifine内置的新版Shadersmod似乎支持在一个pass中向一个缓冲同时即读即写了,奇怪的是它的文档中却明确提到"Writing to color attachments that the composite shader also reads from will generate artifacts" 难道他自己搞了大新闻结果忘了更新文档了吗 ? 其实针对即读即写的情况启用Ping-pong我之前已经打算把它作为OpenShader相对Shadersmod而言的新特性了,不过因为需求不大一直没实现,现在看来是被Optifine将了一军了 233

阅读全文 [...]

OpenShader月报 #0

虽然没有做paperwork的习惯,但我觉得在这时候还是写一篇总结这个月工作进展的文章比较好,毕竟卫星放出来如果没时不时地搞点大新闻的话总会让人觉得弃坑了是吧 233
  • RenderChunk更新优化
    众所周知MC的地图是以16x256x16的Chunk - 即所谓的区块 - 为存储单位,但在渲染时是以16x16x16的RenderChunk为单位进行更新和渲染,问题在于RenderChunk的更新并不是很快,在i5-4590的机器上大概需要6ms每单位,虽然MC已经引入了多线程更新RenderChunk的设计,但这只局限于由地图加载导致的更新,由砖块变化产生的更新依然要求单线程更新,这就导致了当发生大规模的砖块更新时游戏帧率会骤然下降,比如大规模流水或接入高频红石电路的红石灯等.
    RenderChunk的更新操作实际上是遍历区块中16x16x16的部分逐砖块地进行可见性判断,然后将砖块的模型面填充入顶点缓冲中,其中获取光照度和获取砖块的IBlockState这两步操作开销莫名的大,而在原版的更新流程中它们的调用又十分频繁,因此在此做一个简单的缓存就能戏剧性地加快原版更新速度.
    然而Forge还增加了它自己的更新流程,被称为ForgeLightPileline,顾名思义,它是解决在复杂模型下原版光照错误的问题,在默认情况下它是取代原版流程的,它的速度跟未经优化的原版差不多,然而它的可优化空间实在不大,一堆堆的三维数组像节日彩球一样在程序里传来传去还要不停地拆包打包可真没有什么优化手段,但是没有它又还真不行,因此这里采用的措施是增加一个判断器根据被渲染的砖块的模型类型来判断该采用哪个更新流程,毫无疑问这一步并不快,因此加入了一个LUT缓存,采用打表查表的方式来加速判断.
    此外还有一个被临时取消掉了的优化,是让上文提到的发生变化的砖块也进行多线程更新,这一项优化效果斐然,但是却存在两个巨大的缺陷,一个是在首次进入游戏时有大概5%的概率因为某个竞态条件而陷入卡死 - 提交入队列的更新任务莫名其妙地没有被执行,这个问题我调查了几天也没有结果;如果说它可以通过一个条件变量强制在首次更新时采用单线程来"解决"的话,下一个问题根本无法绕过,在更新区块时,某些BlockRenderLayer的变化要到下一帧才会反馈,首先要解释一下BlockRenderLayer(以下简称layer),从1.8开始MC中的砖块被区分为4种layer: SOLID(无Alpha测试,有Mipmap)、CUTOUT_MIPPED(有Alpha测试,有Mipmap)、CUTOUT(有Alpha测试,无Mipmap)和TRANSLUCENT(开启混合). 在渲染时MC会将按照这4个layer逐层渲染砖块,原因很显然,不同的layer需要不同的渲染状态.而在引入多线程更新后遇到的问题就是,当区块发生更新时,某些layer中的顶点变化总会慢一帧才能表现出来.
    比如,草地的layer是CUTOUT_MIPPED,石头和泥土地是SOLID,当你敲碎一个放置在草地旁的石头后,石头破碎的地方不会被填上草地,而且会露出一个空洞,这个空洞直到下一帧时才会补上,然而奇怪的是设断点会发现更新只发生了一次并且是发生在敲碎石头的那一帧,这难以解释为何一次更新的效果直到下一帧才会生效,在更新完毕后将数据从客户端(CPU/内存)上传到服务器(GPU/显存)的操作无论是单线程版本还是多线程版本都会被留在主线程进行,或许是由于内存可见性什么的问题,但不管怎么说,具体原因始终未能确定,因此只能暂时取消掉多线程更新这个优化.
    图:在击碎砖块的瞬间留下的空洞,这个空洞在下一帧即被填上了
  • 自定义砖块贴图
    Shadersmod增加了法线贴图"_N"和高光贴图"_S"两种额外的砖块贴图,现在OpenShader也完成了新增砖块贴图的功能了,开发者可以自定义要载入的贴图的后缀名以及当无贴图时的默认值,比如法线的默认值可以采用0xFF7F7FFF(BGRA格式的(1, 0.5, 0.5, 1)). 不过显然在切换光影包时重新载入一遍贴图会很慢,因此Mod提供了一个选项是提前缓存_N和_S两种后缀的贴图并且在切换光影包时不卸载它们.
  • 界面&选项
    现在已经有了一个类似Shadersmod那样的光影包菜单,并且实现了切换或重载,理论上讲Mod现在已经可以正常使用了.
  • 多线程渲染准备
    在进行正式渲染之前,MC需要先准备渲染数据,比如计算镜头数据、对场景做视锥裁剪等,这一部分在之前是单线程完成的,现在这一步是以Pass为单位进行多线程处理,当Pass数量小于等于CPU核数时,渲染准备所需的时间就从"所有Pass准备时间之和"变成了"最慢的Pass的准备时间",也算是省出了一些时间吧...
正在进行的工作:
  • 池化VBO&MultiDraw (60%)
    当我在测试用的光影包中尝试实现一个朴素三重CSM(不根据视锥做偏移,阴影镜头始终对准玩家)时发现性能急剧下降,从Profiler的结果来看一个问题是最大视距的那个阴影镜头所需要的准备时间太长了,另一个问题是砖块绘制过慢,在10视距时,玩家视角与3个ShadowMapping过程总共需要绘制1000+次RenderChunk,绘制一个RenderChunk时还要为每个layer绑定一次VAO再做一次Drawcall,这样算下来每帧会需要数千次Drawcall和等量的VAO绑定,性能瓶颈妥妥地是在驱动上,事实上确实当显卡占用没增加多少而CPU却有一个核心已经跑满了. 因此问题在于如何降低调用数量,N卡有个BindlessMultiDrawIndirect,可以在不绑定VAO/VBO的情况下进行单指令多渲染,但显然我们不能指望一个供应商独占扩展...因此我把注意力集中到了最简单的MultiDraw上,MultiDraw是个很早的特性,我记得好像是GL1.5时代就有了...它可以一次指令绘制一个几何体中多个不连续的部分,而缺点是中途不能切换纹理等,显然这对MC来说不是问题,关键在于如何将多个RenderChunk中的几个layer的顶点数据都整个在一个VBO中.
    我的解决方案是以4x16x4个RenderChunk为一组(也就是覆盖4x4个Chunk)使用一个手动维护的内存池来共同存储所有layer的顶点数据,这个内存池的实体是在显卡显存中的一个VBO,在客户端程序会负责空间分配以及数据传输等工作,当RenderChunk向内存池首次提交一次数据更新时,内存池会根据数据体积分配一段略大于数据体积的空间,然后返回给提交者一个指针(有意思的是,这个指针是可变值(Mutable),我知道一个可变值指针听上去即不可思议又恶心,但这确实能省下不少事),同时会在一个SortedSet中记录已分配的指针,有个例外是空数据提交产生的指针,这种指针不会被插入到那个SortedSet中.
    每一个内存池都要面临的问题是碎片整理和扩容,理论上这个内存池可以通过glCopySubBuffer来整理碎片,只要将末尾的内存复制到前面的空隙即可(glCopySubBuffer有个特性是复制源范围和目标范围不能重合,这导致了复制空隙紧后面的数据到前面来,以"冒泡"的形式整理碎片的方法不能用了),但我暂时还没实现,现在的做法是在扩容的同时顺便进行碎片整理,glBufferData在扩容的时候不会保留旧的数据,因此得先分配一个新的VBO,然后把数据复制过去,再销毁旧的.在复制时如果遇到空隙的话就会跳过去,同时纠正之后的指针的偏移量(这也是为什么它是可变值)
  • 指令队列 (30%)
    鉴于OpenGL要求必须在主线程渲染的特点,压榨性能的方式之一就是将渲染数据的整理工作通过多线程来处理,主线程只负责指令提交工作,指令队列响应了这一思想,所有渲染操作都被分类为一些指令,一个阻塞队列负责缓存这些指令,协线程负责收集数据和生成渲染指令,并将指令送入队列,主线程从队列取出指令并执行,这应该可以一定程度上提高性能,毕竟拥有更高的并行度,并且主线程执行的工作更单一,不会受CPU缓存污染的困扰. 现在,唯一的问题在于,从队列中读取指令并执行的开销会抵消这些提升吗...
  • Early-Clear (0% :P)
    上文提到了渲染和数据准备是分开的,但事实上有一种渲染操作"基本"无需数据 - 清除缓冲,说它"基本"是因为主视角的清除颜色缓冲还依赖于雾颜色,而雾颜色的计算是在相机计算中进行的,因此可以在完成相机计算后主线程提前开始为各个Pass进行缓冲清除,而协线程继续准备其余的数据.
  • 很...很多... (紫asdfghjkqwertyuiozxcvbnm%)
    显然今年没Demo了! 看看明年1月卫星能不能落地吧! 新年快乐 🙂
阅读全文 [...]

[新坑预告] Yet another shadersmod implementation

最近闭(zhuang)关(si)了几个月,我并没有被Hello Games干掉(顺便在这里祝贺他们没有跑路而坚持在Steam黑五打折期间出了个主要更新,以及敢于向玩家们弹小窗的勇气 - 他们是我见过的第二个向玩家弹窗发送更新通知的游戏厂商,第一个是制作MachineCraft的G2CREW) 这4个月我基本都在过着6点睡2点起(嗯,早晨6点,下午2点)每天见不到几个小时的太阳的生活,在肝腻了文明V(没有I)、舰R、崩3、WT后我终于决定该写点什么,正好手头这个秘密开发了5个月的坑终于有些眉目了,因此决定放个预(wei)告(xing),有人说把坑公开出来就能督促自己不弃坑,然而对我这样的脸皮略厚的人来说好像并没有什么用...2014年我公布了AsmEventBus(基于ASM的Java事件总线系统,比Guava那个基于反射的系统要快很多)和一个没公开名字的Java模块系统(如果称它是"类OSGI"显然有些装逼,但它做的事确实和OSGI差不多) 然后理所当然地坑了,其实对于前者我挺耿耿于怀的,明明只要再完善一点就是个很好的库...今年年初在日狗的软工作业要求下不得不放了个某文字H游戏引擎的Java开源复刻卫星,然后,嗯,然后没下文了.写到这时我自己都忍不住掩面笑了一下,看来羞耻柱这种东西对我这样倒错的人来说并没有什么监督作用.其实我挺怀念11~12年的那段时光,那段不知失败为何物敢于写任何自己想写的代码的日子,在11年的最后一个月我在这个房间的同一个角落半生不熟地用C#写一个文字猎奇游戏,用慢的吔翔的GDI+在WinForm上画文字(现在一想其实挺像ERA啊,当时我要知道ERA的存在的话是不是就给ERA写脚本去了?) 最吃精(?)的是当时我居然在试图写一个自己设计的脚本语言的解析器,幸好当时没做出来,不然这足以让现在的我感到自愧不如,有人说好的程序员应当在看到自己6个月以前写的代码时能发觉自己现在的进步,这么说我应该尽快删IDE退圈了. (笑) 不过我倒真的挺怀念那个项目,毕竟能亲手(即使是只有文字)肢解幻想乡的女孩子怎么想也是一件刺激又有趣的事情,有机会的话我一定要把它复刻出来. (啊呸)

怀古伤今的时间到此为止了,现在说说手头上这个坑,当时提出做MC光影Mod的复刻这个概念是在什么时候已经无从考证了,印象中是14年7月我和ici2cc给CustomSteve做光影Mod兼容时第一次提出了自己做光影的想法,不过当时由于很"容易"地完成了CS与光影的兼容工作,因此这个念头就被打消了.一年半后的16年2月我写完了光影包教程后和ici侃大山时聊到了光影Mod的种种不足,当时我开玩笑地提出自己也打算写一个光影Mod,ici沉默半响后问道你是认真的吗,这时我才开始真正考虑这件事,具体的讨论过程记不清了,结论大概是坑太大填不起,而且当时我刚完稿光影Mod教程很累,也并不想就这么立刻废掉自己的工作成果,更重要的是我想去填一个自己之前挖出来的大坑(这个坑不想提了...也不用猜,"基本上"从未公布过) 因此这件事就被放下了,时间到了6月,那时不知在哪我看到了一个消息,Continuum光影包的作者在用C++给MC写一个渲染器,不用说也知道它会支持第三方光影包,当时就把正在补伊里野的天空的我吓得把播放器关了,为什么呢,因为当初我被Continuum的作者肛过一次(大雾) 2月初我在撰写光影包教程的最后两章时ici弹小窗告诉我"被抢先了",当时吓得我差点提前去见幽幽子,ici赶在我失神之前发来条链接,我赶紧缓过来点开一看是个油土鳖视频列表,上面三四片Continuum的作者录制的光影包教程视频,简单地看了一下后我半自我安慰地得出了个结论:(局座脸)这教程,飞不起来! 本着公平竞争的原则,这里贴出视频列表地址,为什么那么说呢,我感觉他过度死扣光照、ToneMapping和PBR,而忽略了对光影Mod特性的介绍,最简单的例子,除了我的那篇附录以外还能从哪找到对光影Mod的技术规范文档呢,恐怕官方Wiki都没有这么详细,然而话是这么说,但毕竟人家已经抢先发出来了,"第一个光影Mod教程"这个头衔是抢不到了,只好奋笔疾书去抢"第一个成文的光影Mod教程" (笑) 这也是为什么我的教程中最后两章明显的很潦草的原因 (当然,我写烦了也是一个原因...) 后来我的教程发出来了,而他的教程弃坑了,我还顺手打了两发对他的黑枪 (诶嘿,我这人咋就这么爱打黑枪呢) 一个是他那个"号称世界最强却实际只是又一个C13衍生品的光影包",一个是他开的"MC的Vulkan渲染器"坑.故事就看似告一段落了,然而我万万没想到的是他那个Vulkan渲染器在知难而退后又蜕变成了"C++写的OpenGL4.5渲染器",然后又把我肛了一次!被一个人肛两次这事能忍吗!

然而当时是考试周,我只能忍下去了 (笑) 考试结束后我开始探究技术可行性(后来证明这几乎是无用的,所有遇到的问题这时都没发现) 并于7月的实习第一天在那间位于商住一体楼里的破房子中建了项目的Git仓库,版本记录显示前三天我写的代码除了Mod主类和Coremod的LoadingPlugin以外没有一行保留到了现在 (手动滑稽) 一方面说明了那个"百分之多少(记不清了)的代码是要在一年内被重构掉"的理论是正确的,另一方面说明了当时我是有多么低估了问题,虽然那次糟糕的实习让我失去了去CJ和Jeb见面(对此ici可以吹一辈子(笑))以及勾搭上养猪场的机会并且错过了魔都THO,但如果说那一个月实习有什么用的话,那就是让我塌下心能用当初徒手肝解析器的劲头从零制作"Yet another shadersmod implementation",不,它不叫"Yasi"或什么的(虽然现在看起来还挺酷!) 也不叫ShaderCraft云云,我将其命名为OpenShader,因为Open这个词对于开源程序员来说就如同猫薄荷对猫一样充满魅力,显然这是开源的,不过我将它暂时托管到了私有仓库中,因为当初我也实在不敢确定它可以完成,毕竟我之前失败过的太多了,事实上,直到11月初时它甚至还没法正常运行,而我最初的计划是10月初给出一个Demo...当初我写光影包教程时也曾"计划"在10月初完稿,不过现在看来这次我似乎不必拖到次年2月了,然而Mod维护是一件长期的工作,不是吗?主要的技术突破都是在11月中完成的,现在它已经实现了:

  • 高度自定义的渲染管线,可自定义每一帧(Frame)渲染时采用的Pass,以及每一Pass包含哪些绘制阶段(Stage)
  • 着色器加载系统,包含一个简单的Includer实现(无需那个ARB扩展).
  • 可自定义的帧缓冲和帧缓冲的挂件(颜色、深度、模板),包括挂件的尺寸和格式.
  • 顶点着色器的顶点属性(Attribute)注入.
  • 一致变量(Uniform)注入.
  • 优化的GlStateManager,尽可能地减少OpenGL调用.
  • 特性(Feature)系统,用于开启、关闭或改变一些MC的功能与属性,比如设置太阳偏斜(光影Mod的sunAngle)
  • 使用VAO渲染区块 (WOW!) 理论上讲90%+的显卡都支持ARB_vertex_array_object和APPLE_vertex_array_object中的一个,如果有哪个辣鸡卡两个都不支持,那它也不见得跑得动着色器.
  • 支持Mod式和外部文件式的光影包(其实只实现了前者)

当然,它还有一个不短的TODO List,由于没有写TODO List的习惯(??)这里先随手写上一些能想到的:

  • 纹理系统,比如光影Mod的载入法线和高光纹理,以及载入外部纹理文件,可以搞基于LUT的颜色校正啦.
  • 用户界面.
  • 外部文件式的光影包的解析.
  • 资源管理...
  • 条件允许时使用UBO更新一致变量.
  • 世界上有两群人需要人间会社程序员的人间关怀:乌干达的可怜儿童,和苹果机用户.
  • 还有一些疯狂的念头,不过都是要在前面这些完成的前提下才有...
  • 大量的,关于紫sama蓝sama幽幽sama觉sama恋sama秦心酱和玛艾露贝莉x莲子的福利

想说的暂时是这么多,如果成了的话喜大普奔,再一次弃坑了的话那就又是一次喜闻乐见的自挂城墙,我先睡觉(jue)去了...这里贴一个Mod式光影包的光影包初始化代码和渲染管线构建代码的一部分 (群众:有毛用啊!) 可以大概了解一下它的API风格,毕竟将来有了外部配置文件式的光影包后,基本上也会跟它差不多:

53057998_p0

不好意思,贴错了...



20161203071732 20161203065401

这个才对 √

阅读全文 [...]

[3D图形学]视锥剔除入门(翻译)

最近在学3D图形学...看到篇不错的视锥剔除入门教程,于是搬来翻译了...

时至今日,许多刚刚下海的3D引擎程序员仍不了解视锥剔除(Frustum Culling)的重要性和益处,这让我和我的小伙伴们感到很震♂惊.我在Flipcode论坛中发现尽管网络上有海量的相关资料,仍有许多人提出对视锥剔除实现的问题.因此我决定撰写这篇文档,简单描绘出我现在所使用的四叉树剔除引擎(Quad-tree Culled Engine)的工作方式.诚然,市面上有许多种成熟且高效的视锥剔除算法,但我认为这个算法足以用来学习视锥剔除的理论基础.在正式开始前我还想说明一件事,以前我一直把Frustum(平截头体)打成Frustrum(截头锥),为此我没少被论坛上的人喷.在这里我承认Frustum是正确的拼写.对那些以前被我冒犯的人我表示抱歉...你们这群吹毛求疵的傻[哔-]...

大多数人已经知道什么是视锥剔除了(译者:如果你是手滑误点进来的...视锥剔除是一个图形渲染前的步骤,用于剔除掉不需要绘制的部分).视锥(准确说是平截头体Frustum)的形状酷似一个塔尖被削平了的金字塔,更准确地说,是一个四棱锥的顶点偏下位置被一个裁面(Clipping Plane,见图1)裁断.事实上,视锥本身就是由6个面所组成.这6个面被称为近裁面,远裁面,上裁面,下裁面,左裁面,右裁面.视锥剪裁仅仅是一个用来判断物体是否需要被绘制的过程.尽管从本质上讲视锥剔除应该是三维层面的,但事实上大多数时候它仅仅需要以纯代数的方法便能解决.这也是为什么我如此推崇视锥剔除的原因,它非常的快(如果算法好的话),而且是在渲染管线(Rendering Pipeline)之前进行的,不像背面剔除(Backface Culling)那样需要在渲染管线之后一个顶点一个顶点地计算.对于被剪裁掉的物体都不会将其送入显卡(译者:那是...被剔除掉的压根都不用渲染),因此视锥剔除对渲染速度有巨大的改善,毕竟什么都不渲染是最快的渲染... 阅读全文 [...]