Aug 8

练手:二次元图像的光照/阴影绘制(Canvas实现)

Lrdcq , 2019/08/08 18:20 , 程序 , 閱讀(2848) , Via 本站原創
参考基础demo食用:https://lrdcq.com/test/2dnormal-canvas,纯源码网页,另存下来修改更佳。
我们在屏幕上看到的所有图像说到底都是二维图像。在二维图像上表现3d的效果有几种主要方式。一种方式是通过线条来描绘出空间感。另一个种方式就是通过色差来表现光照/阴影了来描绘出层次感了

Demo内嵌:


----------------
我们举一个例子,我们通过简单的线条来描绘出了1/8个椭球体的表面。如下图:
點擊在新視窗中瀏覽此圖片
虽然线条已经足够清晰了,但是如果是一个没有线条只有色块的图像,我们看不到任何东西。因此如果要在空间中表现这么一个椭球体,我们从椭球体正前方,如上图红色箭头的方向给来一个平行白光。那么我们看到的渲染效果大概率是这样子的:
點擊在新視窗中瀏覽此圖片
如果用素描基础来描述这个绘制,方法即:
點擊在新視窗中瀏覽此圖片

有几个需要注意的地方:

1. 这种光照方式,目标图像的材质是100%的漫反射,就算有高光部分部分也是只是表示漫反射的高亮部分。镜面反射的高亮部分应该和摄像头矢量(即视角)和光源有关,当然,如果是平行光的话,也很难表现出镜面反射的高亮了。

2. 上面两张图都表现的平行光,因此对于球体来说,一定是50%半球表面积受光。如果是点光源的话,就是投向球面切线的锥体光线了,甚至有可能为0。

3. 漫反射的明暗关系怎么来的,首先可以想象到的是,假设光源的投射距离为无限长(一般都是)并且无衰减。那么我们看到的明暗关系,只能和一个因素有关——光线和照射面的角度,严格来说是光线和照射面法线的夹角。
點擊在新視窗中瀏覽此圖片
如图,可以得到:

1. 对于图像上任意点的光照亮度,和这个点在3d空间中所在的面的法线向量,与这个点接收到的光线向量的夹角呈正相关。
2. 夹角=0时,光照亮度100%,夹角90度时,光照亮度刚好0%,夹角>90度时,必然无光照。

那么,结论上,计算二维图像上的光照,只需要获得对应像素渲染的对象在实际表示的3d空间中的法线向量,遍历所有光源,计算其夹角,获得“光照强度”进行颜色叠加,即可。这个漫反射光照计算方式也被称为“兰伯特(Lambert)光照模型”。

其中表示图像上的每一个像素的法线向量这个数据,正好可以静态的储存在一个rgb的图像中。一般我们成为这个图像为法线贴图

法线贴图的烘培

对于一般的3d静态模型来说,法线贴图烘培是从3d高模烘培的低模上的,参考https://www.jianshu.com/p/48e475132448。而对于二维画面来说,当前二维画面描述的3d空间如果用同样二维的资源来描述的话,就是深度图了(Z Map)。demo的二次元资源我们手绘配置了深度图(不太精确):
點擊在新視窗中瀏覽此圖片

Z Map有几种表现形式,这里是最简单的灰度图,还有一些可能包括:

1. 指数灰度图。如上的灰度图是普通的灰度图,灰度从0-max对应的渲染空间内具体的某个Zmin和Zmax,中间每个灰度值表示空间内均匀分布的z。这样的灰度图适合如demo一样浮雕一样的效果。而如果要描述一个纵向的空间,比如走廊,只有指数灰度图才能描述出在近处精确,在远处无限延伸的z空间。

2. rgbZMap,只有灰度的0xff的数据空间来表示深度确实会有些不够用,因此一般会通过rgb0xffffff的数据来描述z空间数值来描述相对复杂/并且需要精确计算的场景。

那么从最简单的灰度图上来说,如何把灰度图烘培成法线贴图呢?从当个像素开始,每个像素通过它上下左右4个像素,在灰度图上可以描绘出5个空间坐标:
點擊在新視窗中瀏覽此圖片

当然,这个点和上下左右的四个点,会形成4个面来描述出该像素周围的空间情况。将这个四个面的法线计算出来,求一下平均值,就可以粗略的得到这个像素对于的法线向量了。
點擊在新視窗中瀏覽此圖片

demo中的代码如下
        let canvas_grey = document.createElement('canvas');
        canvas_grey.width = IMG_SIZE;
        canvas_grey.height = IMG_SIZE;
        let canvas_grey_ctx = canvas_grey.getContext('2d');
        canvas_grey_ctx.drawImage(img_grey, 0, 0);
        let canvas_grey_data = canvas_grey_ctx.getImageData(0, 0, IMG_SIZE, IMG_SIZE);

        let canvas_normal = document.createElement('canvas');
        canvas_normal.width = IMG_SIZE;
        canvas_normal.height = IMG_SIZE;
        let canvas_normal_ctx = canvas_normal.getContext('2d');
        let canvas_normal_data = canvas_normal_ctx.getImageData(0, 0, IMG_SIZE, IMG_SIZE);

        let offsetVec = new Vec3(127, 127, 127);
        for (let y = 0; y < IMG_SIZE; y++) {
            for (let x = 0; x < IMG_SIZE; x++) {
                //当前点的坐标
                let point = new Vec3(x, y, getImageValue(canvas_grey_data, x, y, 0) * GREY_HEIGHT_OFFSET_VALUE);
                //上下左右四个向量
                let pointLeft = new Vec3(x - 1, y, getImageValue(canvas_grey_data, x - 1, y, 0) * GREY_HEIGHT_OFFSET_VALUE).sub(point);
                let pointRight = new Vec3(x + 1, y, getImageValue(canvas_grey_data, x + 1, y, 0) * GREY_HEIGHT_OFFSET_VALUE).sub(point);
                let pointTop = new Vec3(x, y - 1, getImageValue(canvas_grey_data, x, y - 1, 0) * GREY_HEIGHT_OFFSET_VALUE).sub(point);
                let pointBottom = new Vec3(x, y + 1, getImageValue(canvas_grey_data, x, y + 1, 0) * GREY_HEIGHT_OFFSET_VALUE).sub(point);
                //将四个向量加合
                let normal = new Vec3(0 ,0 ,0).add(pointLeft.multiplyNew(pointTop)).add(pointTop.multiplyNew(pointRight)).add(pointRight.multiplyNew(pointBottom)).add(pointBottom.multiplyNew(pointLeft));
                //将这个向量归一化,归到(-128~128的范围),并且加上offset使得数据范围在(0~255)
                normal.multiplyNumber(128 / normal.length()).add(offsetVec);
                setImageValue(canvas_normal_data, x, y, normal);
            }
        }

        canvas_normal_ctx.putImageData(canvas_normal_data, 0, 0);
        

这样我们就渲染得到我们demo的法线贴图了:
點擊在新視窗中瀏覽此圖片

另外,这个我们经历了一个烘培的过程,不过既然我们都手动绘制了Z Map,其实我们也可以直接手绘法线贴图。这和手绘Z Map一样,也是直接绘制3d空间了,参考http://www.cnxsj.cn/pmsj/ps/6969.html

法线贴图渲染

法线贴图作用于光照渲染,如果没光照,就没有渲染的意义的。光照简单可分为点光和平行光,先按照平行光来描述。

平行光渲染

储存平行光当然是直接储存这个光照在环境中的向量vec3。当然还有它的光照叠加颜色。那么如何通过光照向量与像素的法线计算得我们要的“光照强度”呢?

记得向量夹角的计算方法是A·B = |A|x|B|xCOS(x),如果我们将两个目标向量归一化后,其实就是A·B = COS(x),并且我们并不需要获得具体的角度x值,以A·B作为结果就好,只是光照强度和角度的关系是cos(0-90度)的曲线,而不是线性的,而这个曲线其实非常符合自然定义。

具体用代码表示出来是:
    let answerFoo = function (answer) {
        if (answer < 0) {
            return 0;
        }
        return answer;
    }
    
    let normal = getImageValue(canvas_normal_data, x, y).sub(offsetVec);//获取法线

    let r = 0, g = 0, b = 0;//最后储存最大值合成
    let num = (x + IMG_SIZE * y) * 4;//当前像素数据offset
    let sr = canvas_display_data.data[num + 0], sg = canvas_display_data.data[num + 1], sb = canvas_display_data.data[num + 2];//当前像素数据

    //处理平行光
    let answer = normal.multiplyDot(light) / 64;
    answer = answerFoo(answer);
    r = sr * LIGHT_MAPPING.R.S + answer * LIGHT_MAPPING.R.R;
    g = sg * LIGHT_MAPPING.G.S + answer * LIGHT_MAPPING.G.R;
    b = sb * LIGHT_MAPPING.B.S + answer * LIGHT_MAPPING.B.R;

其中answerFoo是光照强度通用处理函数,下面的点光源也会使用。另外计算的过程有几个要注意的是:

1. 因为canvas的原因,归一化其实做的归128,因此|A|x|B|其实是128x128,在这里将结果/64,最后得到的answer结果是0~256。

2. 光源的计算是将原始图像按一定系数变暗,并且按照answer的强度和系数叠加上新的颜色。比如这里红色光照的叠加map是{R:{S: 0.7, R: 0.7}, G:{S: 0.7, R: 0.2}, B:{S: 0.7, R: 0.2}}。

最后的渲染效果是:
點擊在新視窗中瀏覽此圖片

非常踏实,毫无意外的平行光。

点光源渲染

记录点光源当然也是Vec3,但是记录的是点的空间坐标,而不是光线向量。光线向量得根据每个像素对应的空间点的坐标去计算。算法接上文后续计算:
for (let i = 0; i < dotlight.length; i++) {
    //接上
    let lightdot = new Vec3(dotlight[i].x - x, dotlight[i].y - y, dotlight[i].z - z);//可以没有-z
    lightdot.multiplyNumber(128 / lightdot.length());
    let answerdot = normal.multiplyDot(lightdot) / 64;
    answerdot = answerFoo(answerdot);
    let lightmap = LIGHT_MAPPING_LIST[dotlight[i].map];
    let ar = sr * lightmap.R.S + answerdot * lightmap.R.R * LIGHT_MAPPING_V;
    if (ar > r) {
        r = ar;
    }
    let ag = sg * lightmap.G.S + answerdot * lightmap.G.R * LIGHT_MAPPING_V;
    if (ag > g) {
        g = ag;
    }
    let ab = sb * lightmap.B.S + answerdot * lightmap.B.R * LIGHT_MAPPING_V;
    if (ab > b) {
        b = ab;
    }
}

點擊在新視窗中瀏覽此圖片

其中有几个和平行光不同的地方进行了处理。

1. 其中计算lightdot获取当前光照向量的地方,-z是可选的。如果不进行-z,假设所有的点在空间中都是0的位置,因此在空间中的光照方向是错误的,但是本身法线是正确的,因此空间感是正确的,看起来就像是浮雕一样。而如果有-z的话,是完全按照空间坐标进行计算,更有3d感。如下图左(-z,3d感)右(不-z,浮雕感)。酌情使用。
點擊在新視窗中瀏覽此圖片

2. 多个光照颜色叠加并不是叠加计算,而是在rgb中取得最大值。直观的理解,如果我往同一个地方打了两个黄色的光线,那个地方不会因为两个黄色光线叠加而更黄。多个光线叠加的效果花里胡哨的,大概这样:
點擊在新視窗中瀏覽此圖片

其他讨论点

1. 刚才说到我们的answer和叠加系数是cos曲线,通过answerFoo直接计算,因此会有目前看起来非常3d的光线效果。通过answerFoo方法,我们也可以对数据进行调整。比如
    let answerFoo = function (answer) {
        if (answer < 100) {
            return 0;
        }
        if (answer < 245) {
            return 255;
        }
        return 9999;
    }

把角度ansewer<100的直接过滤掉,<245的标红,而245-256的就是角度极小的区域极端高亮。就会出现非常二次元的效果。或者直接只保留极端高亮值,就会有如下类似效果。如果是shader的话再对上层做下平滑之类的,就像赛璐珞渲染了。
點擊在新視窗中瀏覽此圖片

2. 还是二次元图像渲染做光照,必然会有一些部分不希望被光照覆盖,具体来说应该是线条部分。无论色块如何高亮,还是希望线条部分不受光照影响。因此需要有一定颜色过滤方案。比如过滤掉所有黑色和灰色,会有如下对比效果(左图做了如上过滤,观察细节上的黑色线条可以发现区别):
點擊在新視窗中瀏覽此圖片

3. 以上说的兰伯特光照模型,注意到我们以向量算出来的光照强度是[-1, 1]的范围,只是我们把<0都视为无光照。而在实际光照使用中,<0的情况也会有一定亮度,毕竟漫反射嘛。因此,这里我们稍微修改下answerFoo:
    let answerFoo = function (answer) {
        answer+=256;
        answer/=2;
        return answer;
    }

就得到了我们著名的“半兰伯特光照模型”。这样的光照整体看起来暗处会少很多,在暗模型以光照为主的场景下视觉效果更好(当然直接在图片上叠加效果就很差了)。
點擊在新視窗中瀏覽此圖片

4. 我们注意到answerFoo函数的作用。这么多效果都可以通过不同的answerFoo函数来表示。那answerFoo到底干了什么呢?其实就是把一个[-1, 1],半兰伯特是[0, 1]的数据转换为另一个[0, 1]的数据。那么这个除了通过函数来表示。我们是否也可以通过资源来表示呢?
因此尝试这个设计:通过一个256x1的条状灰度图来表示[0, 1]->[0, 1]的转换,x表示answer,灰度表示输出answer。我们的半兰伯特answerFoo则变成:
    let answerFoo = function (answer) {
        answer+=256;
        answer/=2;
        return canvas_light_data.data[Math.ceil(answer) * 4];
    }

测试的灰度条与效果如下:
點擊在新視窗中瀏覽此圖片

5. 通过资源来表示光照强度可行的话。拓展这个话题,能否用资源表示光照本身(强度+颜色)呢?看起来如4说的条,填上rgba就可以做到了。并且同时通过调整光照条的颜色/分布,甚至可以做到调整光照效果风格。简直易于管理。demo效果如下:
點擊在新視窗中瀏覽此圖片
关键词:webgl , canvas
logo