Как из пустой пластиковой бутылки, картинки и шейдера сделать игру для iOS за выходные

в 8:59, , рубрики: glsl, iOS, opengl es, swift, начинающим, разработка под iOS

Как-то раз я спросил у своей жены:
— У нас же нет планов на выходные?
— Вроде нет, — ответила она.
— Тогда я еще разок поковыряю этот Swift.
— Поковыряй.

И вот я поставил перед собой задачу, написать очень простую игрушку для iOS на Swift'е, не прибегая к какому-либо ^.*C.*$ (прошлый мой опыт ознакомления со Swift'ом закончился тем, что 80% проекта были написаны на Objective-C (который из-за моего С++'ного мышления, сократился до ближайшего известного мне (Objective-C)+2C-Objective = C)).

Задача

Дано: Одна картинка, какие-то соображения в голове.
Надо: Игра написанная до звонка будильника в понедельник.

Не буду подробно описывать вещи, которые прошли для меня безболезненно, надеюсь, что и у вас они не должны вызвать недопонимания.

Сущности для работы с OpenGL

Не спрашивайте, почему я пишу на чистом OpenGL, а не использую какой-нибудь SpriteKit, я сам не знаю ответа на этот вопрос.
Итак, я создал проект, открыл редактор основного Storyboard, удалил тут всё. Затащил на доску GLKViewController, назначил ему класс GameViewController, его вьюшке — GameView:

import UIKit
import GLKit

class GameView: GLKView {
    override func drawRect(rect: CGRect) {
        glClearColor(0.8, 0.4, 0.2, 1.0)
        glClear(GLbitfield(GL_COLOR_BUFFER_BIT))
    }
}

Обратите внимание на вызов glClear: эта функция принимает аргумент типа GLbitfield (UInt32). Ах, какая незадача, ведь константа GL_COLOR_BUFFER_BIT типа Int32, а в Swift'е запрещено неявное приведение типов. Этот факт, сначала огорчил меня, однако потом я осознал, что этот великолепный запрет заставляет код невнимательного программиста (да, вы, конечно же внимательный и вам это ни к чему) быть чуточку лучше.

Нажимаем Win+R ⌘+R, и что же мы видим? Нет, не оранжевый экран — белый. Все потому, что мы забыли проинициализировать контекст OpenGLES:

class GameViewController: GLKViewController {
    var gameView: GameView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        gameView = self.view as GameView
        gameView.context = EAGLContext(API: .OpenGLES3)
        assert(gameView.context != nil, "Cannot to initialize OpenGL ES3.")
    }
}

Казалось бы, что мешало сделать это без нашего ведома, а нас попросить, лишь, указать версию OpenGLES в редакторе storyboard? Не будем раздувать щеки: думаю, у разработчиков GLKit'а были для этого серьезные основания.

Давайте теперь попробуем загрузить текстурку. Для этого я завел вот такой класс:

Класс Texture

class Texture {
    let name: GLuint = 0
    let width: GLsizei
    let height: GLsizei
    
    init(
        image: UIImage,
        wrapX: GLint = GL_CLAMP_TO_EDGE,
        wrapY: GLint = GL_CLAMP_TO_EDGE,
        filter: GLint = GL_NEAREST
        ) {
        let cgImage = image.CGImage
        
        width = GLsizei(CGImageGetWidth(cgImage));
        height = GLsizei(CGImageGetHeight(cgImage));
        let pixelCount = width * height
        
        var imageData = [UInt32](count: Int(pixelCount), repeatedValue:0)
        
        let imageContext = CGBitmapContextCreate(
            &imageData,
            UInt(width),
            UInt(height),
            8,
            UInt(width * 4),
            CGImageGetColorSpace(cgImage),
            CGBitmapInfo(CGImageAlphaInfo.PremultipliedLast.rawValue)
        )
        
        CGContextDrawImage(imageContext, CGRect(x: 0, y: 0, width: Int(width), height: Int(height)), cgImage);
        
        glGenTextures(1, &name);
        glBindTexture(GLenum(GL_TEXTURE_2D), name);
        
        glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MIN_FILTER), filter);
        glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_MAG_FILTER), filter);
        glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_S), wrapX);
        glTexParameteri(GLenum(GL_TEXTURE_2D), GLenum(GL_TEXTURE_WRAP_T), wrapY);
        
        glTexImage2D(GLenum(GL_TEXTURE_2D), 0, GL_RGBA, GLsizei(width), GLsizei(height), 0, GLenum(GL_RGBA), GLenum(GL_UNSIGNED_BYTE), imageData);
    }
    
    deinit {
        glDeleteTextures(1, [name])
    }
}

Краткая суть инициализации: берем UIImage (картинка общего назначения), превращаем ее в CGIImage (картинка для обработки), создаем CGContext (контекст для рисования с доступным для нас участком памяти — imageData), рисуем в этом контексте нашу картинку, а потом отправляем ее в память видеокарты при помощи glTexImage2D. У вас, наверное, возникла пара вопросов?

  1. О мой Глоб! Зачем столько шагов? Разве нельзя у UIImage попросить getData?
  2. Мне больно смотреть на эти GLenum(..). Почему Swift такой жестокий?

У меня, наверное, возникла пара ответов:

  1. Неа, нельзя. И это кратчайшая цепочка трансформаций после которой можно получить доступ к байтам картинки, которую я смог слепить используя стандартные функции.
  2. Думаю в каком-нибудь обновлении Apple исправит эти автоматические преобразования C'шного кода в Swift'овый и типы аргументов функций начнут согласоваться с типами реальных аргументов, ну а пока терпим.

Небольшая горсть синтаксического сахара: амперсанд перед переменной превращает ее в UnsafeMutablePointer (класс для работы с C'шными указателями в Swift'е); а массивы приводятся (implicit casting, таки, detected!) к типу UnsafePointer.

Не могу не привести класс шейдера:

Класс Shader
class Shader {
    let handle: GLuint
    
    init(name: String, type: GLint) {
        let file = NSBundle.mainBundle().pathForResource(name, ofType: (type == GL_VERTEX_SHADER ? "vert" : "frag"))!
        
        let source = NSString(contentsOfFile: file, encoding: NSUTF8StringEncoding, error: nil)!
        
        var sourceCString = source.UTF8String
        var sourceLength = GLint(source.length)
        
        handle = glCreateShader(GLenum(type))
        
        glShaderSource(handle, 1, &sourceCString, &sourceLength)
        
        glCompileShader(handle);
        
        var compileSuccess = GLint(-42)
        glGetShaderiv(handle, GLenum(GL_COMPILE_STATUS), &compileSuccess)
        
        if (compileSuccess == GL_FALSE) {
            var log = [GLchar](count:1024, repeatedValue: 0)
            glGetShaderInfoLog(handle, GLsizei(log.count), nil, &log)
            NSLog("nShader '(name)' is wrong: [n(NSString(bytes: log, length: log.count, encoding: NSUTF8StringEncoding)!)n]")
        }
    }
    
    deinit {
        glDeleteShader(handle)
    }
}

В нем нет ничего интересного, просто очередная попытка пропихнуть не С'шные объекты в C'шные функции.

Шейдеры

Давайте нарисуем на весь экран два треугольника, чтобы для каждого пикселя на экране был исполнен фрагментный шейдер, который сотворит небольшое чудо.
Очумелые ручки начинаются! Возьмем старый, никому не нужный вершинный шейдер:

attribute vec2 vertex;
varying vec2 coord = vertex;
void main(void) {
    gl_Position = vec4(vertex, 0., 1.);
}

Теперь берем пустую пластиковую бутылку (серьезно?) отрезаем ее дно и смотрим в воронку — вот как-то так и будет выглядеть наша игра. Спокойно, сейчас я все объясню: мы возьмем конформное отображение квадрата на плоскость устроенное следующим образом:

Как из пустой пластиковой бутылки, картинки и шейдера сделать игру для iOS за выходные - 1

И попробуем варьировать его так, будто мы движемся по тоннелю. Сделаем набросок для фрагментного шейдера:

uniform sampler2D img;
varying vec2 coord;
float pi2 = 6.2832;
void main() {
  float r = length(coord);
  float a = atan( coord.y , coord.x );
  vec2 uv = vec2(a/pi2, r);
  gl_FragColor = texture2D(img, uv);
}

Да, мы просто перевели прямоугольные координаты изображения в полярные и уже добились чего хотели, но не того, что задумали.

Изображение в полярных координатах

Как из пустой пластиковой бутылки, картинки и шейдера сделать игру для iOS за выходные - 2

Дело в том, что если вы представите бесконечный тоннель и посмотрите вдоль него, то ни за что не увидите конца тоннеля. Он где-то там, в бесконечно удаленном центре… Постойте-ка! Бесконечно удаленном? Вы наверняка знаете, как в домашних условиях без всяких хитрых приспособлений получить бесконечность? Конечно! В центре экрана мы поделим на ноль:

vec2 uv = vec2(a/pi2, 1./r);

Тоннель

Как из пустой пластиковой бутылки, картинки и шейдера сделать игру для iOS за выходные - 3

Давайте теперь немного прогуляемся по тоннелю:

gl_FragColor = texture2D(img, uv + uv(0, pos.z));

Переменная pos.z увеличивается с течением времени, а мы бежим по тоннелю вперед. Теперь добавим перемещение в экранной плоскости:

vec2 newCoord = coord + pos.xy;

Вот что получилось

Как из пустой пластиковой бутылки, картинки и шейдера сделать игру для iOS за выходные - 4

Вышла какая-то ерунда, мы просто сдвинули рисуемую картинку. Попробуем добиться эффекта перспективы: представьте что вы внутри тоннеля. Теперь начните двигаться вправо тогда, все что правее центра начнет сплющиваться, а все, что левее — растягиваться:

float r = length(newCoord) - dot(newCoord, pos);

Что ты наделал, демон? Да у меня радиус теперь — барби-сайз!

Странный радиус

Как из пустой пластиковой бутылки, картинки и шейдера сделать игру для iOS за выходные - 5

И почему же это преобразование делает то, что нам нужно? dot — это функция, считающая скалярное произведение двух векторов, т.е.

dot(newCoord, pos) = newCoord.x * pos.x + newCoord.y * pos.y

Давайте рассмотрим первое слагаемое: если отклонение pos.x и координата newCoord.x, для которой мы считаем радиус, имеют одинаковый знак (значит newCoord.x отстает от центра тоннеля в направлении сдвига pos.x) то из радиуса вычитается положительная величина, радиус в этом направлении уменьшается и сплющивает изображение; когда же pos.x и newCoord.x имеют разный знак — происходит растяжение. Аналогичное происходит с y-координатами. Хоть это и не честная перспектива, зато считается очень быстро; а при малых отклонениях обман почти не заметен.
Теперь сменим обстановку. Мне уже надоело смотреть на эти бетонные стены.

Смена обстановки

Как из пустой пластиковой бутылки, картинки и шейдера сделать игру для iOS за выходные - 6

Добавим размытие при движении на большой скорости:

float ds = speed / blurCount;
for (float dx = -speed; dx < speed; dx += ds)
  gl_FragColor += texture2D(img, uv + vec2(0, pos.z + dx));
gl_FragColor /= gl_FragColor.a;

Мы только что усреднили несколько изображений, сдвинутых вдоль направления движения, и получили эффект быстрой езды:

Блюююююр!

Как из пустой пластиковой бутылки, картинки и шейдера сделать игру для iOS за выходные - 7

Добавим огни светофора:

gl_FragColor += texture2D(img, uv + vec2(0, pos.z + dx)) + lightColor / distance(uv, lightPos);

Просто добавили некоторый цвет обратно пропорционально расстоянию от источника света.

Вот что получилось

Как из пустой пластиковой бутылки, картинки и шейдера сделать игру для iOS за выходные - 8

На этом, пожалуй, с графикой — все.

Игровой цикл

Для того, чтобы реализовать игровую логику сделаем GameView делегатом GLKViewController:

class GameView: GLKView, GLKViewControllerDelegate { /*...*/ }

В редакторе storyboard нажмите по GLKViewController правой кнопкой мыши и соедините поле delegate с GLKView. Теперь, если вы определите в GameView метод glkViewControllerUpdate, то он будет вызываться перед отрисовкой каждого кадра. В нем я реализовал логику игры для каждого кадра: завел несколько состояний игры (ускорение, торможение, пауза) и для каждого состояния описал поведение скорости движения и позиции камеры; сделал реакцию на проезд на красный свет; добавил проигрыш при слишком медленной езде.

Всё

После небольших доработок я выложил игру на AppStore. Процесс публикования игры занял большее время, чем ее создание: снятие скриншотов для всех платформ, добавление описания и тегов, ожидание одобрения Apple'ом (о ужас, 9 дней!).

Swift обладает множеством удобных особенностей, по сравнению с моим основным языком; разработка под iOS мне показалась достаточно приятным занятием. Так что ждите новых статей по этой теме.

Автор: 0xff80ff

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js