Jan 24

练手:屏幕空间反射绘制(Canvas实现)

Lrdcq , 2020/01/24 02:40 , 程序 , 閱讀(2052) , Via 本站原創
之前用canvas手动编写了光照/阴影绘制(https://lrdcq.com/me/read.php/110.htm)与空间阴影/投影绘制(https://lrdcq.com/me/read.php/115.htm),接下来花了相当多的时间了解实时图形学工程实践中光线反射是怎么解决的。当然,除了光追,实际使用的最多的都是类似于预渲染或者探针类构建时方案,要么就是多个摄像机成本极高,唯独屏幕空间反射SSR(Screen Space Reflection)能一定程度的做到较高精度低成本实时反射渲染,也可以快速上手写demo试试。

Demo:https://lrdcq.com/test/ssr-canvas/,确实相当卡,就不嵌入iframe了。

设计思路

屏幕空间反射的核心思路是为做渲染后流程,低成本的对已渲染的画进行反射计算。比如如demo中的原始画面:
點擊在新視窗中瀏覽此圖片

我们渲染的同时就可以同步准备好该输出图像的深度图:
點擊在新視窗中瀏覽此圖片

与法线图:(不是摄像机空间法线图,而是摄像机正交空间的法线图。摄像头空间无法直接用于反射计算)
點擊在新視窗中瀏覽此圖片

同时我们的输入当然还包括摄像机矩阵本身与摄像机的位置。

- 有深度图与摄像机矩阵,我们就可以还原出可视面上完整的正交空间的实际xyz情况。每一个可视像素均可获得其正交空间坐标。
- 同时配合正交空间的法线情况,我们已经获得可视空间也就是屏幕空间的全部信息。

那么,计算反射线的流程是:

- 对于每个像素点,获取正交空间中的坐标,即可获得摄像机到点的入射向量。
- 有入射向量与这个点的法向量,就可以计算出法线向量了。
- 用法线向量在屏幕空间中做碰撞测试,取第一个碰到的颜色作为反射颜色。

显然这个计算方式,其实是一次单次光追计算,所以也有被称为屏幕空间光线追踪。

- 这个方法被反射的目标必须是正常情况下屏幕中本来就可见的物体,所以肯定没法用来做镜面,一般是用来做地面的水,环境墙面等大概率对视角范围内进行反射的东西。

- 同时这些东西的反射质量要求也不用太高,大概能看进行了,甚至实际场景可能会做一些模糊。因为屏幕空间反射的到的反射画面大概率和本身光栅化方向不一致,因此仔细看肯定会有像素拉伸或者对齐等问题,是经不起细看的。

- 反射像素颜色和原本的颜色叠加肯定需要引入一个叠加系数,因此还需要为每一个有反射能力的贴图引入一个描述反射系数的灰度贴图,假设称为光泽度吧。本demo中光泽度影响的即每个像素的叠加系数。也需要渲染一张中间的光泽度图:
點擊在新視窗中瀏覽此圖片

。到目前为止,每一个贴图相当于搭配了一张法线贴图与一张光泽度贴图(理论上屏幕空间的法线贴图渲染结果是正方向贴图法线贴图+模型法线二次处理过的结果,这里偷懒了直接进行了预处理):
點擊在新視窗中瀏覽此圖片

实现路径

通过canvas实现如图效果,首先要确认的是固定摄像机矩阵。虽然这个demo依旧可以用正交摄像机,但是透视摄像机明显可以更好的实现ssr效果,并且由于正交摄像机后景屏幕外未渲染的画面过于多,因此也不适合通过ssr来实现反射效果。

通过定义相机位置,一般的透视相机矩阵为4x4的:
[[1, 0, 0, 0],
 [0, 1, 0, 0],
 [0, 0, 1, 0.005],
 [0, 0, 0, 1]]

当然,其中控制透视z轴缩放比例的就是 0.005这个数字。一般来说一个vec3叠加相机矩阵的方法是:
        changeByMatrix4(te) {//假设Matrix4是一个0-15的数组
            const vx = this.x, vy = this.y, vz = this.z;
            const d = 1 / ( te[3] * vx + te[7] * vy + te[11] * vz + te[15] );

            this.x = ( te[0] * vx + te[4] * vy + te[8] * vz + te[12] ) * d;
            this.y = ( te[1] * vx + te[5] * vy + te[9] * vz + te[13] ) * d;
            this.z = ( te[2] * vx + te[6] * vy + te[10] * vz + te[14] ) * d;

            return this;
        }

如果以关键系数做简化的话,即可得到转换方法与逆运算方法:
        changeByCameraP(p) {//将正交空间点转化为摄像机空间点
            const d = 1 / ( p * this.z + 1 );
            this.x *= d;
            this.y *= d;
            this.z *= d;
            return this;
        }
        changeByCameraSP(p) {//将摄像机空间点转化为正交空间点
            this.z = this.z / ( 1 - this.z * p);
            const d = 1 / ( p * this.z + 1 );
            this.x /= d;
            this.y /= d;
            return this;
        }

通过这个方法,即可通过手动光栅化的方法用canvas渲染出带贴图本身的效果。即可实时获得以上的原始贴图了。

核心参数计算

模拟片段着色器,对屏幕中的每一帧进行处理。

a. 优先进行的是光泽度图贴图的处理,定义这个像素点是否要进行反射计算,与反射比例
const hasRef = getImageValue(canvas_ref_data_r, IMG_SIZE, x, y, 3);
const refValue = getImageValue(canvas_ref_data_r, IMG_SIZE, x, y, 0);
if (hasRef && refValue) {
    p = refValue / 255;//反射叠加比
    sp = 1 - p;//原色叠加比
    //then
}

同时,通过贴图数据还原出计算过程中的关键向量:
pointC.x = x - IMG_SIZE_HALF;
pointC.y = y - IMG_SIZE_HALF;
pointC.z = - getImageValue(canvas_dep_data_r, IMG_SIZE, x, y, 0) * ZMAP_INFO.len / 256 - ZMAP_INFO.start;
//通过屏幕x y与深度图,还原出摄像机空间坐标

point = pointC.changeByCameraSP_new(camera[11]);
//通过相机参数转换出正交空间坐标
input = point.sub(cameraPos);
//再减去相机位置,即得到相机到当前点的向量,即该点反射光的入射向量

normal.x = (getImageValue(canvas_normal_data_r, IMG_SIZE, x, y, 0) - 128) / 128;
normal.y = (getImageValue(canvas_normal_data_r, IMG_SIZE, x, y, 1) - 128) / 128;
normal.z = (getImageValue(canvas_normal_data_r, IMG_SIZE, x, y, 2) - 128) / 128;
//从法线渲染图中读取出该点的法线

//反射光向量的计算公式是:
//R = I - 2 x (I · N) x N
//因此得到反射向量:
dot = input.multiplyDot(normal);
light.x = point.x - 2 * dot * normal.x;
light.y = point.y - 2 * dot * normal.y;
light.z = point.z - 2 * dot * normal.z;

反射向量碰撞测试

在片段着色器里,碰撞测试并没有什么比较好的办法,只能逐个像素往前去看,判断这条线上第一个射线超过目标深度的位置。如果这是一个代码题的话,可以描述为,有一个数组,由大量的0组成,中间部分段落插入了小段连续数字,如[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 6, 0, 0, 0, 0, 0, 6, 6, 6]。从左开始,查找第一个遇到的非0数字的index与值。期望效率越快越好。

明显这个题目最差的效率都是O(n),目标效率应该是远低于O(n),O(1)最好。。哈哈当然不可能。既然只能是往前遍历,正常的可行思路是:

a. 试试跳着便利,条一个遍历一个,效率提升一倍,如果遇到了最后再回朔一个确认具体位置。

b. 如果我们能获得一个经验值,往前和回朔能解决90%的问题,基本上作为精度要求不高的反射,就成了。

c. 剪枝,输入数组本身还是越短越好,需要判断的点越少,当然整体效率也约高。

基于以上原则,快速完成的代码如下:
//定义测试点testP
testP.x = point.x;
offsetT = light.x > 0 ? HIT_TEST_JUMP : -HIT_TEST_JUMP;//按HIT_TEST_JUMP固定往前查找,如果提效一倍就是跳2个像素,实际demo中使用的5
while (testP.x > -IMG_SIZE_HALF && testP.x < IMG_SIZE_HALF) {//testP的x控制测试范围,用于剪枝
    testP.x += offsetT;
    testP.y = (testP.x - point.x) / light.x * light.y + point.y;
    if (testP.y <= -IMG_SIZE_HALF || testP.y >= IMG_SIZE_HALF) {
        break;
    }
    testP.z = - (testP.x - point.x) / light.x * light.z - point.z;
    if (testP.z <= 0 || testP.z >= IMG_SIZE) {
        break;
    }
    //y z计算出来后同样进行减枝
    testP.changeByCameraPTo(camera[11], testPC);//转换为相机空间坐标
    testDep = (- testPC.z - ZMAP_INFO.start) / ZMAP_INFO.len * 256;//计算为深度图数值
                                    
    refX = Math.ceil(testPC.x + IMG_SIZE_HALF);
    refY = Math.ceil(testPC.y + IMG_SIZE_HALF);
    hasDep = getImageValue(canvas_dep_data_r, IMG_SIZE, refX, refY, 3) && getImageValue(canvas_display_data_r, IMG_SIZE, refX, refY, 3);
    if (hasDep) {
        loadDep = getImageValue(canvas_dep_data_r, IMG_SIZE, refX, refY, 0);
        //判断深度图中是否有值,并且加载该数字
        if (loadDep > testDep) {//这个if即碰撞测试,发现当前测试用点,已经在深度图记录的表面点之后了,即碰撞成功
            color_value[0] = getImageValue(canvas_display_data_r, IMG_SIZE, x, y, 0) * sp + getImageValue(canvas_display_data_r, IMG_SIZE, refX, refY, 0) * p;
            color_value[1] = getImageValue(canvas_display_data_r, IMG_SIZE, x, y, 1) * sp + getImageValue(canvas_display_data_r, IMG_SIZE, refX, refY, 1) * p;
            color_value[2] = getImageValue(canvas_display_data_r, IMG_SIZE, x, y, 2) * sp + getImageValue(canvas_display_data_r, IMG_SIZE, refX, refY, 2) * p;
            setImageArrays(canvas_display_ref_data, IMG_SIZE, x, y, color_value);
            break;//执行颜色叠加,结束
        }
    }
}

这段代码有几个边界的讨论与处理:

a. 这里以x为单位进行碰撞坐标移动,但是确实有可能计算出反射向量light为0的情况,则需要改用y值移动进行计算了。更合理的方式是判断light向量的xy比例,如果绝对值x>y,按x更合适,否则按y。这样计算精度更高,但是计算点更多。

b. 这里是按照还原到正交坐标系进行进步测试,而不是直接在摄像机坐标系进行的测试,摄像机坐标系无法计算出正确的反射向量。

问题与讨论

按上面的写法,即最基本的ssr思路完成的ssr代码,其实会看到很多问题。

點擊在新視窗中瀏覽此圖片
直接执行会发现很验证的拖影问题,如上。

- 这个问题的原因是碰撞测试仅用深度小于数字进行测试,但是有可能已经差的很远了,因此在满足碰撞测试的条件同时,还需要对深度碰撞的差值做限制。
- 这个差值约小,拖影效果就越不明显,如上demo差值取的5,能稍微看到一丢丢拖影方向颜色有延伸。
- 但是这个值过小也不行,本身屏幕空间的数据进行碰撞计算各项数值均有一定误差,如果过小,比如取1,很容易出现碰撞不上的情况,会导致图像出现裂纹,如下:
點擊在新視窗中瀏覽此圖片

当然,图像出现裂纹/斑马纹的原因会更多,包括:

- 如上描述的碰撞差值不合适
- 碰撞测试跳跃值取值过大,或者逻辑不够准确
- 反射面法线图本身部分区域不可导
- 反射向量极限浮点数方向上导致误差

不过也能看到,以上问题大部分都是ssr机制与节省性能这个目标导致的,同时我们也没期望ssr技术的反射结果足够准确。所以只能忍忍了。

查看demo的动态效果的时候,肯定还会注意到这件事,当笑脸遮挡住地面或者墙面的一部分时,对于的倒影部分将会随之消失。
點擊在新視窗中瀏覽此圖片

想想当然这是合理的,屏幕空间渲染嘛,不在屏幕渲染出的区域,当然也无法计算得到反射,本质上和区域在屏幕外没什么区别。但是这个很自然的暴露出了ssr的除了反射精度差之外的其他两大弱点。

- 无法反射正常遮挡物体后的物体,可能会导致很突兀的反射物品消失:所以一般反射体都是模糊过的,至少别让人看出来。
- 在场景延伸到屏幕外后,对应的倒影会有很明显的切断边界:所以除了模糊,一般ssr会在倒影的边界做衰减。

总之要通过ssr实现理想的环境反射效果,能处理优化的还很多。参考阅读:屏幕空间反射在实际工程中的应用 https://blog.csdn.net/zju_fish1996/article/details/88913781。工程中大量高性能使用,还有大量问题与优化方式,拓展了解。
关键词:canvas , ssr
logo