这篇文章介绍了使用 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 Reply