Generated banner for Ink Custom Brush Builder
Generated logo for Ink Custom Brush Builder

Ink Custom Brush Builder

Android (Google)OfficialCreated by Google LLCDistributed by Android (Google)

  • Skill
  • Tool integration
  • Software development
  • Apache-2.0
  • No issues found

From the author

Creates custom BrushFamily definitions for an Android app using Jetpack Ink (androidx.ink 1.1.0-alpha03 and higher). Use this skill when configuring custom BrushTip shapes, adding pressure, tilt, speed, or noise sensitivity using BrushBehavior node graphs, creating multi-coat layered brushes, applying tiling or particle stamping textures, configuring SlidingWindowModel input smoothing, or serializing brushes. Delivers complete Kotlin BrushFamily, BrushCoat, BrushTip, BrushPaint, and BrushBehavior implementations. Don't use for View-based (XML) inking, Compose drawing surface setup, or stock brush selection. For Compose drawing surface setup, InProgressStrokes wet and dry rendering, eraser, or persistence, suggest the ink-app-builder skill.
— the description by Google LLC

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

The ink-custom-brush-builder skill provides a structured approach to creating programmatic custom brushes for Android applications utilizing the Jetpack Ink library (version 1.1.0-alpha03 and higher). It enables developers to define complex brush behaviors, multi-coat layers, and custom textures using Kotlin constructors rather than manual protocol buffer builders. The skill covers the entire brush hierarchy, including BrushFamily, BrushCoat, BrushTip, BrushPaint, and BrushBehavior node graphs, ensuring developers can implement responsive, high-fidelity drawing tools with features like pressure, tilt, and speed sensitivity.

What it does

  • Generates complete Kotlin implementations for BrushFamily, BrushCoat, BrushTip, BrushPaint, and BrushBehavior.
  • Configures dynamic stylus and touch responses using BrushBehavior node graphs.
  • Applies tiling or particle stamping textures to brushes.
  • Sets up input smoothing via SlidingWindowModel.
  • Provides guidance on encoding and decoding custom BrushFamily definitions.
  • Ensures correct dependency versions for Jetpack Ink libraries.

When to use it

  • When you need to create custom brush styles beyond the provided stock brushes.
  • When implementing brushes that respond dynamically to stylus pressure, tilt, or speed.
  • When building multi-coat layered brushes with specific texture or color functions.
  • When you need to serialize and deserialize custom brush definitions.
  • When configuring specific input smoothing for drawing strokes.

Advantages

  • Uses public Kotlin constructors for type-safe brush definition.
  • Enables complex, organic stroke behaviors through procedural noise and response nodes.
  • Supports advanced texture mapping without manual bitmap shader implementation.
  • Provides a consistent, documented workflow for brush structure and behavior.
  • Avoids common antipatterns like manual Bézier smoothing or raw proto builders.

Usage examples

“How do I create a custom pressure-sensitive pen brush?”
The agent will provide a Kotlin code snippet using BrushFamily, BrushCoat, and a BrushBehavior node graph that maps NORMALIZED_PRESSURE to SIZE_MULTIPLIER.
“Help me add a grain texture to my custom brush.”
The agent will guide you through implementing a BrushPaint.TilingTexture, identifying the necessary drawable resource, and registering it in the TextureBitmapStore.

Install

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/android/ink-custom-brush-builder/download
  2. Unzip it into the cross-client skills folder of your project:

    mkdir -p .agents/skills && unzip ink-custom-brush-builder.zip -d .agents/skills

Documentation by Google LLC

Custom brushes in the Android Ink API are built around the BrushFamily hierarchy:

BrushFamily
├── InputModel (BrushFamily.InputModel.SlidingWindowModel or PASSTHROUGH_MODEL)
└── BrushCoat[] (one or more rendering layers)
    ├── BrushTip (shape: scaleX, scaleY, cornerRounding, rotationDegrees, slantDegrees, pinch)
    │   └── BrushBehavior[] (dynamic response to stylus or touch input)
    │       └── Tree: TargetNode(input = [Operators](input = SourceNode))
    └── BrushPaint (visual appearance)
        ├── SelfOverlap (ANY, ACCUMULATE, or DISCARD)
        ├── TextureLayer[] (BrushPaint.TilingTexture or BrushPaint.StampingTexture)
        └── BrushPaint.ColorFunction[] (OpacityMultiplier or ReplaceColor)

Create a Brush by combining a custom BrushFamily with a Compose Color, size, and epsilon:

val brush = Brush.createWithComposeColor(
    family = myCustomFamily,
    color = Color.Black,
    size = 10f,
    epsilon = 0.1f,
)

<br />

Version and experimental API requirements :

  • Requires androidx.ink 1.1.0-alpha03 and higher : In 1.0.0 (Stable) and 1.1.0-alpha01-alpha02, BrushFamily(...), BrushCoat, BrushTip, BrushPaint, BrushBehavior, and androidx.ink.brush.behavior.* nodes were @RestrictTo(LIBRARY_GROUP) (in 1.0.0 stable, custom brushes can only be loaded from binary proto streams using BrushFamily.decode). Starting in 1.1.0-alpha03 and higher , programmatic custom brush constructors graduated to public API (BrushPaint.TilingTexture, BrushPaint.StampingTexture, BrushPaint.ColorFunction, BrushFamily.InputModel.SlidingWindowModel, and all behavior nodes).

  • No @OptIn required in 1.1.0-alpha03 and higher : All public custom brush classes and ink-storage BrushFamily serialization APIs (BrushFamily.encode and decode, AndroidBrushFamilySerialization, BrushFamilyDecodeCallback) are unannotated in 1.1.0-alpha03 and higher (ExperimentalInkCustomBrushApi is @RestrictTo(LIBRARY_GROUP) in 1.1.0-alpha05 and higher, and is only required on BrushFamily serialization in 1.0.0 stable).

Workflow and task routing

Follow these conditional instructions based on the requested custom brush task:

  • Step 1 (Dependencies) : When setting up an Android project for programmatic custom brushes, ask user permission before editing build files and verify that androidx.ink:ink-brush, androidx.ink:ink-brush-compose, androidx.ink:ink-storage, and androidx.ink:ink-nativeloader use version 1.1.0-alpha03 or higher in gradle/libs.versions.toml and app/build.gradle.kts.

  • Step 2 (Hierarchy, tip shape, paint, and input smoothing) : When defining BrushFamily and multi-coat BrushCoat layers, configuring BrushTip geometry (scaleX, scaleY, cornerRounding, rotationDegrees, slantDegrees, pinch), setting BrushPaint properties (SelfOverlap, ColorFunction), or smoothing stroke input with BrushFamily.InputModel.SlidingWindowModel, you MUST follow brush-structure.md. For baseline Brush creation or 1.0.0 resource loading, review Brush APIs.

  • Step 3 (Dynamic stylus and touch behaviors) : When making a brush respond dynamically to stylus pressure, tilt, speed, direction, or procedural noise using BrushBehavior node trees (SourceNode, DampingNode, NoiseNode, ResponseNode, TargetNode, PolarTargetNode), you MUST follow brush-behaviors.md.

  • Step 4 (Textures and serialization) : When adding BrushPaint.TilingTexture or BrushPaint.StampingTexture layers, implementing TextureBitmapStore, or encoding and decoding custom BrushFamily definitions (BrushFamily.encode and decode, AndroidBrushFamilySerialization, BrushFamilyDecodeCallback), you MUST follow textures-and-serialization.md. Inform the user which texture image files (such as app/src/main/res/drawable-nodpi/pencil_grain.png) to add to their project and register in TextureBitmapStore.

  • Step 5 (Complete brush recipes) : When building a complete pressure pen, calligraphy nib, shading pencil, watercolor wash, or multi-coat shadow brush from scratch, start from the copy-ready implementations in examples.md.

  • Compose drawing surface, eraser, or persistence : For Compose drawing surface setup, InProgressStrokes wet and dry rendering, geometry-based erasing, or Room stroke persistence, suggest the ink-app-builder skill.

Antipatterns

  • NEVER use raw ink.proto.* builders to construct brushes---always use the public Kotlin constructors (BrushFamily, BrushCoat, BrushTip, BrushPaint, BrushBehavior from androidx.ink.brush.*).

  • NEVER manually scale stroke width in a touch listener for pressure or tilt sensitivity---use BrushBehavior mapping SourceNode.Source.NORMALIZED_PRESSURE or TILT_IN_RADIANS to TargetNode.Target.SIZE_MULTIPLIER or WIDTH_MULTIPLIER.

  • NEVER use manual Canvas bitmap stamping or BitmapShader for textured brushes---use BrushPaint.TilingTexture or BrushPaint.StampingTexture with a TextureBitmapStore.

  • NEVER use BlendMode.SRC_OVER on the final (or only) TextureLayer ---use BlendMode.MODULATE, DST_IN, DST_OUT, SRC_ATOP, or SRC_IN so edge anti-aliasing and OPACITY_MULTIPLIER behaviors aren't overridden.

  • NEVER serialize BrushFamily as JSON, Gson, Moshi, or Parcelable ---always use BrushFamily.encode and BrushFamily.decode or AndroidBrushFamilySerialization from androidx.ink.storage.

  • NEVER implement manual Bézier (quadTo, cubicTo) smoothing ---configure BrushFamily.InputModel.SlidingWindowModel for input smoothing and DampingNode for behavior smoothing.

  • NEVER use Random.nextFloat in draw loops for organic stroke wobble---attach a NoiseNode varied over stroke distance in BrushBehavior.

Files

references/brush-behaviors.md

Reference for building dynamic BrushBehavior node graphs that respond to stylus pressure, tilt, speed, direction, and procedural noise (androidx.ink 1.1.0-alpha03 and higher).

Overview and package imports

A BrushBehavior is a tree-based node graph attached to BrushTip.behaviors that evaluates per input point: Source or ValueNode to [Operators] to TerminalNode (TargetNode or PolarTargetNode).

  • Import BrushBehavior and InputToolType from androidx.ink.brush.* (never androidx.ink.brush.behavior.BrushBehavior).

  • Import all node classes and enums (SourceNode, ConstantNode, NoiseNode, ToolTypeFilterNode, DampingNode, ResponseNode, IntegralNode, BinaryOpNode, InterpolationNode, TargetNode, PolarTargetNode, OutOfRange, ProgressDomain, EasingFunction) from androidx.ink.brush.behavior.*.

All 11 behavior node types

1. SourceNode (input signals)

Reads a raw stylus or touch property and normalizes it across [sourceValueRangeStart, sourceValueRangeEnd] using sourceOutOfRangeBehavior (OutOfRange.CLAMP, OutOfRange.REPEAT for cyclic angles, or OutOfRange.MIRROR):

val pressureSource = SourceNode(
    source = SourceNode.Source.NORMALIZED_PRESSURE,
    sourceValueRangeStart = 0f,
    sourceValueRangeEnd = 1f,
    sourceOutOfRangeBehavior = OutOfRange.CLAMP,
)

<br />

Available SourceNode.Source constants
CategorySourceNode.Source constantsTypical range
Pressure and tiltNORMALIZED_PRESSURE ([0, 1]), TILT_IN_RADIANS ([0, π/2]), TILT_X_IN_RADIANS ([-π/2, π/2]), TILT_Y_IN_RADIANS ([-π/2, π/2]), ORIENTATION_IN_RADIANS ([0, 2π)), ORIENTATION_ABOUT_ZERO_IN_RADIANS ((-π, π])[0, 1] or radians
Speed and velocitySPEED_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, VELOCITY_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, VELOCITY_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND, SPEED_IN_CENTIMETERS_PER_SECOND, VELOCITY_X_IN_CENTIMETERS_PER_SECOND, VELOCITY_Y_IN_CENTIMETERS_PER_SECOND[0, ∞) or (-∞, ∞)
DirectionDIRECTION_IN_RADIANS ([0, 2π)), DIRECTION_ABOUT_ZERO_IN_RADIANS ((-π, π]), NORMALIZED_DIRECTION_X ([-1, 1]), NORMALIZED_DIRECTION_Y ([-1, 1])Use OutOfRange.REPEAT for angles
AccelerationACCELERATION_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, ACCELERATION_X_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, ACCELERATION_Y_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, ACCELERATION_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, ACCELERATION_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE_PER_SECOND_SQUARED, ACCELERATION_IN_CENTIMETERS_PER_SECOND_SQUARED, ACCELERATION_X_IN_CENTIMETERS_PER_SECOND_SQUARED, ACCELERATION_Y_IN_CENTIMETERS_PER_SECOND_SQUARED, ACCELERATION_FORWARD_IN_CENTIMETERS_PER_SECOND_SQUARED, ACCELERATION_LATERAL_IN_CENTIMETERS_PER_SECOND_SQUARED[0, ∞) or (-∞, ∞)
Distance and timeDISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE, DISTANCE_TRAVELED_IN_CENTIMETERS, DISTANCE_REMAINING_IN_MULTIPLES_OF_BRUSH_SIZE, DISTANCE_REMAINING_AS_FRACTION_OF_STROKE_LENGTH ([0, 1]), PREDICTED_DISTANCE_TRAVELED_IN_MULTIPLES_OF_BRUSH_SIZE, PREDICTED_DISTANCE_TRAVELED_IN_CENTIMETERS, TIME_OF_INPUT_IN_SECONDS, TIME_FROM_INPUT_TO_STROKE_END_IN_SECONDS, PREDICTED_TIME_ELAPSED_IN_SECONDS, TIME_SINCE_INPUT_IN_SECONDS, TIME_SINCE_STROKE_END_IN_SECONDS[0, ∞)
2-9. Value and operator nodes

Use ConstantNode, NoiseNode, ToolTypeFilterNode, DampingNode, ResponseNode, IntegralNode, BinaryOpNode, and InterpolationNode to generate, filter, smooth, or combine signals across a ProgressDomain (TIME_IN_SECONDS, DISTANCE_IN_CENTIMETERS, or DISTANCE_IN_MULTIPLES_OF_BRUSH_SIZE):

// 2. ConstantNode: fixed scalar output
val halfConstant = ConstantNode(0.5f)

// 3. NoiseNode: procedural noise over stroke distance or time
val distanceNoise = NoiseNode(
    seed = 42,
    varyOver = ProgressDomain.DISTANCE_IN_CENTIMETERS,
    basePeriod = 0.3f,
)

// 4. ToolTypeFilterNode: pass signal only for selected InputToolType set
val stylusOnlyPressure = ToolTypeFilterNode(
    enabledToolTypes = setOf(InputToolType.STYLUS),
    input = pressureSource,
)

// 5. DampingNode: low-pass smoothing over time or distance
val dampedPressure = DampingNode(
    dampOver = ProgressDomain.TIME_IN_SECONDS,
    strength = 0.15f,
    input = pressureSource,
)

// 6. ResponseNode: non-linear easing (Predefined, CubicBezier, Linear, Steps)
val easedPressure = ResponseNode(
    responseCurve = EasingFunction.Predefined.EASE_IN_OUT,
    input = dampedPressure,
)

// 7. IntegralNode: accumulates input over time or distance
val accumulatedPressure = IntegralNode(
    integrateOver = ProgressDomain.TIME_IN_SECONDS,
    integralValueRangeStart = 0f,
    integralValueRangeEnd = 1f,
    integralOutOfRangeBehavior = OutOfRange.CLAMP,
    input = pressureSource,
)

// 8. BinaryOpNode: PRODUCT, SUM, MIN, MAX, AND_THEN, OR_ELSE, XOR_ELSE
val scaledPressure = BinaryOpNode(
    operation = BinaryOpNode.BinaryOp.PRODUCT,
    firstInput = easedPressure,
    secondInput = halfConstant,
)

// 9. InterpolationNode: LERP or INVERSE_LERP between startInput and endInput
val lerpedNode = InterpolationNode(
    interpolation = InterpolationNode.Interpolation.LERP,
    paramInput = easedPressure,
    startInput = ConstantNode(0.2f),
    endInput = ConstantNode(1.0f),
)

<br />

10. TargetNode (scalar terminal node)

Maps a normalized [0, 1] ValueNode input onto [targetModifierRangeStart, targetModifierRangeEnd] (endpoints must be finite and distinct: rangeStart != rangeEnd):

TargetNode.Target constantEffect on stroke
SIZE_MULTIPLIER, WIDTH_MULTIPLIER, HEIGHT_MULTIPLIERScales overall tip size, horizontal width, or vertical height
SLANT_OFFSET_IN_RADIANS, ROTATION_OFFSET_IN_RADIANSAdds dynamic shear or rotation offset in radians
CORNER_ROUNDING_OFFSET, PINCH_OFFSETAdds offset to tip cornerRounding or pinch
OPACITY_MULTIPLIER, PAINT_ANIMATION_PROGRESS_OFFSETMultiplies per-vertex opacity (requires SelfOverlap.ANY or ACCUMULATE) or offsets animated texture progress
HUE_OFFSET_IN_RADIANS, CHROMA_MULTIPLIER, LIGHTNESS_OFFSETShifts color hue, chroma, or lightness
POSITION_OFFSET_X_IN_MULTIPLES_OF_BRUSH_SIZE, POSITION_OFFSET_Y_IN_MULTIPLES_OF_BRUSH_SIZE, POSITION_OFFSET_FORWARD_IN_MULTIPLES_OF_BRUSH_SIZE, POSITION_OFFSET_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZEOffsets tip position in multiples of brush size
11. PolarTargetNode (2D vector terminal node)

Applies a polar vector (angleInput + magnitudeInput) to 2D position offset targets (POSITION_OFFSET_ABSOLUTE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE or POSITION_OFFSET_RELATIVE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE):

val polarScatterBehavior = BrushBehavior(
    terminalNode = PolarTargetNode(
        target = PolarTargetNode.PolarTarget.POSITION_OFFSET_ABSOLUTE_IN_RADIANS_AND_MULTIPLES_OF_BRUSH_SIZE,
        angleRangeStart = 0f,
        angleRangeEnd = (2 * Math.PI).toFloat(),
        angleInput = NoiseNode(
            seed = 101,
            varyOver = ProgressDomain.DISTANCE_IN_CENTIMETERS,
            basePeriod = 0.2f,
        ),
        magnitudeRangeStart = 0f,
        magnitudeRangeEnd = 0.5f,
        magnitudeInput = SourceNode(
            source = SourceNode.Source.NORMALIZED_PRESSURE,
            sourceValueRangeStart = 0f,
            sourceValueRangeEnd = 1f,
        ),
    ),
)

<br />

Reusable behavior patterns and examples

Build reusable helper functions for direct (Source to Target), smoothed (Source to Damping to Target), and procedural noise (Noise to Target) behaviors and attach them to BrushTip(behaviors = listOf(...)):

fun smoothedBehavior(
    source: SourceNode.Source,
    sourceStart: Float,
    sourceEnd: Float,
    target: TargetNode.Target,
    targetStart: Float,
    targetEnd: Float,
    dampingSeconds: Float,
    outOfRange: OutOfRange = OutOfRange.CLAMP,
): BrushBehavior = BrushBehavior(
    terminalNode = TargetNode(
        target = target,
        targetModifierRangeStart = targetStart,
        targetModifierRangeEnd = targetEnd,
        input = DampingNode(
            dampOver = ProgressDomain.TIME_IN_SECONDS,
            strength = dampingSeconds,
            input = SourceNode(
                source = source,
                sourceValueRangeStart = sourceStart,
                sourceValueRangeEnd = sourceEnd,
                sourceOutOfRangeBehavior = outOfRange,
            ),
        ),
    ),
)

// 1. Pressure -> Size (50% to 150% with 150ms damping)
val pressureToSize = smoothedBehavior(
    source = SourceNode.Source.NORMALIZED_PRESSURE,
    sourceStart = 0f,
    sourceEnd = 1f,
    target = TargetNode.Target.SIZE_MULTIPLIER,
    targetStart = 0.5f,
    targetEnd = 1.5f,
    dampingSeconds = 0.15f,
)

// 2. Stylus Tilt -> Width (1x to 2.5x as tilt increases to pi/2)
val tiltToWidth = smoothedBehavior(
    source = SourceNode.Source.TILT_IN_RADIANS,
    sourceStart = 0f,
    sourceEnd = (Math.PI / 2).toFloat(),
    target = TargetNode.Target.WIDTH_MULTIPLIER,
    targetStart = 1.0f,
    targetEnd = 2.5f,
    dampingSeconds = 0.1f,
)

// 3. Organic distance-based wobble using NoiseNode -> lateral position offset
fun createWobblyBrush(): BrushFamily {
    val wobbleBehavior = BrushBehavior(
        terminalNode = TargetNode(
            target = TargetNode.Target.POSITION_OFFSET_LATERAL_IN_MULTIPLES_OF_BRUSH_SIZE,
            targetModifierRangeStart = -0.25f,
            targetModifierRangeEnd = 0.25f,
            input = NoiseNode(
                seed = 12345,
                varyOver = ProgressDomain.DISTANCE_IN_CENTIMETERS,
                basePeriod = 0.4f,
            ),
        ),
    )
    return BrushFamily(
        tip = BrushTip(
            scaleX = 1f,
            scaleY = 1f,
            cornerRounding = 1f,
            behaviors = listOf(wobbleBehavior),
        ),
        paint = BrushPaint(selfOverlap = SelfOverlap.ANY),
    )
}

<br />

Common pitfalls

  • Reversing node nesting order : TargetNode or PolarTargetNode must be the outermost terminalNode, wrapping operator ValueNode instances that wrap SourceNode or NoiseNode.

  • Setting rangeStart == rangeEnd : Range endpoints on SourceNode, TargetNode, and PolarTargetNode must be distinct (start != end); equal endpoints throw IllegalArgumentException. Use ConstantNode for fixed values.

  • Using OutOfRange.CLAMP on cyclic direction or orientation : DIRECTION_IN_RADIANS and ORIENTATION_IN_RADIANS wrap around 2π---always pass OutOfRange.REPEAT.

  • Fading OPACITY_MULTIPLIER to 0f without reducing SIZE_MULTIPLIER to 0f : Also taper SIZE_MULTIPLIER to 0f so invisible stroke mesh geometry is pruned.

Requirements and compatibility

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs Node.js

Security

No issues found by our static scan· No issues found

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Created by
Google LLC
Publisher
Android (Google) (Official)
Distributed by
Android (Google)
Source
android/skills · jetpack-compose/ink/ink-custom-brush-builder · commit bf68e2d
Licence
Apache License 2.0 (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE.txt)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Google LLC or Android (Google).

Download ZIP

The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download ink-custom-brush-builder.zip