LWDW!

Learn the work from doing the work🍺

图像处理初见!——水波纹
Posted on 2018-06-12

水波纹效果的实现方法其实相当多,今天俺想讨论的一种是利用算法(就是说依照某种规则将图像的像素乱搞一通,看起来就像波纹一样!)来进行处理的,不借助波纹图片等等,因此有很高的可定制性。

WARNING——本文中的demo未做足够优化,不建议使用手机查看

首先来看一眼效果

See the Pen water ripple by Zhouyi (@padfoot_07) on CodePen.

【伤害大家的眼睛使我快乐】

那么,这么好的算法是什么呢?

不准确的大概描述

在具体介绍策略之前,俺先模糊的描述一下这个策略的思想:

水波纹本质上是像素有规律的发生偏移(offset)造成的视觉效果,且这个偏移连续进行,看起来才会像是水波扩散、荡漾一样。因此在我们试图将这种偏移作用到图像(一堆像素)上之前,首先思考的是一连串的数值变化;我们为图像的每一个像素都赋予一个数值,来代表它在“水波纹”作用下的状态。而这个变化要具有连续性,我们要不停记录当前上一帧渲染的状态,再依据某种策略使其状态的改变自发进行下去。之后再依据每个像素的状态信息,计算出它的偏移

具体策略

假设我们处理的是100*100的图像,这个策略大概分为这么几步:

  1. 记录水波纹**当前(current)上一帧渲染时(previous)**的状态信息。就像我们上文提的一样,这里的信息是对应每个像素的,因此俺就使用了两个数组来记录,长度都为10000(100*100,对应像素顺序是按照一行一行排列

    ,从左向右,从上向下),均初始化为0。

  2. 每一帧,都利用previous对current进行混合(这里似乎是一种卷积计算,然而俺的高数不好,可自行了解),并交换两者,具体如下:

    • 对于每个像素的current值(为了方便,边缘像素这里不处理),将其previous中的邻居像素(比如它的上下左右四个像素)的状态数值相加,除以2,减去current中的数值——当然我知道你听不懂,所以画了个图:

      策略

    • 等等!根据常识,波纹的扩散(塌陷)幅度会之间减小,因此我们还需要一个阻尼来减小current值,最简单的方法就是每帧都乘以一个小于1的值。

    • PS: 当然,咱肯定会想,这他喵是什么个原理?虽然我也不知道是哪位大神想出的算法(这个算法的使用相当广泛,以至于我参考的不同实现样例都不约而同得使用了该算法),但是至少咱可以可视化这个过程,康康到底发生了什么:

      See the Pen 水波纹策略-可视化 by Zhouyi (@padfoot_07) on CodePen.

      这里demo将每个像素的current值直接作为rgb值赋予了像素,数值越高的像素看起来越“亮”,可以直观的看出数值的变化就是波纹状。

  3. 接下来我们可以准备处理图像了,首先自然是获得图像的像素信息,比如我使用的p5.js,提供loadPixels()这样的api便可如字面意义获得图像所有的像素信息,数据结构大概是这样:

    [
        // 如100*100的图片,像素数据的顺序按照一行一行排列,共100*100*4个元素
        // 第一个像素的rgba
        123, // r
        255, // g
        23, // b
        255, // a
        // 第二个像素的rgba
        23,
        55,
        213,
        255,
        // ......
    ]
    

    当然你也可以定义自己习惯的数据格式。

  4. 现在我们需要根据current值来决定图像像素的偏移量offset了。说实话,这一步俺们有很大的自主权,参数以及函数的选择可以带来不同的效果。简单的来说,current值越高意味着水波越高,“折射”越明显,偏移值越大。

  5. 在最后,我们需要互换current和previous数组,准备开始下一轮计算。

  6. 基本是到这一步就结束了——但显然我们还差一个关键步骤,就是触发波纹。上文在初始化current和previous时初始化是0,如果我们不去改变,偏移值自然也一直是0。想要在某个位置触发“水波纹”,只需改变该像素的previous值便可,随后每帧进行的计算会推动波纹的产生。除此之外,还可做一些水波在图像边缘碰撞的检测使效果更加真实。

  7. 大概的代码,具体可查看最上方的样例:

    ...
    // 触发波纹
    function tick () {
        previous[int(random(400))][int(random(400))] = 512;
    }
    ...
    // 每一帧绘制
    function draw() {
        drawImage();
    	loadPixels();
        // 循环非边缘的对象
        for (let i = 1; i < COLS - 1; i++) {
            for (let j = 1; j < ROWS - 1; j++) {
                current[i][j] = (
                    previous[i - 1][j] +
                    previous[i + 1][j] +
                    previous[i][j - 1] +
                    previous[i][j + 1]
                ) / 2 - current[i][j];
    
                current[i][j] *= dampening;
    
    			...
    
                const data = 1024 - current[i][j];
    
                // 获得偏移值
                let xoffset = int((i) * data / 1024);
                let yoffset = int((j) * data / 1024);
    
                // 边缘检查
                if (xoffset >= COLS) xoffset = COLS - 1;
                else if (xoffset < 0) xoffset = 0;
                if (yoffset >= ROWS) yoffset = ROWS - 1;
                else if (yoffset < 0) yoffset = 0;
    
                // 根据偏移值获得偏移下标
                const index = (i + j * COLS) * 4;
                const newIndex = (xoffset + yoffset * COLS) * 4;
    
                pixels[index] = imagePixelsCopy[newIndex];
                pixels[index + 1] = imagePixelsCopy[newIndex + 1];
                pixels[index + 2] = imagePixelsCopy[newIndex + 2];
            }
        }
        updatePixels();
    
        // 交换current与previous
        let temp = previous;
        previous = current;
        current = temp;
    }
    

历史考察

在写这篇水文查阅资料时,发现有几篇文章都指向了一个现在无法打开的地址,好在archive存有快照,这是篇98年的博客,作者对于算法的原理及应用都有很好的介绍,很建议阅读,这里是地址


我什么都不想做,我只想玩赛博朋克2077.jpg