Aug 18

练手:空间基础阴影/投影绘制(Canvas实现)

Lrdcq , 2019/08/18 02:01 , 程序 , 閱讀(2411) , Via 本站原創
上一次编写了二次元图像的光照/阴影绘制(https://lrdcq.com/me/read.php/110.htm)不过其实缺少一大块即投影的绘制。毕竟上次本质上用cpu模拟了半兰伯特光照模型,从js实际执行的帧率上看,已经非常极限了。而最简单的投影绘制,即ShadowMap方案,本身充分利用了GPU管线的基础上依赖是大家用得慎之又慎的东西,Canvas实现起来自然非常酸爽了。整体效果如下(4x模糊有惊喜):

Demo(https://lrdcq.com/test/shadowmap-canvas)内嵌:


设计思路

首先我们来想象一下一个投影是怎么生成。假设我在空间中有三个球体,如下图:
點擊在新視窗中瀏覽此圖片

先简单处理坐标轴向右为x向下为y,向用户即前方为z,并且背景白色为球体背后的。那么,我从下前方,即我的脚下,打一道平行光向坐标中心,即一道45度向上的平行光,那么理论上,上图的画面上会出现三种效果:

1. 每个球体的非受光面会出现阴影,这个和兰伯特光照模型得到的效果是一样的,即光照负角度的区域黑下来了。
2. 背后的球体会受到前方球体的投影。
3. 背后的白色墙面会得到三个球体的投影。
點擊在新視窗中瀏覽此圖片

严格以物体区分一下,可以分为自阴影(自己在自己身上的投影),和物与物之间的投影。不过看物体怎么定义。比如我上图的三个球连同墙面都一起计算,那么这也可以视为一个物体,大家都是自阴影。另外如果以面的方向来区分,和光源角度<90的面上受到的视为投影也行。

关注以上信息,我们就能得到稍微准确的我们要描绘的东西的定性描绘方式:为所有在光照方向背面的面上打上阴影。因此关键点就是,到底当前图像的哪些地方是“背面”——或者说是光照方向的非表面

强行抛砖引玉,还记得上一次我们聊到了描述空间我们可以一张图像,即Z Map或者叫Depth Map即深度图来表示用户可见的表面的几何信息么?我们试试把上面这三个球体,通过我们平行光的视角,看到的样子:
點擊在新視窗中瀏覽此圖片

注意到上面了么,这张深度图中看不到的几何区域,即是阴影的区域。而又正好,这张图记录了从光照视角最近能看到的像素深度。那么,只需要把绘制图像中每个像素,把期坐标放到这个深度图来,如果坐标在灯光视角中的深度大于深度图记录的最近的深度,那它就是背面的一个像素了

核心流程

基于以上讨论,可以知道我们的核心流程为:

a. 光照更新流程:更新光照向量(和上一个demo一样单独讨论简单的平行光),通过光照向量计算光照视角变换矩阵(平行光通过三维矩阵即可表示,很简单)。

b. 帧渲染流程:

- 通过光照变换矩阵渲染光照视角深度图
- 渲染主图所有物体像素(背景墙+三个球体)。每个像素通过光照变换矩阵转换到光照空间坐标
- 比较坐标z与相同位置深度图记录深度,如果z<深度,则为阴影区域,进行颜色处理。

实际代码编写过程中,有几个关键的点:

1. 因为我们原始记录的是灯光向量,因此我们实际上是在求灯光向量到我们视角向量即[0, 0, 1]之间的变换矩阵。这里用到的是罗德里格旋转公式的代入结果,我们首先通过两个向量点积求得他们的夹角,又通过他们的叉积求得他们的法线向量也是变换过程的旋转轴,因此我们获得了一个旋转向量,即可代入罗德里格了。实际简化后的代码如下:
    let createMat3From2Vec3 = function(from, to) {
        const angle = Math.acos(from.multiplyDot(to) / from.length() / to.length());
        return createMat3FromAxisAngle(to.multiplyNew(from), angle);
    }

    let createMat3FromRodrigues = function(top, angle) {
        const axis = top.normalize();
        const u = [axis.x, axis.y, axis.z];
        const el = [];
        el[0] = Math.cos(angle) + u[0] * u[0] * (1 - Math.cos(angle));
        el[1] = u[0] * u[1] * (1 - Math.cos(angle)) - u[2] * Math.sin(angle);
        el[2] = u[1] * Math.sin(angle) + u[0] * u[2] * (1 - Math.cos(angle));

        el[3] = u[2] * Math.sin(angle) + u[0] * u[1] * (1 - Math.cos(angle));
        el[4] = Math.cos(angle) + u[1] * u[1] * (1 - Math.cos(angle));
        el[5] = -u[0] * Math.sin(angle) + u[1] * u[2] * (1 - Math.cos(angle));

        el[6] = -u[1] * Math.sin(angle) + u[0] * u[2] * (1 - Math.cos(angle));
        el[7] = u[0] * Math.sin(angle) + u[1] * u[2] * (1 - Math.cos(angle));
        el[8] = Math.cos(angle) + u[2] * u[2] * (1 - Math.cos(angle));

        return new Mat3(el);
    }

2. 目前还是用灰度图来表示深度图,因此需要一个将空间[-zmax, zmax]转换到[0, 255]的办法,还好是线性转换,很方便啦:
let getGrey = function(z) {
    return (z - ZMAP_INFO.start) / ZMAP_INFO.length * 256;
}

3. 实际对空间中的物体进行抽象。因为绘制光照深度图与绘制渲染场景其实是进行了两次绘制,因此目标是不同的。一般来说,光照深度图绘制的对象关心的是生产投影的物体——demo中即这三个球体。而场景的渲染,其实最佳情况下,最好只计算接受投影的物体。

举个栗子的话,假设我们场景只有一个球体,而这个球体已经通过半兰伯特绘制了自己的阴影,那就不需要再通过光照绘制自阴影了。因此这个过程中,光照深度图的绘制对象只有这个球,而投影绘制过程的目标对象只有地面了。——这也是绝大部分普通游戏的处理方式(即精灵对象投影到环境模型上的过程)。

4. 场景中每个像素每个坐标乘上光照变换矩阵得到的新坐标z和灰度做比较即可得到answer,代码全是运算与比较,非常清新:
//计算当前坐标高度
h = getImageValue(canvas_ball_grey_data, OBJECT_BALL_R * 2, x, y ,0) + OBJECT_BALL[i].position.z;
//计算灯光空间坐标
pos.copy(OBJECT_BALL[i].position.x - OBJECT_BALL_R + x, OBJECT_BALL[i].position.y - OBJECT_BALL_R + y, h).applyMatrix3(lightmat3);
//获得当前数据offset
ceilx = Math.ceil(pos.x) + IMG_SIZE_HALF;
ceily = Math.ceil(pos.y) + IMG_SIZE_HALF;
//获取当前高度在灰度图中数字
h = pos.z - ZMAP_INFO.start) / ZMAP_INFO.len * 256;
//获得灯光灰度图中对应位置的数字,其中定义了深度图中0的值为负无穷,不参与计算
t = getImageValue(canvas_display_light_data, IMG_SIZE, ceilx, ceily, 0);
if (t && h < t) {
    return true;//在阴影中
}
return false;//不在      

5. js实现这个过程由于涉及到高频率的cpu计算和canvas操作,发现了数个性能点:
//问题a
//在大循环(demo中核心循环每一帧50w次)下,不要new任何东西,let与const不同jscore优化程度不同,最好也避免
//因此,这个过程中涉及的复杂对象,最好是可变对象/可复制对象,比如
    class Vec3 {
        constructor(x, y, z) {
            this.x = x;
            this.y = y;
            this.z = z;
        }

        copy(x, y, z) {
            this.x = x;
            this.y = y;
            this.z = z;
            return this;
        }

        copyVec3(vec3) {
            this.x = vec3.x;
            this.y = vec3.y;
            this.z = vec3.z;
            return this;
        }
    }
//提供两个copy方法来快速变更数据。循环中处理方式为:
const temp_vec = new Vec3();
for (let i = 0 ; i < 999999999 ; i++) {
    temp_vec.copyVec3(data[i]);//通过copy复制数据源到编辑对象上
    temp_vec.dosomethingChangeVec3SelfBalabala();
    //然后使用temp_vec
}

//问题b
//避免频繁使用canvas的getImageData与putImageData
//putImageData有卡顿是合理的,但是意外的是getImageData才是卡顿的主要原因,demo代码如下:
//某个循环中:
ctx.rect(lalala);
ctx.rect(lalala);
ctx.fill();
//...非常多的操作
ctx.rect(lalala);
ctx.rect(lalala);
ctx.fill();
const data = ctx.getImageData(0, 0, IMG_SIZE, IMG_SIZE);//
//这里发生卡顿的原因是ctx的标准api操作其实是会话/事务机制的操作,事实上ctx操作后不是同步立刻绘制到画布上的,而是后台线程慢慢执行的。
//而getImageData因为要获取当前画布状态,会强制同步等待以上操作完成。因此在高频操作的过程中,绘制线程延迟会越来越大,getImageData也会越来越卡

//问题c
//意外的发现从canvas的ImageData.data中取数据意外的卡
return ImageData.data[offset];//卡
//猜测原因是data是一个Uint8ClampedArray对象,从中取数到js number会发生各种内存抖动和数据类型转换
//期待js直接引入Uint8操作就好,或者这个过程放到wasm中应该成本会低很多

总之乱七八糟的事情处理完成后,看到渲染出来的效果:
點擊在新視窗中瀏覽此圖片

修正

明显上图的渲染效果是完全不符合预期的,这是什么迷之条纹啊,原理上不应该出现呢。

不过仔细分析下,我们光照方向的深度图,一个像素对应在渲染图上确实不一定是一个像素吧,角度越大,一个像素会覆盖n个像素的宽度,再考虑到深度图的数据其实做了规整有进度丢失,我们记录的那个深度进行比较正面的话,会出现一部分误差。

这个现象有专门的叫法,被称为Shadow Map Acne

因此我们需要在比较的时候加一个小小的bias就可以解决了(不过这个gap距离怎么计算合理又是另一个麻烦的问题了)。
點擊在新視窗中瀏覽此圖片

通过bias进行修正后,我们得到了如下渲染图:
點擊在新視窗中瀏覽此圖片

注意到

- Shadow Map Acne在正常光照面上已经不出在了,只能在自阴影的边界上看到一定间隙。
- 同时,因为基于绝对的高度图做的比较,自阴影和投影的边界其实非0即1,锯齿感非常非常严重。

解决这两个问题,有一个非常粗暴的方法,即边缘模糊,所谓糊掉就看不出来毛刺了。

边缘模糊最简单的实现是矩形PCF(Percentage Closer Filtering),原理上,假设我采样当前点,判断当前点是否是阴影区域,即得到0-1的结果,那么2x2的PCF采用周围的四个点,分别判断这四个点是否是阴影区域,结果相加,得到[0, 4]的结果值,把这个值为做阴影的深度百分比0%到100%,即产生了过度。4x4的PCF则是采样周围16个点的到[0, 16]的过渡值。

demo中实现了这两个最最最基础的模糊计算,效果如下:
點擊在新視窗中瀏覽此圖片點擊在新視窗中瀏覽此圖片

这样我们的到的渲染效果,基本4x4的模糊不仔细看已经很接近自然柔和了,我们的demo也非常接近最最最普通的Shadow Map的渲染结果了。

当然,canvas实现是真的卡。除去canvas的cpu计算实现本身因素之外,产生卡的关键包括:

- 全场景动态对象,每一帧都进行全画幅深度图绘制,其实就是渲染x2了,就算是gpu绘制成本也很高。真的做游戏的话,一个是会按照生产对象和投影对象尽量缩小渲染对象规模,一个是会根据渲染目标的大小范围和可见距离采用颗粒度更粗的深度图渲染,反正远处模糊化了之后根本看不出来(Fit To Scene和Fit to View方案)。

- PCF当然非常非常卡的,就算是PCSS也会好一些,这个讨论起来也很长了。
关键词:canvas , shadowmap
logo