How to Create Realistic Spray Effects in Android Canvas Using Gaussian Blur and Image Masks
This article explains two Android Canvas spray‑effect techniques—one using Gaussian blur and another using fog‑image masks—detailing their challenges, implementation steps, full source code, performance trade‑offs, and tips for fixing density issues.
Preface
It has been a while since I wrote a blog post, and this year most of my work is related to the Native layer. The Android community’s activity has declined, with many developers moving to other fields, and Compose UI has not yet been adopted in my projects.
My recent work involves UI, so I decided to write about Android Canvas spray effects. Canvas is powerful and can support many drawing operations. In previous articles I implemented particle‑based effects such as fireworks, burning, heartbeat animation, and several common loading animations. This article focuses on spray (fog) effects, which are also particle animations.
Challenges
Creating a spray effect is challenging because it involves simulating fog, which consists of countless tiny water droplets. Rendering thousands of particles per fog puff can be computationally expensive.
Particles cannot be too small, otherwise they become invisible; making them larger creates a circular appearance. In a previous article we used GradientShader + Blend to render flames, but that approach does not work for fog because fog is white and sparse.
Principles of This Article
We implement spray effects using two methods. Both must solve two problems: (1) how to draw a fog cloud without using unrealistically tiny particles, and (2) how to make the cloud appear to drift like real fog. The first method relies on Gaussian blur, the second on a fog‑image mask.
Based on Gaussian Blur
We first use a large‑radius circle or ellipse on a Bitmap as a particle emitter (small x‑velocity, larger y‑velocity) and decrease its opacity over time. Then we apply Gaussian blur to the bitmap and draw the blurred bitmap onto the view.
This solves the drifting problem first, then the fog‑cloud effect. However, Gaussian blur introduces a “near‑sighted” look that may not suit all scenes, and large bitmap buffers increase processing time.
To keep performance acceptable, the bitmap should be as small as possible (e.g., 480 × … or 720 × …), but scaling a small bitmap to a large screen can increase the perceived blur.
Gaussian Blur Implementation Source Code
The overall process is as follows
Initialize View and RenderScript
Create Bitmap and Canvas
Generate particles
Draw particles onto Bitmap
Apply Gaussian blur to Bitmap
Render Bitmap to View
class FogView extends View {
private final Paint particlePaint = new Paint();
private final Paint clearPaint = new Paint();
private final List<Particle> particles = new ArrayList<>();
// Off‑screen rendering buffers
private Bitmap mOffscreenBitmap;
private Canvas mOffscreenCanvas;
// Blur utilities
private RenderEffect blurEffect;
private RenderScript rs;
private ScriptIntrinsicBlur blurScript;
private boolean isEmitting = true;
private static final int PARTICLES_PER_FRAME = 15; // particles per frame
private static final float BLUR_RADIUS = 20.0f; // blur radius (1‑25)
public FogView(Context context) {
super(context);
}
public FogView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
{
init();
}
private void init() {
particlePaint.setAntiAlias(true);
particlePaint.setStyle(Paint.Style.FILL);
clearPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.CLEAR));
rs = RenderScript.create(getContext());
blurScript = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs));
blurScript.setRadius(BLUR_RADIUS); // set blur intensity
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (w > 0 && h > 0) {
mOffscreenBitmap = Bitmap.createBitmap(w/2, h, Bitmap.Config.ARGB_8888);
mOffscreenCanvas = new Canvas(mOffscreenBitmap);
}
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mOffscreenBitmap == null) return;
// 1️⃣ generate and update particles
if (isEmitting) {
createParticles(mOffscreenBitmap.getWidth() / 2f, mOffscreenBitmap.getHeight());
}
updateParticles();
// 2️⃣ draw particles to off‑screen canvas
drawParticlesToOffscreenCanvas();
Bitmap blurredBitmap = applyBlurToBitmap(mOffscreenBitmap);
canvas.drawBitmap(blurredBitmap, 0, 0, null);
canvas.drawBitmap(blurredBitmap, getWidth()/2, 0, null);
// keep animating
if (isEmitting && !particles.isEmpty()) {
postInvalidateOnAnimation();
}
}
private void updateParticles() {
Iterator<Particle> iterator = particles.iterator();
while (iterator.hasNext()) {
Particle p = iterator.next();
if (!p.update()) {
iterator.remove();
}
}
}
private void createParticles(float emitterX, float emitterY) {
if (particles.size() > 500) return;
for (int i = 0; i < PARTICLES_PER_FRAME; i++) {
particles.add(new Particle(emitterX, emitterY));
}
}
private void drawParticlesToOffscreenCanvas() {
mOffscreenCanvas.drawRect(0, 0, mOffscreenCanvas.getWidth(), mOffscreenCanvas.getHeight(), clearPaint);
for (Particle p : particles) {
particlePaint.setARGB(p.alpha, Color.red(p.color), Color.green(p.color), Color.blue(p.color));
mOffscreenCanvas.drawCircle(p.x, p.y, p.size, particlePaint);
}
}
// Low‑version blur method
private Bitmap applyBlurToBitmap(Bitmap bitmap) {
Allocation input = Allocation.createFromBitmap(rs, bitmap);
Allocation output = Allocation.createTyped(rs, input.getType());
blurScript.setInput(input);
blurScript.forEach(output);
output.copyTo(bitmap);
input.destroy();
output.destroy();
return bitmap;
}
}Final effect:
The Gaussian‑blur solution suffers from occasional stutter due to large bitmap buffers, and the blur can appear overly heavy.
Based on Fog Image
This approach uses a single fog‑image as a texture. Random particles are emitted and drawn onto the image, similar to the Gaussian method, but without the heavy blur.
Although you could generate a fog texture from the Gaussian method and animate it, the resulting fog would still be too blurry.
Fog Image Implementation Source Code
The implementation leverages the open‑source Leonids particle system.
public class DustExampleActivity extends Activity implements OnClickListener, Handler.Callback {
private static final int MSG_SHOT = 1;
private Handler handler;
private Bitmap bitmap;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_dust_example);
findViewById(R.id.button1).setOnClickListener(this);
handler = new Handler(Looper.getMainLooper(), this);
}
@Override
public void onClick(View v) {
handler.sendEmptyMessage(MSG_SHOT);
}
@Override
public boolean handleMessage(Message msg) {
if (msg.what == MSG_SHOT) {
Bitmap originBitmap = decodeBitmap(R.drawable.dust);
new ParticleSystem(this, 4, originBitmap, 2000)
.setSpeedByComponentsRange(-0.045f, 0.045f, -0.25f, -0.8f)
.setAcceleration(0.00001f, 30)
.setInitialRotationRange(0, 360)
.addModifier(new AlphaModifier(180, 0, 0, 1500))
.addModifier(new ScaleModifier(0.5f, 2f, 0, 1000))
.oneShot(findViewById(R.id.emiter_bottom), 4);
handler.sendEmptyMessageDelayed(MSG_SHOT, 32);
}
return false;
}
private Bitmap decodeBitmap(int resId) {
BitmapFactory.Options options = new BitmapFactory.Options();
options.inMutable = true;
return BitmapFactory.decodeResource(getResources(), resId, options);
}
}Final effect:
Detail Handling
We need a fog‑image texture; the provided open‑source asset appears yellowish, so you may need to recolor it or replace the alpha channel.
After adjusting the code to recolor the alpha mask, the final effect looks like this:
Conclusion
Both approaches can achieve spray effects, but the fog‑image method is generally preferred because it avoids the heavy blur of the Gaussian solution. The fog‑image method still has a density‑calculation issue that may not adapt to all screen sizes; adjust the code as suggested.
Problem Code
The issue appears in the ParticleSystem class:
mDpToPxScale = (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT);Suggested Fix
mDpToPxScale = displayMetrics.density;Other Approaches
In game development, spray effects are often implemented with OpenGL ES or Vulkan for more realistic results. This article is one of the few that implements spray effects on Android without resorting to those graphics APIs.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
