Статьи

Физика, основанная на компьютерной музыке с AudioKit и SpriteKit



Этот пост смотрит на небольшое экспериментальное приложении , которое
я создал с помощью 
AudioKit  и 
SpriteKit  для создания музыки (в некотором роде) на основе простого моделирования физики. Приложение, являющееся бодро звучащей вещью, использующей SpriteKit, называется точно 
Sprittle .

Пользовательский интерфейс позволяет пользователям создавать статические блоки, которым назначают звуковую частоту в зависимости от их длины и прыгающие шарики. Когда шар сталкивается с коробкой, AudioKit воспроизводит звук « Vibes » на частоте коробки с амплитудой, основанной на скорости мяча.

Ящики создаются жестом панорамирования и могут перемещаться и вращаться. Шарики создаются длинным нажатием. Как только шарики падают с нижней части экрана, они снова появляются наверху в их исходном положении x. Хотя шары взаимодействуют с коробками, они не взаимодействуют друг с другом — это допускает регулярный повторяющийся узор.


Я расширил два класса SpriteKit для использования в Sprittle:
  • TouchEnabledShapeNode  расширяет  SKShapeNode  и используется для блоков. У него есть  свойство частоты, которое заполняет метку, а также принимает делегат типа  TouchEnabledShapeNodeDelegate,  который позволяет ему сообщать, когда к нему прикасались.
  • ShapeNodeWithOrigin также расширяет  SKShapeNode  и используется для шаров. Он имеет дополнительное  StartingPosition свойства. Это позволяет мне вернуть шары в их первоначальное положение x после того, как они обернуты вокруг экрана.

Грубо говоря, есть три основные части Sprittle, давайте посмотрим на каждую:


Обработка жестов


Контроллер 
представления
 обрабатывает жесты пользователя. Его вид имеет три разных распознавателя жестов, долгое нажатие, панорамирование и поворот.

Обработчик длинного нажатия  longPressHandler () довольно прост: он вызывает мою  функцию createBall (),  которая добавляет новый круговой  ShapeNodeWithOrigin  к сцене SpriteKit.

Обработчик панорамирования  panHandler () выполняет две функции: если в данный момент выбран блок, жест панорамирования перемещает этот блок, а при отсутствии выбранного блока — панорамирование.

Помня, что у жестов есть три основных состояния интереса в этом контексте. Запускается ход действий с жестом это Стали , где он устанавливает значение для  panGestureOrigin в  зависимости от местоположения этого жеста в. Поскольку жест панорамирования является непрерывным, каждый вызов  panHandler ()  с  измененным  состоянием перемещает выбранное поле на разницу между panGestureOrigin  и текущей позицией жеста:

            [...]
            else if recogniser.state == UIGestureRecognizerState.Changed
            {
                let currentGestureLocation = recogniser.locationInView(view)
                
                selectedBox!.position.x += currentGestureLocation.x - panGestureOrigin!.x
                selectedBox!.position.y -= currentGestureLocation.y - panGestureOrigin!.y
                
                panGestureOrigin = recogniser.locationInView(view)
            }
            [...]

Наконец, когда жест заканчивается или отменяется, я 
обнуляю  переменную panGestureOrigin

Если флажок не установлен, пан жест начать добавляет временный узел формы с именем  creatingBox  на сцену. Это действует как заполнитель во время процесса создания. С каждым вызовом Измененный  жест  , я должен воссоздавать это поле, потому что геометрия  SKShapeNodes  неизменна — я использую не только расстояние между текущим местоположением жеста и panGestureOrigin,  но и угол, чтобы установить вращение нового окна:

            [...]
            else if recogniser.state == UIGestureRecognizerState.Changed
            {
                creatingBox!.removeFromParent()
                
                let invertedLocationInView = CGPoint(x: recogniser.locationInView(view).x,
                    y: view.frame.height - recogniser.locationInView(view).y)
                
                let boxWidth = CGFloat(panGestureOrigin!.distance(invertedLocationInView)) * 2
                
                creatingBox = SKShapeNode(rectOfSize: CGSize(width: boxWidth, height: boxHeight))
                creatingBox!.position = panGestureOrigin!
                
                creatingBox!.zRotation = atan2(panGestureOrigin!.x - invertedLocationInView.x, invertedLocationInView.y - panGestureOrigin!.y) + CGFloat(M_PI / 2)
                
                scene.addChild(creatingBox!)
            }
            [...]

Наконец, после того, как жест панорамирования закончен, я на самом деле создаю прямоугольник в окончательном месте и под углом. Sprite содержит массивы частот, которые отображаются на музыкальные ноты, а метод  createBox ()  использует этот массив для назначения частоты блоку на основе его длины:

        [...]
        let frequencyIndex = Int(round((actualWidth - minBoxLength) / (maxBoxLength - minBoxLength) * CGFloat(frequencies.count - 1)))

        box.frequency = frequencies[frequencyIndex]
        [...]

Обработчик поворота, 
rotateHandler () , имеет значение только при выборе поля. Он использует технику, аналогичную 
panHandler () , в том, что когда жест начинается, он устанавливает значение 
rotateGestureAngleOrigin  в угол жеста и с каждым
измененным  шагом поворачивает выбранное поле на разницу между последним и текущим углами.


SpriteKit Физика


Контроллер представления действует как представитель контакта для сцены SpriteKit. Вся логика воспроизведения тонов, основанная на столкновениях и обертывании шариков, реализована в 
didBeginContact ()
. Эта функция передается экземпляру
SKPhysicsContact,
 который имеет свойства 
bodyA
 и 
bodyB
для двух 
SKPhysicsBody,
участвующих в столкновении. Глядя на 
категорию «
БитМаски» этих тел, я могу определить, какие субъекты столкновения являются шарами, телами или статическими объектами, такими как пол.


Если одним из этих тел является ящик, я могу быть уверен, что произошло столкновение с шариком. В этом случае я могу привести тело, являющееся прямоугольником, к 
TouchEnabledShapeNode 
и вычислить скорость другого тела. С этими двумя числами я могу сыграть тон:

        [...]
        if contact.bodyA.categoryBitMask == boxCategoryBitMask
        {
            let amplitude = Float(sqrt((contact.bodyB.velocity.dx * contact.bodyB.velocity.dx) + (contact.bodyB.velocity.dy * contact.bodyB.velocity.dy)) / 1500)

            let frequency = (contact.bodyA.node as? TouchEnabledShapeNode)?.frequency
                
            conductor.play(frequency!, amplitude: amplitude)
        }
        else if contact.bodyB.categoryBitMask == boxCategoryBitMask
        {
            // do the opposite
        [...]

Если одним из тел является пол, другим будет шар (который является  экземпляром ShapeNodeWithOrigin  ). Простая установка положения   экземпляра SKShapeNode со связанным физическим телом не работает, поэтому мне нужно временно удалить физическое тело, установить позицию с помощью  начального положения  и повторно применить физическое тело.

        [...]
        if contact.bodyA.categoryBitMask & ballCategoryBitMask == ballCategoryBitMask && contact.bodyB.categoryBitMask == floorCategoryBitMask
        {
            physicsBodyToReposition = contact.bodyA
        }
        else if contact.bodyB.categoryBitMask & ballCategoryBitMask == ballCategoryBitMask && contact.bodyA.categoryBitMask == floorCategoryBitMask
        {
            physicsBodyToReposition = contact.bodyB
        }
        
        if let physicsBodyToReposition = physicsBodyToReposition
        {
            let nodeToReposition = physicsBodyToReposition.node
            let nodeX: CGFloat = (nodeToReposition as? ShapeNodeWithOrigin)?.startingPostion?.x ?? 0
            
            nodeToReposition?.physicsBody = nil
            nodeToReposition?.position = CGPoint(x: nodeX, y: view.frame.height)
            nodeToReposition?.physicsBody = physicsBodyToReposition
        }
        [...]

AudioKit Sound


Заключительная часть проекта заключалась в добавлении 
 поддержки AudioKit
. Мой первоначальный подход состоял в том, чтобы назначить каждой коробке свой собственный инструмент, на котором играли по отдельности. Однако, остановка оркестра, добавление нового инструмента и перезапуск оркестра каждый раз, когда создавался новый ящик, были очень непостоянными. Затем я создал провайдера инструментов, который при запуске загружал инструменты и из которого каждый ящик запрашивал бы инструмент при создании экземпляра: этот подход работал, но был совершенно ненужным.


После сотрудничества с 
Аврелием Прочазкой
он реализовал гораздо лучшее решение. Он создал один 
класс Conductor
с одним инструментом. Это 
 метод play () класса Conductor 

который вызывается в  методе didBeginContact () выше. 



Когда создается 
Проводник  , он создает экземпляр 
BarInstrument  (который определяет тип звука, например, мандолину, удар по металлическому стержню или, в данном случае, «вибрирующий» звук), который в свою очередь имеет 
BarNote  (который определяет частоту и амплитуда). Когда  вызывается метод play () проводника 
, он создает новый 
BarNote  необходимой частоты и амплитуды и передает его своему инструменту через 
playNote () :
    func play(frequency: Float, amplitude: Float) {
        let barNote = BarNote(frequency: frequency, amplitude: amplitude)
        barNote.duration.value = 3.0
        barInstrument.playNote(barNote)
    }

Конечный результат, на мой взгляд, 
довольно эффективен . Короткие блоки создают более высокие ноты, а длинные — более низкие. Поскольку после первого цикла каждый шарик следует по одному и тому же пути, можно создавать регулярные шаблоны нот, а путем смешивания блоков различной длины можно создавать интересные окружающие звуковые ландшафты.

Еще раз, огромное спасибо Аврелию, его вклад оказался неоценимым в оптимизации кода. Конечно, большое спасибо всей   команде AudioKit , их библиотеки не только упрощают работу со звуком, но и их документация позволяет легко и быстро приступить к работе. Исходный код этого проекта  доступен в моем репозитории GitHub здесь .

Приложение: примечание к видео: как ни  странно, на некоторых машинах звук на видео, встроенном выше, слегка искажен. Более длинная коробка с частотой 130 Гц должна играть более низкую ноту, чем более короткую, но на некоторых машинах она играет более высокую ноту. Похоже, это проблема YouTube, звуковая дорожка источника правильная, а проблема со звуком есть только на некоторых машинах.