Skip to main content

Ink Geometry Pipeline & Rendering

The different rendering modes have already been introduced. This guide explains how to setup the pipeline and the rendering, step-by-step.

The visual result is dependent on the brush configuration, the pipeline configuration and the configuration of the platform rendering.

Raster Ink

This section describes the best practice for setting up the geometry and rendering pipeline for Raster Ink using a step-by-step approach.

Brush configuration

To setup the raster particle brush, two textures are needed:

Shape and fill texture

The shape and the fill texture:

The shape parameter specifies the shape of the particle. It accepts an image in which the non-transparent pixels define the particle shape.

The fill parameter specifies the fill pattern of the shape. It accepts an image from which the pattern is sampled.

For both textures, you can also pass a sequence of images in this parameter. In this case, the method interprets them as mipmaps.

...
fun pencil(context: Context): RasterBrush {
val opts = BitmapFactory.Options()
opts.inSampleSize = 1
opts.inScaled = false
// Texture for shape
val shapeTexture =
BitmapFactory.decodeResource(context.resources, R.drawable.essential_shape, opts)
// Texture for fill
val fillTexture =
BitmapFactory.decodeResource(context.resources, R.drawable.essential_fill_11, opts)

// Convert to PNG
val stream = ByteArrayOutputStream()
shapeTexture!!.compress(Bitmap.CompressFormat.PNG, 100, stream)
val shapeTextureByteArray = stream.toByteArray()

val stream2 = ByteArrayOutputStream()
fillTexture!!.compress(Bitmap.CompressFormat.PNG, 100, stream2)
val fillTextureByteArray = stream2.toByteArray()

// Create the raster brush
var brush = RasterBrush(
URIBuilder.getBrushURI("raster", "Pencil"), // name of the brush
0.15f, // spacing
0.15f, // scattering
RotationMode.RANDOM, // rotation mode
listOf(shapeTextureByteArray), // shape texture
listOf(), fillTextureByteArray, // fill texture
"", // fill texture URI
fillTexture.width.toFloat(), // width of texture
fillTexture.height.toFloat(), // height of texture
false, // randomized fill
BlendMode.MAX // mode of blending
)

shapeTexture.recycle()
fillTexture.recycle()

return brush
}

...

PathPoint calculators

With all the relevant brushes defined, we need to configure the PathPoint calculators which influence the way sensor data changes the width, intensity and the tilt of the ink strokes.

First, the layout needs to be configured. Depending on the input provider, the layout can be adapted. For instance for touch input you are not receiving tilt nor pressure information.

...

import com.wacom.ink.PathPointLayout

...

override fun getLayout(): PathPointLayout {
// Define different layouts for stylus and touch input
if (isStylus) {
return PathPointLayout(
PathPoint.Property.X,
PathPoint.Property.Y,
PathPoint.Property.SIZE,
PathPoint.Property.ALPHA,
PathPoint.Property.ROTATION,
PathPoint.Property.OFFSET_X
)
} else {
return PathPointLayout(
PathPoint.Property.X,
PathPoint.Property.Y,
PathPoint.Property.SIZE,
PathPoint.Property.ALPHA
)
}
}
...

As touch and stylus input can be optimized for the different kinds of input sensor data, you need to define calculators which receive the previous, current and next sensor data input and computes the appropriate PathPoint. For instance, touch data includes neither pressure information nor tilt orientation like a stylus. Thus, to achieve the same difference in alpha intensity or variation of ink stroke width, only speed information can be used. The function to compute width based on the current speed of the input data is offered by the engine.

Here, the min and max value define the range of the width, while min and max speed define how the width is changed within its range. The result is a normalized value between 0 and 1. Finally, with the remap function the normalized value can be modified, e.g., to reverse the behavior. So that, for instance a slow movement can increase the width of the stroke rather reduce it, while if the finger moves faster the stroke becomes thinner.

...

companion object {
val uri = URIBuilder.getToolURI("raster", "pencil")
// Minimum size of the pencil tip
val MIN_PENCIL_SIZE = 4f
// Maximum size of the pencil tip
val MAX_PENCIL_SIZE = 25f
// Minimum alpha values for the particles
val MIN_ALPHA = 0.1f
// Maximum alpha values for the particles
val MAX_ALPHA = 0.7f
// Unit for speed is px/second.
// NOTE: This needs to be optimized for different Pixel densities of different devices
val MAX_SPEED = 15000f
}

...

override val touchCalculator: Calculator = { previous, current, next ->
// Use the following to compute size based on speed:
var size = current.computeValueBasedOnSpeed(
previous,
next,
minValue = MIN_PENCIL_SIZE,
maxValue = MAX_PENCIL_SIZE,
minSpeed = 80f,
maxSpeed = MAX_SPEED,
// reverse behaviour
remap = { 1f - it}
)!!

var alpha = current.computeValueBasedOnSpeed(
previous,
next,
minValue = MIN_ALPHA,
maxValue = MAX_ALPHA,
minSpeed = 80f,
maxSpeed = MAX_SPEED,
// reverse behaviour
remap = { 1f - it}
)

if (alpha == null) {
alpha = previousAlpha
} else {
previousAlpha = alpha
}

PathPoint(current.x, current.y, size = size, alpha = alpha)
}

The configuration for stylus input offers more opportunities to configure a natural behavior of a real world writing tool. Here, pressure sensor data can be used to increase the width of tools or change the intensity of the color. Also, if a stylus is tilted it would naturally change the contact surface area. If you want to implement pencil hatching, water color effects, or fountain pens, you need to implement the effect here.

NOTE: Input sensor quality varies, thus, test if the desired behavior is achieved with different kinds of stylus input.

...

var previousAlpha = 0.2f

...
override val stylusCalculator: Calculator = { previous, current, next ->
// calculate the offset of the pencil tip due to tilted position
val cosAltitudeAngle = cos(current.altitudeAngle!!)
val sinAzimuthAngle = sin(current.azimuthAngle!!)
val cosAzimuthAngle = cos(current.azimuthAngle!!)
val x = sinAzimuthAngle * cosAltitudeAngle
val y = cosAltitudeAngle * cosAzimuthAngle
val offsetY = 5f * -x
val offsetX = 5f * -y
// compute the rotation
val rotation = current.computeNearestAzimuthAngle(previous)
// now, based on the tilt of the pencil the size of the brush size is increasing, as the
// pencil tip is covering a larger area
val size = max(MIN_PENCIL_SIZE,
min(MAX_PENCIL_SIZE, MIN_PENCIL_SIZE + 20f * abs(current.altitudeAngle!!)))
// Change the intensity of alpha value by pressure of speed, if available else use speed
var alpha = if (current.force == -1f) {
current.computeValueBasedOnSpeed(
previous,
next,
minValue = MIN_ALPHA,
maxValue = MAX_ALPHA,
minSpeed = 0f,
maxSpeed = 3500f,
// reverse behaviour
remap = { 1.0f - it}
)
} else {
current.computeValueBasedOnPressure(
minValue = MIN_ALPHA,
maxValue = MAX_ALPHA,
minPressure = 0.0f,
maxPressure = 1.0f,
remap = { v: Float -> v.toDouble().pow(1).toFloat() }
)
}

if (alpha == null) {
alpha = previousAlpha
} else {
previousAlpha = alpha
}
PathPoint(
current.x, current.y,
alpha = alpha, size = size, rotation = rotation,
offsetX = offsetX, offsetY = offsetY
)
}
...

Pipeline

The RasterInkBuilder defines the Raster Ink pipeline:

import com.wacom.ink.*
import com.wacom.ink.format.rendering.RasterBrush
import com.wacom.ink.pipeline.PathProducer
import com.wacom.ink.pipeline.SmoothingFilter
import com.wacom.ink.pipeline.SplineInterpolator
import com.wacom.ink.pipeline.SplineProducer
import com.wacom.ink.pipeline.base.ProcessorResult

...
class RasterInkBuilder() {

// The pipeline components:
lateinit var pathProducer: PathProducer
lateinit var smoother: SmoothingFilter
lateinit var splineProducer: SplineProducer
lateinit var splineInterpolator: SplineInterpolator
lateinit var pathSegment: PathSegment
lateinit var tool: RasterTool

...

fun updatePipeline(newTool: Tool) {
if (newTool !is RasterTool) {
throw IllegalArgumentException("Invalid tool")
} else {
tool = newTool
var layout = tool.getLayout()
// Path producer needs to know the layout
pathProducer = PathProducer(layout, tool.getCalculator())
smoother = SmoothingFilter(layout.size) // Dimension
splineProducer = SplineProducer(layout) // Layout
splineInterpolator = SplineInterpolator(
spacing = tool.brush.spacing, // Spacing between two successive sample.
splitCount = 1, // Determines the number of iterations for the
// discretization.
calculateDerivatives = true, // Calculate derivatives
interpolateByLength = true // Interpolate by length
)
pathSegment = PathSegment()

splineProducer.keepAllData = true
}
}
}
...

Raster View

Next, setup the raster view:

import com.wacom.ink.egl.EGLRenderingContext
import com.wacom.ink.format.tree.data.Stroke
import com.wacom.ink.format.tree.nodes.StrokeNode
import com.wacom.ink.rasterization.InkCanvas
import com.wacom.ink.rasterization.Layer
import com.wacom.ink.rasterization.StrokeRenderer
import com.wacom.ink.rendering.BlendMode
...

/**
* This is a surface for drawing raster inking.
* Extends from SurfaceView
*/
class RasterView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : SurfaceView(context, attrs, defStyleAttr) {

...

private var inkCanvas: InkCanvas? = null
private lateinit var viewLayer: Layer
private lateinit var strokesLayer: Layer
private lateinit var currentFrameLayer: Layer
private lateinit var strokeRenderer: StrokeRenderer

private var rasterInkBuilder = RasterInkBuilder() // The ink builder
var rasterTool: RasterTool = PencilTool(context) // The default tool

...

init {
rasterInkBuilder.updatePipeline(PencilTool(context))
setZOrderOnTop(true);
holder.setFormat(PixelFormat.TRANSPARENT);

// the drawing is going to be performer on background
// on a surface, so we initialize the surface
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceChanged(holder: SurfaceHolder?, format: Int, w: Int, h: Int) {
// here, once the surface is crated, we are going to initialize ink canvas

// firs we check that there is no other inkCanvas, in case there is we dispose it
if (inkCanvas?.isDisposed == false) {
releaseResources();
}

inkCanvas =
InkCanvas(holder, EGLRenderingContext.EGLConfiguration(8, 8, 8, 8, 8, 8))
viewLayer = inkCanvas!!.createViewLayer(w, h)
strokesLayer = inkCanvas!!.createLayer(w, h)
currentFrameLayer = inkCanvas!!.createLayer(w, h)

inkCanvas!!.clearLayer(currentFrameLayer)

strokeRenderer =
StrokeRenderer(inkCanvas, PencilTool(context).brush.toParticleBrush(), w, h)

drawStrokes(strokeNodeList)
renderView()

if (listener != null) {
listener.onSurfaceCreated()
}
}

override fun surfaceDestroyed(holder: SurfaceHolder?) {
releaseResources();
}

override fun surfaceCreated(p0: SurfaceHolder?) {
//we don't have to do anything here
}
})
}
}
...

Input Handling

Input events from the operating system are handled using the following:

// This function is going to be call when we touch the surface
fun surfaceTouch(event: MotionEvent) {
// Check the input method
if (event.resolveToolType() == InkInputType.PEN) {
if ((newTool) || (!isStylus)) {
newTool = false
isStylus = true
rasterInkBuilder.updateInputMethod(isStylus)
}
} else {
if ((newTool) || (isStylus)) {
newTool = false
isStylus = false
rasterInkBuilder.updateInputMethod(isStylus)
}
}

for (i in 0 until event.historySize) {
val pointerData = event.historicalToPointerData(i)
rasterInkBuilder.add(pointerData.phase, pointerData, null)
}

val pointerData = event.toPointerData()
rasterInkBuilder.add(pointerData.phase, pointerData, null)

val (added, predicted) = rasterInkBuilder.build()

if (pointerData.phase == Phase.BEGIN) {
// initialize the sensor data each time a new stroke begin
val pair = inkEnvironmentModel.createSensorData(event)
sensorData = pair.first
channelList = pair.second
}

for (channel in channelList) {
when (channel.type) {
InkSensorType.X -> sensorData.add(channel, pointerData.x)
InkSensorType.Y -> sensorData.add(channel, pointerData.y)
InkSensorType.TIMESTAMP -> sensorData.addTimestamp(
channel,
pointerData.timestamp
)
InkSensorType.PRESSURE -> sensorData.add(channel, pointerData.force!!)
InkSensorType.ALTITUDE -> sensorData.add(channel, pointerData.altitudeAngle!!)
InkSensorType.AZIMUTH -> sensorData.add(channel, pointerData.azimuthAngle!!)
}
}

if ((pointerData.phase == Phase.END) && (rasterInkBuilder.splineProducer.allData != null)) {
addStroke()
sensorDataList.add(sensorData)
}


if (added != null) {
drawStroke(event, added, predicted)
}

renderView()
}

Finally, draw the stroke:

fun drawStrokes(strokeList: MutableList<Pair<StrokeNode, RasterBrush>>) {
for (stroke in strokeList) {
drawStroke(stroke.first, stroke.second)
}
}

...

// Draw stroke
private fun drawStroke(
event: MotionEvent,
added: InterpolatedSpline,
predicted: InterpolatedSpline?
) {
strokeRenderer.drawPoints(added, defaults, event.action == MotionEvent.ACTION_UP)

if (predicted != null) {
strokeRenderer.drawPrelimPoints(predicted, defaults)
}

if (event.action != MotionEvent.ACTION_UP) {
inkCanvas!!.setTarget(currentFrameLayer, strokeRenderer.strokeUpdatedArea)
inkCanvas!!.clearColor()
inkCanvas!!.drawLayer(strokesLayer, BlendMode.SOURCE_OVER)
strokeRenderer.blendStrokeUpdatedArea(currentFrameLayer, rasterTool.getBlendMode())
} else {
strokeRenderer.blendStroke(strokesLayer, rasterTool.getBlendMode())
inkCanvas!!.setTarget(currentFrameLayer)
inkCanvas!!.clearColor()
inkCanvas!!.drawLayer(strokesLayer, BlendMode.SOURCE_OVER)
}
}
...

Vector Ink

This section describes the best practice for setting up the geometry and rendering pipeline for Vector Ink using a step-by-step approach.

Brush configuration

Vector Ink brush configuration is defined by an arbitrary convex polygon:

Polygon

import com.wacom.ink.format.enums.RotationMode
import com.wacom.ink.format.rendering.RasterBrush
import com.wacom.ink.rendering.BlendMode
import com.wacom.ink.rendering.BrushPrototype
import com.wacom.ink.rendering.VectorBrush
...
class BrushPalette {
...

fun circle(): VectorBrush {
return VectorBrush(
"circle", // inSampleSize
arrayOf(
BrushPrototype(FloatArrayList(ShapeFactory.createCircle(4)), 0f),
BrushPrototype(FloatArrayList(ShapeFactory.createCircle(8)), 2f),
BrushPrototype(FloatArrayList(ShapeFactory.createCircle(16)), 6f),
BrushPrototype(FloatArrayList(ShapeFactory.createCircle(32)), 18f)
)
) // Brush prototypes for different level of details
}

...

class ShapeFactory {

companion object {

/**
* Creates polygon with circle shape
*
* @param {int} [n=20] number of polygon segments
* @param {int} [r=1] radius (0 < r <= 1)
* @param {Point} [c={x: 0, y: 0}] center
* @return {Polygon} circle shape points
*/
@JvmStatic
fun createCircle(n: Int = 20, r: Float = 1f,
c: PointF = PointF(0f, 0f)): FloatArray {
return createEllipse(n, r, r, c);
}

...

}
}

}

PathPoint calculators

The PathPoint calculators are defined in a similar way as for the raster tools. Again, we need to define the layout of the PathPoints:

override fun getLayout(): PathPointLayout {
if (isStylus) {
/**
* The currently layout will use:
* - Coordinate values (X and Y)
* - Size - the size of the brush at any point of the stroke
* - For tilt effect the rotation, scale y and offset y
*/
return PathPointLayout(
PathPoint.Property.X,
PathPoint.Property.Y,
PathPoint.Property.SIZE,
PathPoint.Property.ROTATION,
PathPoint.Property.SCALE_Y,
PathPoint.Property.OFFSET_Y
)
} else {
/**
* The currently layout will use:
* - Coordinate values (X and Y)
* - Size - the size of the brush at any point of the stroke
*/
return PathPointLayout(
PathPoint.Property.X,
PathPoint.Property.Y,
PathPoint.Property.SIZE
)
}
}

Pipeline

The VectorInkBuilder is a class that handles the path building, using the pipeline. It processes the input to the whole pipeline in order to produce polygons from the input. After that, the polygons are converted to native structures.

import com.wacom.ink.PathSegment
import com.wacom.ink.Phase
import com.wacom.ink.PointerData
import com.wacom.ink.Spline
import com.wacom.ink.pipeline.*
import com.wacom.ink.pipeline.base.ProcessorResult
...
class VectorInkBuilder() {

// The pipeline components:
internal lateinit var pathProducer: PathProducer
internal lateinit var smoother: SmoothingFilter
internal lateinit var splineProducer: SplineProducer
internal lateinit var splineInterpolator: SplineInterpolator
internal lateinit var brushApplier: BrushApplier
internal lateinit var convexHullChainProducer: ConvexHullChainProducer
internal lateinit var polygonMerger: PolygonMerger
internal lateinit var polygonSimplifier: PolygonSimplifier
internal lateinit var bezierPathBuilder: PolygonToBezierPathProducer
internal lateinit var pathSegment: PathSegment

lateinit var tool: VectorTool

fun updatePipeline(newTool: Tool) {
if (newTool !is VectorTool) {
throw IllegalArgumentException("Invalid tool")
} else {
tool = newTool
var layout = tool.getLayout()
pathProducer = PathProducer(layout, tool.getCalculator())
smoother = SmoothingFilter(
layout.count(), // Dimension
30) // Moving average window size
splineProducer = SplineProducer(layout) // Layout
splineInterpolator = SplineInterpolator(
tool.brush.spacing, // Spacing between two successive sample.
6, // Determines the number of iterations for the discretization.
false, // Calculate derivatives
false) // Interpolate by length
brushApplier = BrushApplier(
tool.brush, // Vector brush
true) // Keep all data
convexHullChainProducer = ConvexHullChainProducer()
polygonMerger = PolygonMerger()
polygonSimplifier = PolygonSimplifier(0.1f) // Epsilon
bezierPathBuilder = PolygonToBezierPathProducer()
pathSegment = PathSegment()
splineProducer.keepAllData = true
}
}
}
...

Vector View

Next, the vector view needs to be setup. This class is a simple view that is used to draw vector inking. Vector inking is a Path, so it is drawn by extending the platform rendering method and drawing the path there.

import com.wacom.ink.PathPoint
import com.wacom.ink.Phase
import com.wacom.ink.PointerData
import com.wacom.ink.Spline
import com.wacom.ink.format.InkSensorType
import com.wacom.ink.format.enums.InkInputType
import com.wacom.ink.manipulation.ManipulationMode
import com.wacom.ink.manipulation.SpatialModel
import com.wacom.ink.manipulation.callbacks.ErasingCallback
import com.wacom.ink.manipulation.callbacks.SelectingCallback
import com.wacom.ink.model.InkStroke
import com.wacom.ink.model.StrokeAttributes
...

class VectorView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

// Relevant for serialization
var inkSensorTypes = listOf(InkSensorType.X, InkSensorType.Y, InkSensorType.TIMESTAMP)
lateinit var inkEnvironmentModel: InkEnvironmentModel

var drawingTool: VectorTool = PenTool() //default tool
var inkBuilder = VectorInkBuilder()

// This will contain the current drawing path before it is finished
private var currentPath = Path()
var paint: Paint = Paint()

// A list of existing strokes
private var strokes = mutableMapOf<String, WillStroke>()

...

private fun addStroke(event: MotionEvent) {
val brush = inkBuilder.tool.brush
val stroke = WillStroke(inkBuilder.splineProducer.allData!!.copy(), brush)
stroke.sensorData = inkEnvironmentModel.createSensorData(event, inkSensorTypes)

stroke.strokeAttributes = object : StrokeAttributes {
override var size: Float = 10f
override var rotation: Float = 0.0f

override var scaleX: Float = 1.0f
override var scaleY: Float = 1.0f
override var scaleZ: Float = 1.0f

override var offsetX: Float = 0.0f
override var offsetY: Float = 0.0f
override var offsetZ: Float = 0.0f

override var red: Float = Color.red(paint.color) / 255f
override var green: Float = Color.green(paint.color) / 255f
override var blue: Float = Color.blue(paint.color) / 255f
override var alpha: Float = paint.alpha.toFloat() / 255
}

spatialModel.add(stroke)
stroke.path = currentPath
strokes[stroke.id] = stroke
}
}

Input Handling

Input events from the operating system are handled in the same way as for the raster input (please see Raster Input Handling)

Finally, draw the stroke:

/**
* In order to make the strokes look nice and smooth, it is recommended to enable:
* - Antialiasing
* - Dither
*/
val paint = Paint().also {
it.isAntiAlias = true
it.isDither = true
}
...
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)

selectionBox?.drawSelectionBox(canvas)

for ((id, stroke) in strokes) {

if (id in selectedStrokes) {
canvas.drawPath(stroke.path, paintSelected)
} else {
// Selection color
val color = Color.argb(
(stroke.strokeAttributes.alpha * 255).toInt(),
(stroke.strokeAttributes.red * 255).toInt(),
(stroke.strokeAttributes.green * 255).toInt(),
(stroke.strokeAttributes.blue * 255).toInt()
)
paint.color = color
canvas.drawPath(stroke.path, paint)
}
}

// draw the current path,
// because we have not finished the stroke we has not saved it yet
if (drawingTool.uri() == EraserVectorTool.uri) {
paint.color = Color.WHITE
} else if (drawingTool.uri() == EraserWholeStrokeTool.uri) {
paint.color = Color.RED
} else {
paint.color = currentColor
}
canvas.drawPath(currentPath, paint)
}