Статьи

BristlePaint: рельефная роспись с отдельными щетинками с использованием SpriteKit Normal Mapping

Исходя из  FurrySketch  и  MercurialPaint , мои эксперименты с рисованием и техниками рисования для iOS в Swift продолжаются с  BristlePaint . BristlePaint рисует отдельные щетинки кисти и использует  нормальное сопоставление SpriteKit, чтобы придать изображению красивый глянцевый рельефный эффект.

Это демо предназначено для iPad Pro и Pencil. Код чертежа использует углы силы, азимута и высоты для управления эффектом кисти, но, тем не менее, нет причин, по которым этот код нельзя изменить для работы со стандартными событиями касания.

В качестве демонстрации весь код объединен в одном  контроллере представления . Итак, без дальнейших церемоний, давайте прыгнем и посмотрим, как все это вместе.

обзор

Проще говоря, BristlePaint использует два  CoreImage Image Accumulator  для хранения отдельных изображений для видимого, цветного изображения и карты неровных оттенков. Эти изображения преобразуются в текстуры SpriteKit (при этом карта рельефа в оттенках серого преобразуется в карту нормалей RGB с  textureByGeneratingNormalMapWithSmoothness () ), которые затем сопоставляются с одним спрайтом SpriteKit.

Сенсорная обработка 

Поскольку создание текстур не происходит мгновенно, при каждом вызове метода  touchesMoved () вместо попытки окончательного рендеринга я сохраняю соответствующую информацию касания для каждого из  объединенных касаний  в массиве. Для этого я определяю псевдоним типа и объявляю этот массив на уровне класса:

typealias TouchDatum = (location: CGPoint, force: CGFloat, azimuthVector: CGVector, azimuthAngle: CGFloat)

    var touchData = [TouchDatum]()

А внутри  touchesMoved () используйте map для заполнения этого массива:

    guard let
        touch = touches.first,
        coalescedTouces = event?.coalescedTouchesForTouch(touch) where
        touch.type == UITouchType.Stylus else
    {
        return
    }

    touchData.appendContentsOf(coalescedTouces.map({(
        $0.locationInView(spriteKitView),
        $0.force / $0.maximumPossibleForce,
        $0.azimuthUnitVectorInView(spriteKitView),
        $0.azimuthAngleInView(spriteKitView)

        )}))

Именно в  touchesEnded ()  я создаю путь из этих сенсорных данных и помещаю его в очередь для отображения в фоновом режиме. Чтобы создать путь, у меня есть статическая функция (я сделал ее статической, чтобы убедиться, что у нее не может быть побочных эффектов) с именем  pathFromTouches,  которая возвращает  CGPath  из массива  TouchDatum :

    guard let path = ViewController.pathFromTouches(touchData, bristleAngles: bristleAngles) else
    {
        return

    }

BristleAngles  массив содержит  CGFloat S , которые определяют угол наклона каждой щетины. Я наполнил мое двадцатью ценностями, что, как ни удивительно, даст мне двадцать щетинок. pathFromTouches  зацикливается на каждой щетине, а затем на каждом элементе в  touchData . Он просто генерирует  UIBezierPath  из этих элементов, используя силу и угол, чтобы создать эффект кисти, который имитирует кисть реального мира:

    let bezierPath = UIBezierPath()

    for var i = 0; i < bristleAngles.count; i++
    {
        let x = firstTouchDatum.location.x + sin(firstBristleAngle) * forceToRadius(firstTouchDatum.force)
        let y = firstTouchDatum.location.y + cos(firstBristleAngle) * forceToRadius(firstTouchDatum.force)

        bezierPath.moveToPoint(CGPoint(x: x, y: y))

        for touchDatum in touchData
        {
            let bristleAngle = bristleAngles[i]

            let x = touchDatum.location.x + sin(bristleAngle + touchDatum.azimuthAngle)
                * forceToRadius(touchDatum.force)
                * touchDatum.azimuthVector.dy

            let y = touchDatum.location.y + cos(bristleAngle + touchDatum.azimuthAngle)
                * forceToRadius(touchDatum.force)
                * touchDatum.azimuthVector.dx

            bezierPath.addLineToPoint(CGPoint(x: x, y: y))
        }

    }

Когда этот путь возвращается в  touchesEnded () , он добавляется в массив путей, ожидающих рендеринга, и  вызывается drawPendingPath (),  который попытается его отрендерить:


    pendingPaths.append((path, origin, diffuseColor, temporaryLayer))
    drawPendingPath()

рисунок

Теперь у нас есть путь для жеста пользователя, пришло время преобразовать его в карты, которые требуются SpriteKit, и это делается в фоновом потоке, чтобы пользовательский интерфейс реагировал. drawPendingPath ()  выбирает первый элемент из  массива pendingPaths :

    guard pendingPaths.count > 0 else
    {
        return
    }


    let pendingPath = pendingPaths.removeFirst()

… а затем на заднем плане использует другую статическую функцию  textureFromPath () для создания текстур SpriteKit по этому пути. Поскольку техника компоновки и накопители изображений различаются для карт рассеивания и нормалей, они должны быть переданы в  textureFromPath () , поэтому он имеет довольно длинную сигнатуру:

    static func textureFromPath(path: CGPathRef,
        origin: CGPoint,
        imageAccumulator: CIImageAccumulator,
        compositeFilter: CIFilter,
        color: CGColorRef,

        lineWidth: CGFloat) -> SKTexture

Но внутренности функции довольно просты: она использует  CGContext  для генерации  UIImage  из предоставленного пути:

   UIGraphicsBeginImageContext(size)

    let cgContext = UIGraphicsGetCurrentContext()

    CGContextSetLineWidth(cgContext, lineWidth)
    CGContextSetLineCap(cgContext, CGLineCap.Round)

    CGContextSetStrokeColorWithColor(cgContext, color)

    CGContextAddPath(cgContext, path)

    CGContextStrokePath(cgContext)

    let drawnImage = UIGraphicsGetImageFromCurrentImageContext()


    UIGraphicsEndImageContext()

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

    compositeFilter.setValue(CIImage(image: drawnImage),
        forKey: kCIInputImageKey)
    compositeFilter.setValue(imageAccumulator.image(),
        forKey: kCIInputBackgroundImageKey)

    imageAccumulator.setImage(compositeFilter.valueForKey(kCIOutputImageKey) as! CIImage)

    let filteredImageRef = ciContext.createCGImage(imageAccumulator.image(),

        fromRect: CGRect(origin: CGPointZero, size: size))

… и, наконец, создает и возвращает текстуру SpriteKit из составного изображения:

    return SKTexture(CGImage: filteredImageRef)

drawPendingPath ()  вызывает этот метод дважды, сначала для диффузной карты, а затем для карты нормалей:

    let diffuseMap = ViewController.textureFromPath(pendingPath.path,
        origin: pendingPath.origin,
        imageAccumulator: self.diffuseImageAccumulator,
        compositeFilter: self.diffuseCompositeFilter,
        color: pendingPath.color.CGColor,
        lineWidth: 2)

    let normalMap = ViewController.textureFromPath(pendingPath.path,
        origin: pendingPath.origin,
        imageAccumulator: self.normalImageAccumulator,
        compositeFilter: self.normalCompositeFilter,
        color: UIColor(white: 1, alpha: 0.1).CGColor, lineWidth: 2)

        .textureByGeneratingNormalMapWithSmoothness(0.75, contrast: 3)

… и устанавливает  текстуру  и  normalTexture  на фоновом узле SpriteKit:

    backgroundNode.texture = diffuseMap
    backgroundNode.normalTexture = normalMap

После этого  drawPendingPath ()  вызывает себя для отрисовки любых других путей жестов, которые могли быть добавлены к pendingPaths ()  во время этого процесса.

Вывод 

SpriteKit’s normal mapping offers a convenient way to create a pseudo-3D embossed drawing andtextureByGeneratingNormalMap() makes converting an easily generated bump map to a normal map super easy. By doing that work in a background thread, the user interface can be kept super responsive . Furthermore, utilising the data from Pencil allows the brush to mimic a real paint brush by following the angles and increasing the spread with pressure. 

As always, the code for this project is available in my GitHub repository here.