[翻译]1K 玫瑰花

这篇文章介绍了使用 canvas 的绘图功能绘制一个 3D 玫瑰。很有特色,随翻译于此。
在 Valentine’s Day 即将来临之际,希望能给诸位死 coder 一点点好运气……

原文在此:http://www.romancortes.com/blog/1k-rose/

—————-翻译分隔线—————-

我参加了 js1k 组织的关于“爱”的第四次主题活动。提交了一个静态图片,由程序生成的 3D 玫瑰花。你可以在这里找到它

它是用蒙特卡洛方法显式分段采样生成 3D 曲面。我将在这个文章中逐步解释所有的要点。

蒙特卡洛方法的简短说明

蒙特卡洛方法是一个强大到令人难以置信的工具。我经常在各种函数最优化和采样问题中使用它,当你可以运行大量 CPU 计算,但是没有设计和编写算法的时间的时候,它们工作起来就像魔法一样。在玫瑰花的例子中,它是优化代码体积的有用工具。

如果你对于蒙特卡洛方法不怎么了解,可以在 Wikipedia 上阅读这篇相当棒的文章

显式曲面和采样/绘制

为了定义玫瑰花的形状,我使用了多种显式定义曲面。一共使用了 31 个曲面:24 个花瓣,4 个萼片(花瓣周围的小叶子),2 个叶子,还有一个用于玫瑰花的枝干。

那么这些显式曲面是如何工作的呢?很简单,接下来展示一个 2D 例子:

首先定义显式曲面函数:

function surface(a, b) {  // a 和 b 参数范围从 0 到 1。
    return {
        x: a*50,
        y: b*50
    };
    // 这个曲面将是一个 50x50 单位尺寸的正方形。
}

然后是绘制的代码:

var canvas = document.body.appendChild(document.createElement("canvas")),
    context = canvas.getContext("2d"),
    a, b, position;

// 现在将用参数 a 和 b 对曲面按照 .1 间隔进行采样:

for (a = 0; a < 1; a += .1) {
    for (b = 0; b < 1; b += .1) {
        position = surface(a, b);
        context.fillRect(position.x, position.y, 1, 1);
    }
}

结果为:

现在,让我们尝试用密度更高的采样间隔(更小间隔 = 更高密度):

你已经看到了,当采样密度越来越高时,点和点之间越来越近,直到一个点和它临近的点之间的距离小于一个像素,这时屏幕上的曲面就完全被填充了(看 0.01)。这时,无论怎样提高密度也不会带来视觉上的变化,那么刚刚绘制的的区域就是已经被填充过的(比较 0.01 和 0.001 的结果)。

好,现在让我们重新定义曲面函数来绘制一个圆。有各种办法来实现,我会用这个公式:(x-x0)^2 + (y-y0)^2 < radius^2 这里的 (x0, y0) 是圆心: [javascript] function surface(a, b) { var x = a * 100, y = b * 100, radius = 50, x0 = 50, y0 = 50; if ((x - x0) * (x - x0) + (y - y0) * (y - y0) < radius * radius) { // 圆里 return { x: x, y: y }; } else { // 圆外 return null; } } [/javascript] 由于我屏蔽了圆外的点,所以要在采样中加入一个条件: [javascript] if (position = surface(a, b)) { context.fillRect(position.x, position.y, 1, 1); } [/javascript] 结果为:

刚才已经说了,有各种途径来定义圆,有些无需在采样中进行屏蔽。这里会展示其中一个办法,不过仅仅是一个提示;在接下来的文章中不会使用它:

function surface(a, b) {
    // 使用圆的极坐标
    var angle = a * Math.PI * 2,
        radius = 50,
        x0 = 50,
        y0 = 50;

    return {
        x: Math.cos(angle) * radius * b + x0,
        y: Math.sin(angle) * radius * b + y0
    };
}

(这个方法需要比前一个方法更高的采样来填充这个圆)

好,现在来让圆变形,这样让它看起来更像一片花瓣:

function surface(a, b) {
    var x = a * 100,
        y = b * 100,
        radius = 50,
        x0 = 50,
        y0 = 50;

    if ((x - x0) * (x - x0) + (y - y0) * (y - y0) < radius * radius) {
        return {
            x: x,
            y: y * (1 + b) / 2 // 变形
        };
    } else {
        return null;
    }
}

结果为:

好,现在这个看起来更像是玫瑰花的花瓣的形状了。我建议你对变形多进行一些调整。你可以使用任何数学函数来实现,加减乘除、sin、 cos、乘方……任何函数。尝试修改这个函数,就能得到许多的形状(有的会很有趣)。

现在我为其增加一些颜色,对曲面添加颜色数据:

function surface(a, b) {
    var x = a * 100,
        y = b * 100,
        radius = 50,
        x0 = 50,
        y0 = 50;

    if ((x - x0) * (x - x0) + (y - y0) * (y - y0) < radius * radius) {
        return {
            x: x,
            y: y * (1 + b) / 2,
            r: 100 + Math.floor((1 - b) * 155), // 这会产生一个梯度
            g: 50,
            b: 50
        };
    } else {
        return null;
    }
}

for (a = 0; a < 1; a += .01) {
    for (b = 0; b < 1; b += .001) {
        if (point = surface(a, b)) {
            context.fillStyle = "rgb(" + point.r + "," + point.g + "," + point.b + ")";
            context.fillRect(point.x, point.y, 1, 1);
        }
    }
}

结果为:

铛!铛!铛!铛!隆重推出,有颜色的花瓣!

3D 曲面和透视投影

定义 3D 曲面是直接明了的:只要为曲面函数添加 z 属性。例如,接下来定义一个管道/圆柱体:

function surface(a, b) {
    var angle = a * Math.PI * 2,
        radius = 100,
        length = 400;

    return {
        x: Math.cos(angle) * radius,
        y: Math.sin(angle) * radius,
        z: b * length - length / 2, // 通过减掉 length/2,使得这个管道的中心在 (0, 0, 0)
        r: 0,
        g: Math.floor(b * 255),
        b: 0
    };
}

现在,添加透视投影,首先定义一个摄影机:

我将摄影机放置于 (0, 0, cameraZ),我将摄影机到画布的距离叫做“perspective”。我认为画布在 x/y 平面上,中心点是 (0, 0, cameraZ + perspective)。现在,每个采样点将会投影到画布:

var pX, pY,  // 在画布上投影的 x 和 y 坐标
    perspective = 350,
    halfHeight = canvas.height / 2,
    halfWidth = canvas.width / 2,
    cameraZ = -700;

for (a = 0; a < 1; a += .001) {
    for (b = 0; b < 1; b += .01) {
        if (point = surface(a, b)) {
            pX = (point.x * perspective) / (point.z - cameraZ) + halfWidth;
            pY = (point.y * perspective) / (point.z - cameraZ) + halfHeight;
            context.fillStyle = "rgb(" + point.r + "," + point.g + "," + point.b + ")";
            context.fillRect(pX, pY, 1, 1);
        }
    }
}

这个结果为:

Z-buffer

在计算机图形学中 z-buffer 是相当常见的技术,用于在远离摄影机的已经被绘制的点上绘制接近摄影机的点。它的工作方式是维护一个图像上已经画过的像素的数组。

这是玫瑰花的可视的 z-buffer,黑色是距离摄影机远的,白色是距离近的。

实现为:

var zBuffer = [],
    zBufferIndex;

for (a = 0; a < 1; a += .001) {
    for (b = 0; b < 1; b += .01) {
        if (point = surface(a, b)) {
            pX = Math.floor((point.x * perspective) / (point.z - cameraZ) + halfWidth);
            pY = Math.floor((point.y * perspective) / (point.z - cameraZ) + halfHeight);
            zBufferIndex = pY * canvas.width + pX;
            if ((typeof zBuffer[zBufferIndex] === "undefined") || (point.z < zBuffer[zBufferIndex])) {
                zBuffer[zBufferIndex] = point.z;
                context.fillStyle = "rgb(" + point.r + "," + point.g + "," + point.b + ")";
                context.fillRect(pX, pY, 1, 1);
            }
        }
    }
}

旋转圆柱体

你可以使用任何向量旋转方法。在玫瑰花的例子中,我使用 Euler 旋转。来实现一个基于 Y 轴的旋转:

function surface(a, b) {
    var angle = a * Math.PI * 2,
        radius = 100,
        length = 400,
        x = Math.cos(angle) * radius,
        y = Math.sin(angle) * radius,
        z = b * length - length / 2,
        yAxisRotationAngle = -.4, // in radians!
        rotatedX = x * Math.cos(yAxisRotationAngle) + z * Math.sin(yAxisRotationAngle),
        rotatedZ = x * -Math.sin(yAxisRotationAngle) + z * Math.cos(yAxisRotationAngle);

    return {
        x: rotatedX,
        y: y,
        z: rotatedZ,
        r: 0,
        g: Math.floor(b * 255),
        b: 0
    };
}

结果为:

蒙特卡洛采样

在文章中已经使用了基于间隔的采样。它需要对每个曲面设定合适的间隔。如果间隔大,渲染会很快,但是曲面可能未被完全填充而存在空洞。另一方面,如果间隔过小,渲染会超过透视投影所需的时间。

所以,还是切换到蒙特卡洛采样吧:

var i;

window.setInterval(function () {
    for (i = 0; i < 10000; i++) {
        if (point = surface(Math.random(), Math.random())) {
            pX = Math.floor((point.x * perspective) / (point.z - cameraZ) + halfWidth);
            pY = Math.floor((point.y * perspective) / (point.z - cameraZ) + halfHeight);
            zBufferIndex = pY * canvas.width + pX;
            if ((typeof zBuffer[zBufferIndex] === "undefined") || (point.z < zBuffer[zBufferIndex])) {
                zBuffer[zBufferIndex] = point.z;
                context.fillStyle = "rgb(" + point.r + "," + point.g + "," + point.b + ")";
                context.fillRect(pX, pY, 1, 1);
            }
        }
    }
}, 0);

现在,参数 a 和 b 被设置为两个随机值。对足够多的点进行采样,曲面就可以利用这种方法填充。多亏了间隔采样,我可以确定每次用 10000 个点绘制,然后更新屏幕。

特别说明一下,完全填充一个曲面仅仅需要保证伪随机数生成器的品质够好。在某些浏览器中,Math.random 是按照线性一致生成器实现的,而这可能在某些曲面上产生一些问题。如果你在采样中有较好的伪随机噪声生成的需求,你可以使用更高品质的如 Mersenne Twister (这里有其 JS 实现),或者在某些浏览器里可以使用的密码随机生成器。同样使用低差异数序列也是很好的解决方案。

总结

为了完成这个玫瑰花,花朵的每个部分,每个曲面,需要同时进行渲染。我为函数添加了第三个参数用于确定返回玫瑰花的哪个部分的点。数学上来说,这是一个分段函数,每个片段对应玫瑰花的一部分。在花瓣的部分,我使用旋转和拉伸/变形来创建所有花瓣。所有都是用文章中提及的方法混合来完成的。

当然通过采样建立显式曲面是众所周知的方法,并且是最古老的 3D 图形方法之一,在艺术用途中像我这样使用分段/蒙特卡洛/z-buffer 已经非常少见了。没什么创新,对于实际生活也不怎么有用,但是非常适合 js1k 这种简单并且小尺寸的要求。

通过这篇文章,我希望能够激发读者在计算机图形学上的兴趣来进行尝试,并且在不同的渲染方法中找到乐趣。在图形学的世界中探索和玩耍是一件令人兴奋的事情。

Leave a comment

Your email address will not be published. Required fields are marked *