文章背景:这是我刚入职公司时接到任务,对playcanvas不是很熟悉,公司前人以为playcanvas上传hdr生成天空盒dds文件,必须上playcanvas官方编辑器手动上传下载才可以,所以希望我用python实现这个效果。(后面接手playcanvas引擎后,发现其实在github issue里面就有这个算法解释,而且引擎源码里面就有对应的js代码),虽然是个乌龙,但我觉得当时的探索过程可以分享出来。
一、playcanvas天空盒的原始流程
步骤一:利用3D软件,将一张全景图(hdr),切割成6张hdr图片
步骤二:登录playcanvas官网,使用它提供的editor,创建一个场景,上传6张hdr,生成6张png图片(用于天空盒四周的纹理贴图)
步骤三:在editor上添加cubemap,贴上6张图的位置,下载生成后的dds(用于天空盒影响下模型反射)
步骤四:在playcanvas engine上,添加步骤二的6张png图 和 步骤三dds文件 的 代码到数据库,使用该天空盒。
二、自己实现天空盒的意义
从上面原始流程上看,要使用playcanvas的天空盒,前期必须在playcanvas生成png和dds的操作,再由开发人员添加代码,才可生成天空盒,对用户并不友好。
而我们的目标:在我们的服务上,用户只要上传一张全景图,后台自动处理后,直接生成天空盒
所以现在需要解决的问题点:
1、如何将一张全景图切割成6张立方体贴图
2、如何对这6张贴图进行编码(因为playcanvas engine在渲染时是有进行解码的,至于playcanvas为什么这样操作, 后面会详细说明)
3、如何利用6张贴图生成dds文件
三、将一张全景图切割成6张立方体贴图
按原来的探索流程,可以使用sphere2cube(https://github.com/Xyene/sphere2cube),它是基于blender处理的,所以在不同平台,不同版本的blender切出来的贴图是有一定的差异性的。
原来探索流程是直接使用sphere2cube将hdr直接切割成6张png,所以会导致hdr存在的亮度丢失(这也就是playcanvas会有编码和解码的原因),导致生成天空盒的光源随着亮度变暗并不会 缩小成一点。
所以这里python的imageio实现全景图切割,保留了hdr的亮度信息,对应文章:https://blog.csdn.net/u014494705/article/details/107413908
四、实现playcanvas贴图的encode算法
这里讲下playcanvas处理纹理贴图的原理(当时误以为官方并没解释,按前端给的shader和效果推测,其实这个算法是RGBM,有兴趣的可以Google下,主要是通过编码将亮度信息编码到a通道)
1、HDR格式
HDR,即高动态范围图像(High-Dynamic Range,简称HDR),相比普通的图像,可以提供更多的动态范围和图像细节。
HDR在代码中的表现形式:
跟PNG类似,存在RGB三个通道,但PNG是8位,取值范围只能是(0-1 或 0-255)
而在HDR中,正常的颜色值同PNG表现出来,但HDR使用32位,允许RGB的值高于1(或255),在肉眼中,表现为越来越亮。下面用个例子说明:
这是一个点光的场景,假设用RGB中的最大值来表示亮度,
如果用png存储,我们只能知道左下方一片都是“亮”的状态,亮度都是1,而不知道那里“更亮”,那里“最亮”
但hdr不同,由于他允许RGB大于1来表示,可以存储更多的亮度层次,我们可以看看把亮度划分区间(>1,>2,>10,>100)放入R值看看效果(左边是png,右边是hdr)
2、利用PNG的Aplha通道存储光照信息
playcanvas天空盒支持调节亮度,对于含有点光的场景,我们可以实现下图效果
在playcanvas引擎的源码里,实现调节亮度只是简简单单的 "rgb * intensity"(对hdr的rgb同时乘以intensity也可以达到这个效果哦),rgb同时变大时图片会变亮,超过1的部分全部显示为白色。
但对于原图,我们显然无法实现调节,因为rgb已经存储了颜色信息,而不能存储更多的光照信息(gl只支持解析rgb<=1)。
所以playcanvas这里使用了第四个通道,alpha通道,来存储亮度信息。那么我们调节函数可从"rgb * intensity" 变为 “"a * rgb * intensity",
那么a值越大的地方,乘以调节参数,rgb就越容易超过1,就越快变白,说明越亮
3、实现RGB通道的encode算法
通过playcanvas引擎的渲染源码,我们可以发现,里面存在的encode算法,这里用伪代码:
decode.rgb = (encode.rgb * 8 * encode.a) * (encode.rgb * 8 * encode.a)
符合上面提到的利用a来存储亮度的想法,虽然多乘了8,然后进行平方
于是,我们encode算法可以写为
encode.rgb = sqrt(decode.rgb) / (8*encode.a)
而引擎渲染(decode)出来的图效果应该跟我们的原图一样,所以
encode.rgb = sqrt(src.rgb) / (8*encode.a)
但只根据decode算法,我们还无法逆向得出a通道的encode的算法,这时候,我们只能通过统计法来解决这个问题了。
4、利用统计拟合Alpha通道的encode函数
由于引擎的decode算法直接将alpha通道赋值为1,展示出来,所以playcanvas如何将hdr的亮度信息存储到alpha通道,是个黑盒子。
此时,我们需要利用统计的方式,看是否能拟合出alpha和原图的rgb有什么关系。
首先,通过playcanvas的平台,上传一张hdr,获得一张encode后的Png,画出png的a值 和 原图hdr的rgb的最大值的关系(不要问我为啥知道是取最大值,因为前面用了很多无法描述的手段)
从点的分布,貌似符合开方函数,所以我们取 a 值 和 sqrt(rgb)的函数分布试试
没错,我们可以得到一条完美的直线关系,即 encode_a = math.sqrt(max(src_r,src_g,src_b))
5、解决python的png临界像素问题
在得出上面关系后,用png做原图可以生成正常playcanvas贴图(无法调节亮度而已)。但用hdr生成的贴图,在一些含有光源处,正常图片会异常变黑,变成其他颜色。
在经过一系列实验之后,可得出下列结论:
python在存储png时,当RGB超过临界值,该值会显示成0,即(1.1,0.5,0.5)会显示成(0,0.5,0.5)。而在opengl,会处理为(1,0.5,0.5)
python在存储png时,当A超过临界值,整张图会被破坏,异常变黑
6、hdr和png转换的精度问题
在测试过程中,发现天空盒贴图若存在连续像素区域(类似天空),渲染出来后会有条纹感,如下图
这是因为hdr是32位精度,其rgb值也是32位精度,但存成png是8位精度
按我们第3步公式可以知道
encode.rgb (32位)=math. sqrt(src.rgb)(32位) / (8*encode.a)(32位)
而在渲染的时候,我们只能拿到encode.rgb (8位), (8*encode.a)(8位),都丢失了精度,反向算出来的math. sqrt(decode.rgb)可能会有一些临近像素点被合成 “块” 。
所以,在encode的,我们要先把src.rgb 和 encode.a变成 8位精度,算出8位encode.rgb ,后面逆回来的src.rgb就完全一致了
encode.rgb (8位)=math. sqrt(src.rgb)(8位) / (8*encode.a)(8位)
此时encode出来的中间图片有条纹感,decode后的图片就没条纹感了。至于为什么大家再理一理呗
五、使用cubemap合成dds
1、工具选择
playcanvas在模型反射时,并不是用周围的贴图进行映射,而是需要将6张贴图合成一个dds,利用dds来进行做贴图进行渲染
而在https://blog.playcanvas.com/the-making-of-seemore-webgl/ 这篇文章有提到,playcanvas使用的amd-cubemapgen对贴图进行合成
2、dds格式解析
dds是一种图片格式,是DirectDraw Surface的缩写,它是DirectX纹理压缩的产物。目前大部分3D游戏引擎都是选择用它进行渲染。
dds可以通过window下的photoshop安装插件进行查看,废话不多说,直接感受playcanvas合成的dds长什么样
playcanvas天空盒的dds是由6张128x128贴图合成,每张128x128都有对应的5张mipmap(分别是64x64,32x32,16x16.....)
3、模糊问题解决
在之前探索流程中,我们发现就算6张贴图是正确的,高清的,使用cubemapgen时,反射渲染出来会模糊。测试后发现修改了filter的方式即可
./ModifiedCubeMapGen.exe
-importFaceXPos:dds_right.png
-importFaceXNeg:dds_left.png
-importFaceYPos:dds_top.png
-importFaceYNeg:dds_bottom.png
-importFaceZPos:dds_front.png
-importFaceZNeg:dds_back.png
-exportCubeDDS
-exportFilename:disc.dds
-exportMipChain
# {Disc|Cone|Cosine|AngularGaussian|CosinePower}
# 默认使用CosinePower,会变模糊,也可以通过ExcludeBase不渲染第一张128x128的问题
-filterTech:Cosine
-exit
4、合成的dds具有锯齿感
废话不多说,问题如图,右图为Playcanvas效果
原因:我们先对1024的hdr进行编码成png,在压缩成128png,用于合成dds,由于先编码再模糊压缩,再解码后,压缩造成的部分图像信息会形成锯齿感
所以先把1024hdr进行压缩128hdr,再编码成128png,那么合成dds解码后效果就跟128hdr一样,不会有锯齿感
不过由于上面提到sphere2cube切割的1024hdr会有模糊感,所以还需要同第三点一同解决
5、还存在的问题
生成的mip模糊含有方块感,需要再对其mip相关参数测试
(后续发现参数如何调试都无效,最后以找到playcanvas官方实现方案解决)
六、numpy加速
由于sphere2cube在切割hdr时,会造成图片一定程度的模糊,所以切割出来的6张hdr得尽量大,这里取1024x1024,在python中使用遍历处理像素,一张贴图需要16s左右,6张贴图用了将近2分钟的时间
而使用numpy的矩阵加速后,1张贴图均在几百毫秒,这里记录一下用到矩阵实现的奇淫怪巧
1、除数为0时,希望把结果设置为0:先取倒数,把inf作为0乘进去
inverse_array = 1 / alpha_array
inverse_array[np.isinf(inverse_array)] = 0
image_array = image_array * inverse_array
2、当a通道大于1时,rgba值全部需要设置为1:把(1024,1024,4)按第3维拆分4个(1024,1024,1),使用内置索引按a=1条件赋值,后在第三维合并
r,g,b,alpha_array = np.split(image_array,[1,2,3],axis=2)
r[alpha_array>1] = 1
g[alpha_array>1] = 1
b[alpha_array>1] = 1
alpha_array[alpha_array > 1] = 1