Tuesday, January 28, 2014

Monkey Write: Brush strokes

Back when I started Monkey Write at the AT&T hackathon, I was already varying the pen stroke width by the pressure of the touch events. But the pressure ranges change a lot on depending on the device. I was not able to get consistent stroke rendering with all those pressure ranges, so I only vary the stroke width by pressure if I know it is from the active stylus reported via an HTC PenEvent.

I was really excited when I saw the beautiful Markers app. Its pressure-sensitive strokes work on many devices, plus it is open sourced with Apache License 2.0, so all I have to do is to integrate that into Monkey Write. Well, all I have to do is to find time to integrate that into Monkey Write, which I finally did!

PressureCooker

When I first looked into the Markers code I was very amused by the PressureCooker class. What a name! It calibrates the pressure coming from a series of touch events, which is what I need. I lifted that class and put it into Monkey Write, but that alone did not make beautiful strokes. This was where I stopped the first time I looked into Markers, for the rest involves a more complicated co-ordination among the Slate, TiledBitmapCanvas and SpotFilter classes.

Markers architecture

I finally set aside some time to understand how Markers work.

  1. Convert each incoming touch event into a Spot.
  2. Add the Spot to a SpotFilter.
  3. The SpotFilter takes a Plotter in its constructor. After filtering it calls the plot() function of the Plotter.
  4. The Plotter renders the Spot on screen. In Markers this is handled by the inner class MarkersPlotter in Slate, which draws on the TiledBitmapCanvas.

Where is the PressureCooker, you ask? It is used inside the plot() function. Instead of using Spot.pressure directly, it is calibrated by the PressureCooker.

Monkey Write modifications

Here is what I did to incorporate Markers stroke rendering into Monkey Write:

  1. Replace my own class with Spot to store touch events.
  2. Add a TiledBitmapCanvas to the character writing custom view (called SketchPad).
  3. Make SketchPad implement Plotter, which takes a Spot and renders to the TiledBitmapCanvas.
  4. Add a SpotFilter to SketchPad. As touch events are captured by onTouch, pass the Spots to the SpotFilter.
  5. In SketchPad.onDraw(), call TiledBitmapCanvas.drawTo() after rendering the base character to show the pen strokes.
  6. After the user writes a stroke, grade it. If it was not a good stroke, call TiledBitmapCanvas.step(-1) to remove that stroke.

Pen styles

After I set up the basic pressure cooking and spot filtering I started to experiment with different pen styles. This essentially means changing the pen tip, or how to render each touch point aka Spot.

Basic style

The basic style renders the pen tip as a solid circle. Fairly straight forward:

c.drawCircle(x, y, r, mPaint);

Brush style

Markers has an airbrush style, which draws a bitmap as the pen tip. I looked at the bitmap and thought, hey, that's just a RadialGradient! I decided to generate that programmatically so I can vary the alpha value on the fly:

private Shader createBrushShader(
    float width, float alphaStart, float alphaEnd) {
  final float center = width / 2;
  final float radius = Math.max(1, width / 2);
  final int red = Color.red(mPaint.getColor());
  final int green = Color.green(mPaint.getColor());
  final int blue = Color.blue(mPaint.getColor());
  return new RadialGradient(
      center, center, radius,
      Color.argb(alphaStart, red, green, blue),
      Color.argb(alphaEnd, red, green, blue),
      Shader.TileMode.CLAMP);
}
mPaint.setShader(mBrushShader);
c.drawCircle(x, y, r, mPaint);
I update mBrushShader with createBrushShader() whenever the user changes the width or alpha from the UI.

Pencil style

Once I started playing with Shaders I could not stop. I decided to mimic a pencil stroke on a rough paper by plotting little dots at the pen tip.

private Shader createPencilShader(float alpha) {
  final int size = 32;
  int color = Color.rgb(
      Color.red(mPaint.getColor()),
      Color.green(mPaint.getColor()),
      Color.blue(mPaint.getColor()));
  float threshold = alpha / 255f;

  int[] colors = new int[size * size];
  for (int i = 0; i < colors.length; ++i) {
    colors[i] = (Math.random() >= threshold) ? 0 : color;
  }

  Bitmap bitmap = Bitmap.createBitmap(
      colors, size, size, Bitmap.Config.ARGB_8888);
  return new BitmapShader(
      bitmap, TileMode.REPEAT, TileMode.REPEAT);
}
mPaint.setShader(mPencilShader);
c.drawCircle(x, y, r, mPaint);

I use a BitmapShader to draw the little dots. It is a tiling bitmap, each pixel is either transparent or the chosen color. The lower the alpha value, the more transparent pixels.

Constant width

Finally I want to provide an option for users who don't want variable width. This is achieved by ignoring the pressure from the touch event and supplying a constant value to the pen tip renderer.

Mix and match

With that you can have a lot of fun making different pen styles.

Here is a very transparent blue stroke. Looks like water, doesn't it?

You can pick different styles for different strokes:

Hopefully these beautiful strokes will make it even more fun to practice writing Chinese. Download Monkey Write and try them out!

No comments:

Post a Comment

Inline coding questions will not be answsered. Instead, ask on StackOverflow and put the link in the comment.