1K的玫瑰——只属于程序员的浪漫

前段时间我参加了以“love”为主题的第四届js1k大赛(JavaScript界的高端赛事),我提交的作品是一幅动态3d玫瑰图像,你可以先看一下它的效果:http://js1k.com/2012-love/demo/1022

它采用蒙特卡洛抽样算法分段构建三维表面,我将通过下面这篇文章阐述所有的细节。

(最后效果图)rose

关于蒙特卡罗方法的简短说明

蒙特卡罗方法是令人难以置信的强大工具。对于很多函数优化和采样问题,我一直使用它去解决。尤其当你有更多的时间用于CPU计算而不是设计和编写算法时,它几乎就像魔术一般神奇。在这个“1k玫瑰”的案例中,蒙特卡洛方法对代码大小的优化起到了重要的作用。

如果你不太了解蒙特卡洛方法,你可以通过维基百科上这篇优秀的文章窥探一二。(https://zh.wikipedia.org/zh-cn/%E8%92%99%E5%9C%B0%E5%8D%A1%E7%BE%85%E6%96%B9%E6%B3%95

摘要

蒙特卡洛方法(Monte Carlo method),也称统计模拟方法,是二十世纪四十年代中期由于科学技术的发展和电子计算机的发明,而被提出的一种以概率统计理论为指导的一类非常重要的数值计算方法。是指使用随机数(或更常见的伪随机数)来解决很多计算问题的方法。

在解决实际问题的时候应用蒙特卡洛方法主要有两部分工作:用蒙特卡洛方法模拟某一过程时,需要产生各种概率分布的随机变量。用统计方法把模型的数字特征估计出来,从而得到实际问题的数值解。

显式曲面和采样/绘图

为了描绘玫瑰的形状,我使用了31个显式定义的曲面:24个花瓣,4个萼片(花瓣周围的薄叶),2片叶子和1个枝条。

那么,这些曲面是如何协同工作的呢?其实很容易,这里我提供一个二维的例子:
首先定义表面函数:

function surface(a, b)
{ // 使用取值范围在0-1之间的两个参数a和b
    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以0.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);
}
}

结果如下:

xiaoguo

现在,让我们尝试一下更密集的采样间隔(小间隔=更密集采样):

你可以看到,随着取样间隔越来越小,点之间的距离也越来越小,直至距离小于一个像素点,这时屏幕已经被完全填充(见0.01),此时若再缩小间隔,已经看不出明显差异(比较0.01和0.001的效果),所以抽样间隔选取0.01是比较合适的。
好了,现在让我们重新定义一个表面函数,画一个圆。有很多方法可以用,这里我使用这个公式:

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;
}
}

因为我们不使用圆外的点,所以应该在现有的抽样条件下再加一个条件:

if (position = surface(a, b))
{
context.fillRect(position.x, position.y, 1, 1);
}

结果是:

正如之前所说,有很多方法来定义一个圆,其中一些方法不需要在抽样时拒绝一些点(例如圆外的点)。下面我展示一种,但只是作为一个小拓展,之后我不会再继续使用它:

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 // deformation
        };
}
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), // this will add a gradient
            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);
}
}
}

效果:

这就是一个有颜色的花瓣了!

三维表面和透视投影

定义三维表面是很简单的:只需在表面函数中添加一个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, 
             // by subtracting length/2 I have centered the tube at (0, 0, 0)
        r : 0,
g : Math.floor(b * 255),
b : 0
};
}

现在,要增加透视投影,首先我们要定义一个摄像头:

我会把我的相机放在在(0,0,cameraZ)这个点,从相机到画布的距离我会称之为“视角”,画布可以看作X / Y平面,以(0,0,cameraZ +视角)为中心。现在,每个采样点都将被投影到画布上:

var pX, pY, // projected on canvas x and y coordinates
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缓存

Z Buffer(Z 缓存)是计算机图形学中的一门常见技术,Z-buffering是在为物件进行着色时,执行“隐藏面消除”工作的一项技术,所以隐藏物件背后的部分就不会被显示出来。 在3D环境中每个像素中会利用一组数据资料来定义像素在显示时的纵深度(即Z轴坐标值)。

这就是一个可视化的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);
}
}
}
}

 下面让我们旋转汽缸

可以使用任何矢量旋转方法,玫瑰花的案例中我使用的是欧拉旋转,下面让我们来实现一个绕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参数被设置为2个随机值。采样足够的点,表面就会被完全填充。我每次画10000个点,然后让屏幕根据间隔自动更新。

顺便说一下,表面能够填充满的唯一保证是伪随机数发生器是正常的。在一些浏览器,Math.random是通过线性同余发生器实现的,这可能会导致一些问题。如果你需要一个好的PRNG(伪随机数生成器)来采样,你可以使用高质量的Mersenne Twister(随机数生成器:梅森绞扭器,用JS实现),或一些浏览器提供的加密随机数发生器,使用低差异序列也是很明智的。

最后

要完成这多玫瑰,让每一部分,每个表面,在同一时间呈现。我在函数中增加了第三个参数,用于选择玫瑰的一部分并返回其起点。在数学上,它是一个分段函数,其中每一段都代表玫瑰的一部分。在绘制花瓣时,我用旋转和拉伸变形来创建所有的花瓣。所有细节的实现都是与这篇文章展现的概念相结合的。

通过抽样方法创建显式表面是一个很好的方法,是三维图形学最古老的方法之一,我的分段/蒙特卡洛/ z缓充方法已经多次用于艺术创作。虽然不是很有创新性,并且在现实生活的场景也不是很有用,但它很适合用在重视代码简洁与大小的js1k赛事中。
通过这篇文章,我真的希望能够激发读者对计算机图形学的兴趣,并从不同的渲染方法中找到乐趣。在满是图形的世界里玩耍时一件多么令人开心的事情!

原创翻译,转载请注明以上信息

发帖时间: web 归档位置:

关于 “1K的玫瑰——只属于程序员的浪漫” 的 1 个意见

评论关闭。