如何做一个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);

...

}

源代码

你可能感兴趣的:(如何做一个iOS分形App)