分类:每周造物

代码造物︱“小星球程序”指南

作者: Trousers pocket

今天,小编为大家介绍的的是Alan Luo教授设计的一款程序——“小星球程序”,准确的说,这是一个编程指南,内容包括“小星球程序”项目的概念以及它的操作步骤,如果小伙伴们觉得有趣,可以跟着步骤做一做哦!

该项目使用HTML5 Canvas编写。

1.概念 1.1噪声

在大多数情况下,整个项目有且仅有一个概念。

要明白这点,我们首先要考虑以下的问题:

如果我们要生成“几座山丘”,应该怎样操作呢?

我们想要的东西可能是这样的:

在这里,我们想要一些随机性(Randomness),但同时也要保证地形的有机性(Organic Nature)。

也就是,说我们想要让山丘看起来不规则,而且还要保证地形的自然流畅。

我们要的是局部相似性(Local Similarity),而非全局随机性(Global Randomness)。

所以,生成随机点(Random Points)是没有用的,因为它会导致过多的局部变动。

我们可以通过延伸随机点来进行调和,这一方法叫做“插值(interpolation)”。

Ps. 插值是一种图像处理方法,它可以为数码图像增加或减少像素的数目。

从技术层面上讲,使用更加复杂的插值,对山丘的脊线进行平滑处理,这种处理方法可以得到切实可行的地形。

事实上,让地形看起来逼真的方法有无数种,但我们只会考虑Perlin噪声(Perlin Noise)。

Perlin噪声拥有较复杂的细节,且很难被探索。

结果可以参考第一张图,它就是利用Perlin噪声生成的。

1.2更多的维度

我们可以将这个推论延伸到更高的维度。

如果想要生成三维地形应该怎么办呢?

我们希望在二维输入(two-dimensional input)的情况下,达到相同的局部相似性和全局变动。

我们不必将其描绘成三维地形,而只需将第三个维度,直接绘制在这个黑白连续体上。

它最后生成了类似“云”的图案。

Perlin噪声常常被应用于游戏和电影的特效制作上,而它的创始人Ken Perlin也因此获得了科技成就学院奖。

我们将Perlin噪声的“维度”称为输入维度(Dimension of the input)。

所以,2D地形是1D噪声,它由输入维度和输出维度(Dimension of output)生成。

而3D地形则是2D噪声。

有趣的是,第三维度不一定是物理层面的,我们可以通过3D空间来提升2D噪声平面,从而呈现出地形的变化,但在运用3D噪声时,我们确实只是将时间当作变量来使用。

接下来,定义噪声。

 N维噪声是从Rn 到连续区间[0, 1]的函数。

为了操控噪声,我们把它当作正弦函数来对待。

矢量为x的噪声函数f可采用“af(bx)+c”形式。

振幅从0到 a之间变化,使用伸缩因子b、以及偏移值c。

在这样的情况下,右侧的图像拥有与左侧图像一样的噪声,不同的是,右侧是左侧的拉长版。

现在这些基本的概念都清晰了,让我们开始活动吧!

2. 山脉与山丘

山脉与山丘都是使用一维Perlin噪声生成,但要注意两点:首先,山脉比山丘拥有更多混乱的噪声;其次,它们都有颜色“图层(Layers)”。

2.1倍频

倍频(Octaves)产生变化(variation)。

任意函数中,Perlin噪声都是遵循相长干涉(Constructive Interference)这一概念。

Ps.在波的叠加原理中,若两波的波峰(或波谷)同时抵达同一地点,称两波在该点同相,干涉波会产生最大的振幅,称为相长干涉(建设性干涉constructive interference)。

假设我们有一个正弦波,不妨看看它的平滑度。

现在假设我们想要在正弦波中插入一些变化,我们可以做的一件事,就是在上面叠加一个更小的正弦波——波长和幅度都很小的正弦波。

我们还可以一次次地叠加,获得更加“嘈杂(noisy)”的函数。

我们把这些称作“倍频”,是因为信号的基波、或者两倍基础频率、或者与sin(x)波相一致的sin(2x)波增加一个“倍频程”,等同于音乐上“提高一个八度”。

例如,钢琴上的A4音符是440Hz,A5是880Hz,A3则是220Hz…

同样地,我们可以为Perlin噪声创建一个“倍频”。

实际上,我写了一个用于绘制和填充噪声的函数:

function make1DNoise(axis, amplitude, scale, params) {
var newNoise = [];

for(var i=0; i<CANVAS_WIDTH; i++) {
    newNoise.push({x: i, y:axis+amplitude*
    params.noiseFunction(scale*i, params.zaxis)});  
}

newNoise.push(LOWER_LEFT); newNoise.push(LOWER_RIGHT);
ctx.fillStyle = params.fillColor;
fillPath(newNoise);

}

(fillPath不是一个默认的canvas函数——它用于绘制和填充点阵列[array of points]中的路径)

之后我为山脉和山丘分别编写了两个噪声函数:

function mountainNoise(x, z) {
return simplex.noise2D(z, x)+0.5simplex.noise2D(0, 2x +0.25simplex.noise2D(0, 4x)+0.125simplex.noise2D(0, 8x); } function hillNoise(x, z) {
return simplex.noise2D(z, x); }

(Simplex Noise只是Perlin噪声的一个变体)

2.2观察

你也许注意到:山脉和山丘都有色带。

要使山脉和山丘都有色带,首先要做以下的观察。

假设我们有一个与2D噪声交叉的平面,也就意味着我们从2D噪声那儿,分得了一部分的噪声。

如果要做到这点,本质上要固定输入的一个变量,换句话说,我们仅有一个剩余变量,而维度也因此降到了1D。

如果我们之后投射这一部分的噪声,会发现它是1D的。

既然2D噪声遵循局部相似性这一规则,我们就可以从2D噪声中获取一系列相邻的噪声,进而构建一系列相似、但不相同的噪声函数。

所以,通过把略有不同的固定y值,设置为2D噪声函数的输入值,就可以生成山脉和山丘上的色带了。

for(var i=0; i<3; i++) {
make1DNoise(430, 200, 0.005, {noiseFunction:mountainNoise, fillColor:toHslString(mountainshade), zaxis:0.07*i}); mountainshade.s-=0.05; mountainshade.l-=0.04; }

3. 河流

河流与山脉类似,它们是从噪声的最小点开始产生。

从这个最小点画出两条斜线。

再从两条斜线中,拉伸出一倍频Perlin噪声。

要创建四种色带,则将这一步骤重复四次。

4. 天空

天空的设计需要考虑配色方案,它共有两种类型的场景:夜晚和白天

4.1颜色理论

首先,我们需要介绍颜色理论(Color Theory)的基本概念。

我们可以将红、黄、蓝三种原色尽可能均匀地分布在圆周上,然后添加所有的颜色,我们把它称作色轮(Color Wheel)。

下面我将列出几种基本的颜色搭配:

(1)类比色(Analagous)

类比色搭配是选择一种颜色和其它相似的颜色。

Ps. 相邻的颜色我们称为类比色。

(2)补色(Complementary)

补色搭配也是选择一种颜色和其它相似的颜色。

Ps. 在色轮上直线相对的两种颜色,我们称为补色。

(3)三色组(Triad s)

三色组搭配选择彼此等距的三种颜色。

当然还有许多其它的配色方案,在此不再深入。

在我们的例子中,夜景使用的是类比色搭配,日景使用的是补色搭配。

4.2具体颜色

程序中大多数的颜色都被处理成hsb值而非rgb值。

大多数的计算机颜色是三个字节的形式:红色、绿色和蓝色分别一个值,范围从0到255。

但是,我将颜色处理为色调(Hue)、饱和度(Saturation)和亮度(Brightness)。

这样的方式更容易描述颜色“看起来像什么”,而不是修改rgb值。

色调描述了色轮上的角度位置,其中0=360度表示红色。

饱和度描述有多少颜色,0是灰色,1是完全着色。

亮度则描述有多少白色,0是全黑色,1是全黑色

我写了一个函数,它可以将hsb(亦称hsl)颜色输入转换成canvas可用的字符串。

function toHslString(color) {
return "hsl("+(color.h%1.0)360+", "+(color.s%1.0)100+"%, "+(color.l%1.0)*100+"%)"; }

颜色细节部分的修改有两种不同的方案——一种用于白天,一种用于夜晚,以下是完整的代码:

if(data.time"night") {
baseColor = randomColor({brightness:"dark"}); mountainColor = {h:baseColor.h+Math.random()0.2-0.1, s:baseColor.s+Math.random()0.2-0.1, l:baseColor.l+Math.random()0.1+0.15}; hillColor = {h:mountainColor.h+Math.random()0.2-0.1, s:mountainColor.s+Math.random()0.2-0.1, l:mountainColor.l+Math.random()0.1+0.15}; riverColor = {h:baseColor.h, s:baseColor.s-Math.random()0.1-0.05, l:baseColor.l+Math.random()0.1-0.05}; skyColor2 = { h:baseColor.h, s:baseColor.s, l:baseColor.l-0.2 };

cloudColor={h:baseColor.h, s:0.4, l:0.2};
treeColor={h:baseColor.h+0.5, s:0.4, l:0.2};
leafColor={h:treeColor.h+0.5, s:0.8, l:0.6};
planetColor={h:hillColor.h, s:0.4, l:0.4};

} else if(data.time"day") { baseColor = randomColor({brightness:"medium"}); mountainColor = {h:baseColor.h+0.4+Math.random()0.1, s:0.2+Math.random()0.2, l:baseColor.l+Math.random()0.1-0.05}; hillColor = {h:mountainColor.h+Math.random()0.2-0.1, s:0.4+Math.random()0.2, l:baseColor.l+Math.random()0.1}; riverColor = {h:baseColor.h, s:baseColor.s-Math.random()0.1-0.05, l:baseColor.l+Math.random()0.1+0.2 };

skyColor2 = {
    h:baseColor.h,
    s:baseColor.s,
    l:baseColor.l-0.2
};

cloudColor={h:baseColor.h, s:0.3, l:0.9};
treeColor={h:baseColor.h-0.25, s:0.6, l:0.4};
leafColor={h:treeColor.h+0.5, s:0.8, l:0.6};
planetColor={h:treeColor.h+0.5, s:0.8, l:0.6};

}

4.3三角剖分

你会发现,天空被分割成许多个三角形蜂窝,这个图案通过使用Delaunay三角剖分算法(Delaunay Triangulation)得来。

Delaunay三角剖分算法是由一组点织成的三角形网,而且里面的每个三角形都要尽可能地接近等边三角形。

这样的组合让整个图案看起来“漂亮且匀称”。

以下是我为天空编写的函数:

var grd=ctx.createLinearGradient(0,CANVAS_HEIGHT,0,0);
grd.addColorStop(0,colors.skyColor);
grd.addColorStop(1,colors.skyColor2);
ctx.fillStyle=grd;
ctx.fill();

var trianglePoints = [];
trianglePoints.push(
[0, CANVASHEIGHT], [0, 0], [CANVASWIDTH, CANVASHEIGHT], [CANVASWIDTH, 0]);

for(var i=0; i<15; i++) { //add some stuff on top and bototm
trianglePoints.push([Math.random()CANVAS_WIDTH, 0]); trianglePoints.push([Math.random()CANVASWIDTH, CANVASHEIGHT]); } for(var i=0; i<10; i++) { //add some stuff on the sides
trianglePoints.push([0, Math.random()CANVAS_HEIGHT]); trianglePoints.push([CANVAS_WIDTH, Math.random()CANVASHEIGHT]); } for(var i=0; i<50; i++) { //add some stuff in the middle
trianglePoints.push([Math.random()*CANVAS
WIDTH, Math.random()*CANVAS_HEIGHT]); }

for( /* do this for each triangle */) {
var center; center.x = (newtriangle[0].x+ newtriangle[1].x+ newtriangle[2].x)/3; center.y = (newtriangle[0].y+ newtriangle[1].y+ newtriangle[2].y)/3; var centercolor = ctx.getImageData(center.x, center.y, 1, 1).data;

var fillcolor = "rgb("+centercolor[0]+", 
                "+centercolor[1]+", "+centercolor[2]+")";
ctx.fillStyle = fillcolor;

fillPath(newtriangle);

}

5. 云

“云”也是使用Perlin噪声生成,我们运用一个阈函数来消除经过某一点的噪声。

如果我们从顶部往下看,便可以看到我们的“云”图案。

以下是我为“云”编写的函数:

function makeClouds(threshold, offset, variance) {
ctx.globalAlpha = 0.4; ctx.beginPath(); for(var i=0; iparams.threshold +Math.random()*params.variance) { drawPixel({x:i, y:j}, colors.cloudColor); } } } ctx.globalAlpha = 1.0; }

6.天体

场景中存在许多天体:白天的太阳,晚上的行星和恒星。

6.1恒星

我们在天空中的随机点上生成一群恒星,大多数的恒星都是小小的,但每个恒星都有随机成为“大恒星”的机会。

大恒星用于模拟星光闪烁,它们被建模成两个细长方形的焦点,渐变色填充。

//5% chance to make a big star if(Math.random()<0.05){
ctx.beginPath();

var starwidth = Math.random()*7+3;
ctx.rect(starx-1, stary-starwidth, 2, 2*starwidth);
ctx.rect(starx-starwidth, stary-1, 2*starwidth, 2);

var grd=ctx.createRadialGradient(starx, stary, 3, 
                                 starx, stary, starwidth+5);
grd.addColorStop(0,"white");
grd.addColorStop(1,"rgba(1, 1, 1, 0.0)");
ctx.fillStyle=grd;

ctx.fill();

}

6.2行星

行星几乎是被硬编码(hard-coded)的,在之后的工作中,我也许会增加一些变动(Variance)。

现在,我仅绘制出一个圆圈和一堆椭圆,并将它们以一定的角度组合,模拟出光线。

我使用的方法和制作“云”纹理的方法类似:

function makePlanet(position, radius, params) {
ctx.beginPath(); ctx.arc(position.x, position.y, radius, 0, 2*Math.PI); ctx.fillStyle = colors.planetColor; ctx.fill(); ctx.save(); ctx.clip();

//in a square around the planet
var xposmax = position.x+radius;
var yposmax = position.y+radius;
ctx.globalCompositeOperation = 'overlay';
for(var xpos=position.x-radius; xpos<xposmax; xpos++) {
    for(var ypos=position.y-radius; ypos<yposmax; ypos++) {
        if(simplex.noise2D(xpos, ypos)>0.1+Math.random()*0.2) {
            drawPixel({x:xpos, y:ypos}, 'rgba(0, 0, 0, 0.05)');
        }
        if(simplex.noise2D(xpos*0.03, ypos*0.03)>
                                0.1+Math.random()*0.2) {
            drawPixel({x:xpos, y:ypos}, 'rgba(0, 0, 0, 0.05)');
        }
    }
}
ctx.fillStyle="rgba(255, 255, 255, 0.1)";
ctx.beginPath();
ctx.ellipse(position.x+60, position.y-60, 60, 
        40, 45 * Math.PI/180, 0, 2 * Math.PI);
ctx.fill(); ctx.beginPath();
ctx.ellipse(position.x+40, position.y-40, 80, 
        60, 45 * Math.PI/180, 0, 2 * Math.PI);
ctx.fill(); ctx.beginPath();
ctx.ellipse(position.x+20, position.y-20, 100, 
        80, 45 * Math.PI/180, 0, 2 * Math.PI);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';

ctx.restore();

}

6.3太阳

太阳不过是个圆圈,以及周围另一个圆圈。

function makeSun(position, params) {
ctx.beginPath(); ctx.arc(position.x, position.y, params.innerradius, 0, 2*Math.PI); ctx.globalAlpha = 0.95; ctx.fillStyle=toHslString({h:baseColor.h, s:0.3, l:0.8}); ctx.fill();

ctx.arc(position.x, position.y, params.outerradius, 0, 2*Math.PI);
ctx.globalAlpha = 0.5;
ctx.fillStyle=toHslString({h:baseColor.h, s:0.3, l:0.9});
ctx.fill();

ctx.globalAlpha = 1.0;

}

7.树

树由两个部分组成。

这个程序也有两个画布组件(Canvas Component)。

第一个组件显示场景;

而每当我们用鼠标点击“一棵树”时,第二个组件则会显示“一棵分形树(Fractal Tree)”。

7.1小树

首先,我们应该考虑绘制树木的目的。我们将树干建模成一个矩形,把树叶建模成Blob状。

所以,什么是Blob?

我们应该如何创建?

本质上,我们想要的效果是,如果你从远处观察Blob,它的形状看起来是不规则的,但它仍然保持基本的圆形结构。

听起来很耳熟是吗?

其实,我们所采用的还是Perlin噪声循环。

我们用一堆顶点绘制一个圆圈。

然后,我们将圆上的每个顶点的坐标输入到噪声函数中,并将其拉伸。

var pointcount = 30, radius = 10, blobpoints;
for(var j=0; j var prepos = { x:leafcenter.x+radiusMath.cos(j(2Math.PI)/pointcount), y:leafcenter.y+radiusMath.sin(j(2Math.PI)/pointcount)}; newradius = radius + 5*simplex.noise2D(prepos.x, prepos.y);

var newpos = {
    x:leafcenter.x+newradius*Math.cos(j*(2*Math.PI)/pointcount),
    y:leafcenter.y+newradius*Math.sin(j*(2*Math.PI)/pointcount) };
blobpoints.push(newpos);

} fillPath(blobpoints);

7.2大树

用鼠标点击树,应该可以“放大”这棵树。

在同一棵树上点击两次,可以连续两次“放大”这棵树。

这是使用L-system完成的,我们不会深入这个L-system,但我们可以将其描述为一种递归绘制函数(Recursive Drawing Function)。

Ps. L-system(或Lindenmayer system)是一个相似重写系统,是一系列不同形式的正规语法规则,多被用于植物生长过程建模,但是也被用于模拟各种生物体的形态。

我们编写一个绘制线段的函数(树的“躯干”),然后再向另一侧伸展出两个额外的线段。

以下是为“大树”编写的函数:

function makeBigTree(newseed) {
resetCanvas(); setRandomSeet(newseed); branch(50); } function branch(len){
var theta = random()*(Math.PI/3);

drawLine({x:0, y:0}, {x:0, y:len}, ctx2); ctx2.translate(0, len);

len *= 0.66; if (len > 2) { ctx2.save(); ctx2.rotate(theta); branch(len); ctx2.restore();

ctx2.save();
ctx2.rotate(-theta);
branch(len);
ctx2.restore();

} }

canvas.addEventListener('mousedown', doMouseDown, false);
function doMouseDown(evt) {
var mousePos = getMousePos(canvas, evt); for(var i=0; iclickboxes[i].left && mousePos.xclickboxes[i].top
&& mousePos.y

9. 结论和工作展望

总之,这是一个有趣的项目,它告诉我们一个道理:小技巧也可以制作出非常酷炫的东西。

我还学过很多其它的技巧,但我并没有将它们逐一展示,包括分形(Fractals)和细胞自动机(Cellular Automata)。

如果一定要加入其它的内容,我想:

 实施情境类型:比如沙漠和草原的预置(Presets)…  使用更多地技巧  绘制一个更大的世界,把星系加入其中等等…

由于篇幅有限,小编略写了部分的内容,详情可移步: http://alanluo.com/procgen/midterm.html