前言
很长时间没写博客了,另外今年的大分部工作都是都和Native层相关。不过话说回来,Android社区的活跃度已经大不如从前了,很多人都转向其他领域了。另外,去年也就过一段时间的Compose UI,目前所接触的项目还没见过有使用这种的。
最近的工作是和UI相关,所以顺便写一篇。Android Canvas总体上是很强大的,可以支持很多绘制,在之前的文章中,我们实现过《烟花效果》、《火焰效果》、《心跳效果》、《粒子loading效果》等等,当然,总体的文章不仅仅这些,之所以说这几种效果,是因为他们都属于"粒子"动画。所以,本篇本质也和粒子动画有关,显然,就是这样。

挑战
对于喷雾效果,实际上涉及一些仿真的问题,本身也是一个很有挑战的事情。自然界的雾都是水气组成,基于最基本的原则,如果在绘制时,很小的一团雾都可能需要上千个粒子,这对绘制而言,压力会很大。
基于上面的问题,粒子的绘制,显然不能让粒子过小,但是粒子变大就会出现圆圈效果,在之前的《火焰效果》中,我们使用GradientShader + Blend实现了火焰,但是,它并不能适用于"雾气"效果,一个本质的原因是雾的颜色都是白色,同样还有稀疏感。
本篇原理
实际上,在本篇我们通过两种方式实现喷雾效果。首先,无论哪一点,都要解决两个问题,第一个是"雾团"的绘制问题。因为我们知道,雾团绘制时使用过小粒子是不现实的,除非你用Open GL等GPU工具,因此,我们需要生成一个雾团,第二个问题是,如何让画面看起来像雾一样飘散。
本篇,我们实现两种喷雾,希望给大家提供建议。
一种是基于高斯模糊,另一种是基于雾团图片的实现。
基于高斯模糊
首先,我们利用Bitmap缓冲,在Bitmap上我们可以通过半径较大的圆或者椭圆,实现粒子的喷射(x方向速度小一点,y方向速度大一点),并且随着进度透明度递减,然后,利用高斯模糊,将图片模糊化,之后,再将Bitmap绘制到View上。
整个流程来说,我们是先解决了飘散问题,其次才实现了雾团效果。当然,我们需要注意的一个问题是,高斯模糊这个名称中的"模糊"二字,这种"模糊"感类似近视眼的人看到雾团,显然,对一些场景而言,不太适应。
当然,你觉得没啥问题时候,有需要考虑另外一个问题,Bitmap缓冲大小,高斯模糊也是算法,Bitmap缓冲越大,模糊算法就会越耗时,显然,你需要尽可能让Bitmap变小,比如,480大小或者720大小,但这样副作用也是有的,我们知道,小图绘制到大的屏幕,也会增加模糊感。
因此而言,这种方案,局限性其实很大的。
高斯迷糊方案源码
总体流程如下
- 初始化View和RenderScript
- 创建Bitmap和Canvas
- 生成粒子
- 在Bitmap上绘制粒子
- 高斯模糊化Bitmap
- 绘制Bitmap到View
java
class FogView extends View {
private final Paint particlePaint = new Paint();
private final Paint clearPaint = new Paint();
private final List<Particle> particles = new ArrayList<>();
// 离屏渲染的缓存 Bitmap 和 Canvas
private Bitmap mOffscreenBitmap;
private Canvas mOffscreenCanvas;
// RenderEffect 和 RenderScript 相关变量,用于高效模糊
private RenderEffect blurEffect;
private RenderScript rs;
private ScriptIntrinsicBlur blurScript;
private boolean isEmitting = true;
// 控制喷雾效果的参数
private static final int PARTICLES_PER_FRAME = 15; // 每帧生成粒子数
private static final float BLUR_RADIUS = 20.0f; // 模糊半径(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));
// 根据 API 版本选择合适的模糊技术
rs = RenderScript.create(getContext());
blurScript = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs));
blurScript.setRadius(BLUR_RADIUS); // 设置模糊强度
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
if (w > 0 && h > 0) {
// 当 View 尺寸变化时,重新创建离屏缓存
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步:生成和更新粒子
if (isEmitting) {
createParticles(mOffscreenBitmap.getWidth() / 2f, mOffscreenBitmap.getHeight());
}
updateParticles();
// 第2步:将粒子绘制到离屏画布
drawParticlesToOffscreenCanvas();
Bitmap blurredBitmap = applyBlurToBitmap(mOffscreenBitmap);
canvas.drawBitmap(blurredBitmap, 0, 0, null);
canvas.drawBitmap(blurredBitmap, getWidth()/2, 0, null);
// 持续触发重绘以实现动画效果
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);
}
}
// 低版本使用的模糊方法
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;
}
}
最终效果如下

基于雾团图片
这种方案的基本原理就是找1张雾团图片,然后通过随机粒子的方式喷射,喷射方式基本和高斯模糊方案一样。
但是你可能会想,既然这样,高斯模糊方案是不是也可以生成一张雾团方案,然后喷射渐变,显然,这个方案也是可以的,但是终究解决不了一个问题,那就是高斯模糊的雾团还是太模糊。
基于雾团图片方案的源码
实际上,只要有基本的绘制逻辑,实现这种效果其实并不难,这里我们借助Github开源代码Leonids来实现即可。
java
public class DustExampleActivity extends Activity implements OnClickListener, Handler.Callback {
private static final int MSG_SHOT = 1;
private Handler handler;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_dust_example);
findViewById(R.id.button1).setOnClickListener(this);
this.handler = new Handler(Looper.getMainLooper(),this);
}
@Override
public void onClick(View arg0) {
this.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);
this.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);
}
}
细节处理
不过,这里几个细节需要处理,首先我们需要拿到一张舞团图片,当然,此开源项目中实际上也给了,但是如你所见,这个颜色实际上泛黄。

那么最终的效果就是这样的

这种问题我们可以找设计重新绘制一下,或者用PS换一下,如果你实在找不到方法,不妨试试Alpha通道替换,在之前的文章《Android 闪烁描边效果》中,我们已经使用过这种技术,具体步骤如下。
- 解码图片图片
- 提取ALPHA通道 (确保你的图片有alpha通道才能提取),也就是只保留透明度数据bitmap
- 为alpha通道的bitmap添加自己喜欢的rgb颜色,当然本篇是白色。

那么,我们调整代码
java
public class DustExampleActivity extends Activity implements OnClickListener, Handler.Callback {
private static final int MSG_SHOT = 1;
private Handler handler;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_dust_example);
findViewById(R.id.button1).setOnClickListener(this);
this.handler = new Handler(Looper.getMainLooper(),this);
}
@Override
public void onClick(View arg0) {
this.handler.sendEmptyMessage(MSG_SHOT);
}
@Override
public boolean handleMessage(Message msg) {
if(msg.what == MSG_SHOT){
Bitmap originBitmap = decodeBitmap(R.drawable.dust);
Bitmap alphaMaskBitmap = Bitmap.createBitmap(originBitmap.getWidth(), originBitmap.getHeight(), Bitmap.Config.ALPHA_8);
Paint paint = new Paint();
paint.setColor(Color.WHITE);
Canvas alphaMaskCanvas = new Canvas(alphaMaskBitmap);
alphaMaskCanvas.drawBitmap(originBitmap,0,0,null);
Bitmap bitmap = Bitmap.createBitmap(alphaMaskBitmap.getWidth(), alphaMaskBitmap.getHeight(), Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
canvas.drawBitmap(alphaMaskBitmap,0,0,paint);
new ParticleSystem(this, 4, bitmap, 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);
this.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);
}
}
最终效果如下

总结
好了,以上是本篇的全部内容,两种方式都可以实现喷雾效果,但是,总体而言,比较推荐第二种,因为可以避免高斯模糊的模糊感太重的问题。但是第二种方案中,源码中也存在部分缺陷,那就是"密度计算问题",会导致和屏幕不适配,建议修改代码后。
问题代码
问题出现在ParticleSystem类中
java
mDpToPxScale = displayMetrics.density;
建议改造成
java
mDpToPxScale = displayMetrics.density;
综上,以上是本篇全部内容,希望对你有所帮助。