Исходя из 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.