如何做一个iOS分形App

介绍

在这个教程中,我们会做一个可以渲染Mandelbrot Set的应用程序,我们可以缩放和平铺它来看分形那令人惊叹的复杂之美。最终的结果如下:

着色程序的代码

void main() {

#define iterations 128

vec2 position = v_tex_coord; // gets the location of the current pixel in the intervals [0..1] [0..1]

vec3 color = vec3(0.0,0.0,0.0); // initialize color to black

vec2 z = position; // z.x is the real component z.y is the imaginary component

// Rescale the position to the intervals [-2,1] [-1,1]

z *= vec2(3.0,2.0);

z -= vec2(2.0,1.0);

vec2 c = z;

float it = 0.0; // Keep track of what iteration we reached

for (int i = 0;i < iterations; ++i) {

// zn = zn-1 ^ 2 + c

// (x + yi) ^ 2 = x ^ 2 - y ^ 2 + 2xyi

z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);

z += c;

if (dot(z,z) > 4.0) { // dot(z,z) == length(z) ^ 2 only faster to compute

break;

}

it += 1.0;

}

if (it < float(iterations)) {

color.x = sin(it / 3.0);

color.y = cos(it / 6.0);

color.z = cos(it / 12.0 + 3.14 / 4.0);

}

gl_FragColor = vec4(color,1.0);

}

你可以下载起始版本跟着教程一起做,也可以在本文结尾找到最终版本的代码。

项目设置

Gamescene.sks文件里包含一个名为fractal的子画面,它填充了整个界面并且着色程序程序Fractal.fsh也附在它上。

Fractal.fsh包含了上面着色程序的代码

GameViewController.swift包含了设置游戏场景的代码

GameScene.swift为空

如果你现在运行代码,你将会得到如下的结果:

请注意纵横比固定为3/2,我们需要先根据屏幕大小调节它。

并且由于画面是静态的,所以你不可能与它有任何方式的交互。

设置界面

我们将用一个透明的scrollview来处理平铺缩放。scrollview将自动跟踪我们的位置以及我们在分形中的缩放程度。

打开`Main.storyboard`文件,拖进去一个scrollview。将scrollview设置成fill the view,并对它的宽度,到顶部距离,到底部距离设置限制。

将scrollview的最大缩放程度设置为100000,意味着我们将可以把分享放大到十万倍!我们不能再放大更多了因为已经接近了`float`类型的准确极限。

拖一个view(画面)到scrollview里,它将用作处理缩放。这个view本身不会展示任何东西,我们将用到它的contentOffset和scrollView的zoom属性来更新我们的着色程序。要确保这个画面可以填满scrollView,并且设定好宽度,到顶部底部左右距离的限制。将画面的背景色设置为 Clear Color (透明色)。

接下来我们将连接我们所需要的outlet和scrollView的代理。

给scrollView和scrollView的contentView拖进outlet。

class GameViewController: UIViewController, UIScrollViewDelegate  {

@IBOutlet weak var contentView: UIView!

@IBOutlet weak var scrollView: UIScrollView!

...

}

接下来我们去掉代理方法,并且实现viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView?这个方法

class GameViewController: UIViewController, UIScrollViewDelegate  {

...

func scrollViewDidScroll(scrollView: UIScrollView) {

}

func scrollViewDidZoom(scrollView: UIScrollView) {

}

func viewForZoomingInScrollView(scrollView: UIScrollView) -> UIView? {

return contentView

}

...

}

向着色程序发送数据

着色程序可以从你的swift代码里的uniform变量里获得数据。uniform变量可以在SpriteKit编辑器里声明。那现在我们来声明一下uniform变量。

打开GameScene.sks文件,选择 mandelbrote sprite。将insepctor拖到底部,在“Custom shader Uniforms”里添加两项:float类型的zoom,值为1, 以及vec2类型的offset。我们将用这两项uniform变量储存scrollView的contentOffset以及zoom属性。

警告:Xcode 6.3的uniform变量有bug。它不能直接在编辑器里赋值初始化,你必须在代码里初始化它们。

我们可以通过shader属性来获取节点上(node)着色程序,用theuniformedName()方法来从着色程序得到uniform变量。以下是我们获取zoom uniform变量的例子:

let zoomUniform = node.shader!.uniformNamed("zoom")!

Once we have a uniform we can change its value via one of of the properties

当我们有了uniform变量后,我们可以通过它的属性来改变它的值。

var textureValue: SKTexture!

var floatValue: Float

var floatVector2Value: GLKVector2

var floatVector3Value: GLKVector3

var floatVector4Value: GLKVector4

var floatMatrix2Value: GLKMatrix2

var floatMatrix3Value: GLKMatrix3

var floatMatrix4Value: GLKMatrix4

We’re only interested in usingfloatValueandfloatVector2Valuefor this tutorial.

在本教程里,我们只对floatValue和floatVector2Value感兴趣。

Ex: to set the zoom to 2 we use

例子:将zoom的值设置成2

zoomUniform.floatValue = 2

Coordinate systems and mapping intervals

坐标系以及映射出区间

我们将在保持比例的基础上映射不同的坐标系。我们将用这个来转化scrollview的坐标到复平面。

让我们先看一下一维的情况:

将x从区间[0,a]映射到区间[0,1],我们只需要除以区间长度x' = x / a。

将x从区间[0,1]映射到区间[a,b],我们可以乘上区间长度,然后再加上区间起始值,x' = x * (b - a) + a。

举个例子,比如iPhone4的x坐标,x坐标为0到480之间。映射x到[0,1], 我们用x' = x / 480。映射x'从[0,1]到[-2,2],我们用x'' = x' * 4 - 2

如果我们屏幕上有一点x,坐标值为120,那么对应到区间[0,1]将成为120 / 480 = 0.25,以及在区间[-2,2],如下所见它将成为0.25 * 4 - 2 = -1。

Mapping between the scrollview and the complex plane

scrollView及复平面互相映射

我们需要讲scrollView上的点转换到复平面。第一步,先将scrollView上的点转换到区间[0,1]。通过将contentOffset除以contentSize可以将contentOffset转换到区间[0,1]。

var offset = scrollView.contentOffset

offset.x /= scrollView.contentSize.width

offset.y /= scrollView.contentSize.height

我们着色程序x,y坐标都有点在区间[0,1],所以我们要在scrollView的contentView里映射出这些店。

标准化过的contentView为1.0 / zoom,所以contentView里标准化过的点坐标讲在区间[contentOffset / contentSize,contentOffset / contentSize + 1.0 / zoom]。

还有我们必须牢记的是,y轴的点在GLSL上,而点(0,0)在左下角,所以我们必须翻转y轴来对应我们的scrollView。

下面的GLSL代码转换scrollView的contentView里点的位置。

// Fractal.fsh

void main {

vec2 position = v_tex_coord;

position.y = 1.0 - position.y; // flip y coordinate

vec2 z = offset + position / zoom;

...

}

如下你可以看见蓝色的scrollView的contentView在标准化与未标准化过的边框。contentSize = (960,640),contentOffset = (240,160),zoom = 2.0

ScrollView

标准化过的ScrollView

最后我们将点映射到复平面。为了在mandelbrot里得到好看的效果,我们将希望映射区域[-1.5,0.5] x [-1,1]复平面。

我们还想使纵横比正确。现在我们的x、y轴的比例一样,我们要乘以x和纵横比使得图片不会变形。

纵横比是什么

纵横比是屏幕宽度和高度的比例。

// Fractal.fsh

void main {

...

z *= 2.0;

z -= vec2(1.5,1.0);

float aspectRatio = u_sprite_size.x / u_sprite_size.y;

z.x *= aspectRatio;

...

}

下面你可以看到我们scrollView的contentView映射到的平复面以及纠正过纵横比的结果。

为了整合上面所有代码,我们建了一个新的方法叫updateShader,它可以传一个contentView坐标到着色程序。我们所需要做的就是在scrollView的代理方法里调用updateShader方法。

class GameViewController: UIViewController, UIScrollViewDelegate  {

...

func updateShader(scrollView: UIScrollView) {

let zoomUniform = node.shader!.uniformNamed("zoom")!

let offsetUniform = node.shader!.uniformNamed("offset")!

var offset = scrollView.contentOffset

offset.x /= scrollView.contentSize.width

offset.y /= scrollView.contentSize.height

zoomUniform.floatValue = Float(scrollView.zoomScale)

offsetUniform.floatVector2Value = GLKVector2Make(Float(offset.x), Float(offset.y))

}

func scrollViewDidScroll(scrollView: UIScrollView) {

updateShader(scrollView)

}

func scrollViewDidZoom(scrollView: UIScrollView) {

updateShader(scrollView)

}

...

}

同时也别忘了当view出现时调用updateShader方法,这样你才可以初始化uniform变量。

class ViewController {

...

override func viewDidAppear(animated: Bool) {

super.viewDidAppear(animated)

updateShader(scrollView)

}

...

}

最终着色程序的如下所示:

void main() {

#define iterations 128

vec2 position = v_tex_coord; // gets the location of the current pixel in the intervals [0..1] [0..1]

position.y = 1.0 - position.y;

vec2 z = offset + position / zoom;

z *= 2.0;

z -= vec2(1.5,1.0);

float aspectRatio = u_sprite_size.x / u_sprite_size.y;

z.x *= aspectRatio;

vec2 c = z;

float it = 0.0; // Keep track of what iteration we reached

for (int i = 0;i < iterations; ++i) {

// zn = zn-1 ^ 2 + c

// (x + yi) ^ 2 = x ^ 2 - y ^ 2 + 2xyi

z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);

z += c;

if (dot(z,z) > 4.0) { // dot(z,z) == length(z) ^ 2 only faster to compute

break;

}

it += 1.0;

}

vec3 color = vec3(0.0,0.0,0.0); // initialize color to black

if (it < float(iterations)) {

color.x = sin(it / 3.0);

color.y = cos(it / 6.0);

color.z = cos(it / 12.0 + 3.14 / 4.0);

}

gl_FragColor = vec4(color,1.0);

}

Complete Source Code

完整代码

挑战

1 . 优化

黑色部分渲染的最慢。幸好根据下图,我们可以很快知道一个点是否在两块黑色部分之一里 (心形部分或者区域2)。这里你可以找到如何判断点是否在两块黑色区域之一里的方法。加上这些代码来改进着色程序,它们只会在点不在这两个区域里执行mandelbrot循环。这将大幅度提高app在这些区域可见时的表现。

见下图,主要的心形为红色,区域2为绿色。

Hint

提示

只当点在这些区域中的一个以外的时候执行mandelbrot循环。

Solution

答案

void main() {

#define iterations 128

vec2 position = v_tex_coord; // gets the location of the current pixel in the intervals [0..1] [0..1]

position.y = 1.0 - position.y;

vec2 z = offset + position / zoom;

z *= 2.0;

z -= vec2(1.5,1.0);

float aspectRatio = u_sprite_size.x / u_sprite_size.y;

z.x *= aspectRatio;

vec2 c = z;

bool skipPoint = false;

//    cardioid checking

if ((z.x + 1.0) * (z.x + 1.0) + z.y * z.y < 0.0625) {

skipPoint = true;

}

//    period 2 checking

float q = (z.x - 0.25) * (z.x - 0.25) + z.y * z.y;

if (q * (q + (z.x - 0.25)) < 0.25 * z.y * z.y) {

skipPoint = true;

}

float it = 0.0; // Keep track of what iteration we reached

if (!skipPoint) {

for (int i = 0;i < iterations; ++i) {

// zn = zn-1 ^ 2 + c

// (x + yi) ^ 2 = x ^ 2 - y ^ 2 + 2xyi

z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);

z += c;

if (dot(z,z) > 4.0) { // dot(z,z) == length(z) ^ 2 only faster to compute

break;

}

it += 1.0;

}

}

vec3 color = vec3(0.0,0.0,0.0); // initialize color to black

if (it < float(iterations) && !skipPoint) {

color.x = sin(it / 3.0);

color.y = cos(it / 6.0);

color.z = cos(it / 12.0 + 3.14 / 4.0);

}

gl_FragColor = vec4(color,1.0);

}

完整代码

2 . 做一个类似的app,可以让你探索Julia set 的某点c。

例子: vec2 c = vec2(-0.76, 0.15);

Solution

答案

void main() {

#define iterations 128

vec2 position = v_tex_coord; // gets the location of the current pixel in the intervals [0..1] [0..1]

position.y = 1.0 - position.y;

vec2 z = offset + position / zoom;

z *= 2.0;

z -= vec2(1.0,1.0);

float aspectRatio = u_sprite_size.x / u_sprite_size.y;

z.x *= aspectRatio;

vec2 c = vec2(-0.76, 0.15);

float it = 0.0; // Keep track of what iteration we reached

for (int i = 0;i < iterations; ++i) {

// zn = zn-1 ^ 2 + c

// (x + yi) ^ 2 = x ^ 2 - y ^ 2 + 2xyi

z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);

z += c;

if (dot(z,z) > 4.0) { // dot(z,z) == length(z) ^ 2 only faster to compute

break;

}

it += 1.0;

}

vec3 color = vec3(0.0,0.0,0.0); // initialize color to black

if (it < float(iterations)) {

color.x = sin(it / 3.0);

color.y = cos(it / 6.0);

color.z = cos(it / 12.0 + 3.14 / 4.0);

}

gl_FragColor = vec4(color,1.0);

}

完整代码

3 . 添加一个点c的uniform变量,使用户可以用两个手指改变其值。

提示

用UIPanGestureRecognizer来检测两个手指的范围。你需要标准化手势识别器传来的结果。

答案

class GameViewController: UIViewController, UIScrollViewDelegate {

...

var c: GLKVector2 = GLKVector2Make(0, 0)

override func viewDidLoad() {

...

let panGr = UIPanGestureRecognizer(target: self, action: "didPan:")

panGr.minimumNumberOfTouches = 2

view.addGestureRecognizer(panGr)

}

func didPan(panGR: UIPanGestureRecognizer) {

var translation = panGR.translationInView(view)

translation.x /= view.frame.size.width

translation.y /= view.frame.size.height

c = GLKVector2Make(Float(translation.x) + c.x, Float(translation.y) + c.y)

let cUniform = node.shader!.uniformNamed("c")!

cUniform.floatVector2Value = c

panGR.setTranslation(CGPointZero, inView: view)

}

}

完整代码

4 . 用一个图片来给julia分形涂色。有很多方法都可以实现,其中有一个很有意思的方法如下:

每一次循环都从图片里得到对应z的颜色。如果颜色不是透明的就跳出循环。

如果跑完所有循环,得到的颜色依旧不是透明的,那么就用它来填色对应的像素。

如果是透明的,那么就用另外一个公式来填点的颜色。比如标准化过的循环次数。

下面是一个用兔子照片来填色的julia分形。

Hint

提示

你需要再添加一个Texture类型的uniform变量,命名为image。你可以用vec4 color = texture2D(image,p)来得到texture在p位置的颜色。

答案

class GameViewController: UIViewController, UIScrollViewDelegate {

...

override func viewDidLoad() {

...

let imageUniform = node.shader!.uniformNamed("image")!

imageUniform.textureValue = SKTexture(imageNamed: "bunny")

}

...

}

vec4 getColor(vec2 p) {

if (p.x > 0.99 || p.y > 0.99 || p.x < 0.01 || p.y < 0.01) {

return vec4(0.0);

}

return texture2D(image,p);

}

void main() {

#define iterations 128

vec2 position = v_tex_coord; // gets the location of the current pixel in the intervals [0..1] [0..1]

position.y = 1.0 - position.y;

vec2 z = offset + position / zoom;

z *= 2.0;

z -= vec2(1.0,1.0);

float aspectRatio = u_sprite_size.x / u_sprite_size.y;

z.x *= aspectRatio;

vec2 c = vec2(-0.76, 0.15);

vec4 color = vec4(0.0); // initialize color to black

float it = 0.0; // Keep track of what iteration we reached

for (int i = 0;i < iterations; ++i) {

// zn = zn-1 ^ 2 + c

// (x + yi) ^ 2 = x ^ 2 - y ^ 2 + 2xyi

z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);

z += c;

color = getColor(z);

if (dot(z,z) > 4.0 || color.w > 0.1) { // dot(z,z) == length(z) ^ 2 only faster to compute

break;

}

it += 1.0;

}

if (color.w < 0.1) {

float s = it / 80.0;

color = vec4(s,s,s,1.0);

}

gl_FragColor = color;

}

完整代码

5 .类比分形,实验一下Mandelbrot的公式。这个是开放性的挑战。以下提供了两个例子

燃烧之船的分形

Formulazn = abs(zn-12 + c)

公式zn = abs(zn-12 + c)

GLSL

GLSL

z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);

z += c;

z = abs(z);

源代码

Sierpinski Julia

公式zn = zn-12 + 0.5 * c / (zn-12)

GLSLS

vec2 powc(vec2 z,float p) {

vec2 polar = vec2(length(z),atan(z.y,z.x));

polar.x = pow(polar.x,p);

polar.y *= p;

return vec2(polar.x * cos(polar.y),polar.x * sin(polar.y));

}

void main() {

...

z = vec2(z.x * z.x - z.y * z.y, 2.0 * z.x * z.y);

z += 0.5 * c * powc(z,-2.0);

...

}

源代码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 198,932评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,554评论 2 375
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 145,894评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,442评论 1 268
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,347评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,899评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,325评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,980评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,196评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,163评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,085评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,826评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,389评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,501评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,753评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,171评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,616评论 2 339

推荐阅读更多精彩内容

  • 深入理解傅里叶变换Mar 12, 2017 这原本是我在知乎上对傅立叶变换、拉普拉斯变换、Z变换的联系?为什么要进...
    价值趋势技术派阅读 5,708评论 2 2
  • 我还是一个年轻的小人儿 可在很久很久以前 我就决定要在二十多岁的某一天 骑马踏雪归家 用一整个季节的寒梅见证我的清...
    bb53503536bd阅读 156评论 0 1
  • 看了今天的晨读素材我想到了我们国家正在提倡的工匠精神,工匠精神其实也是专注力的表现,我们经常说“干一行爱一行”。 ...
    曹娜2017阅读 169评论 0 1
  • 东海之滨的太阳总着急升起,冉冉的在蔚兰的大海里晃悠着撩人的亮丽!停留在8楼的2路汽车缓缓启动着,似乎还在缠绵于午夜...
    ZG那时花开阅读 307评论 0 5
  • 最近老婆总是莫名其妙生气 每次都能被我哄好 不但不生气了 还会觉得 老公真的优秀
    一条人文主义狗_阅读 246评论 0 0