稀土掘金技术社区

Android 喷雾效果实现

点击关注公众号,“技术干货” 及时达!

前言

很长时间没写博客了,另外今年的大分部工作都是都和 Native 层相关。不过话说回来,Android 社区的活跃度已经大不如从前了,很多人都转向其他领域了。另外,去年也就过一段时间的 Compose UI,目前所接触的项目还没见过有使用这种的。

最近的工作是和 UI 相关,所以顺便写一篇。Android Canvas 总体上是很强大的,可以支持很多绘制,在之前的文章中,我们实现过《烟花效果》、《燃烧起来吧》、《心跳动画》、《Android 四种常见的loading效果》等等,当然,总体的文章不仅仅这些,之所以说这几种效果,是因为他们都属于 “粒子” 动画。所以,本篇本质也和粒子动画有关,显然,就是这样。

Image

挑战

对于喷雾效果,实际上涉及一些仿真的问题,本身也是一个很有挑战的事情。自然界的雾都是水气组成,基于最基本的原则,如果在绘制时,很小的一团雾都可能需要上千个粒子,这对绘制而言,压力会很大。

基于上面的问题,粒子的绘制,显然不能让粒子过小,但是粒子变大就会出现圆圈效果,在之前的《燃烧起来吧》中,我们使用 GradientShader + Blend 实现了火焰,但是,它并不能适用于 “雾气” 效果,一个本质的原因是雾的颜色都是白色,同样还有稀疏感。

本篇原理

实际上,在本篇我们通过两种方式实现喷雾效果。首先,无论哪一点,都要解决两个问题,第一个是 “雾团” 的绘制问题。因为我们知道,雾团绘制时使用过小粒子是不现实的,除非你用 Open GL 等 GPU 工具,因此,我们需要生成一个雾团,第二个问题是,如何让画面看起来像雾一样飘散。

本篇,我们实现两种喷雾,希望给大家提供建议。

一种是基于高斯模糊,另一种是基于雾团图片的实现。

基于高斯模糊

首先,我们利用 Bitmap 缓冲,在 Bitmap 上我们可以通过半径较大的圆或者椭圆,实现粒子的喷射(x 方向速度小一点,y 方向速度大一点),并且随着进度透明度递减,然后,利用高斯模糊,将图片模糊化,之后,再将 Bitmap 绘制到 View 上。

整个流程来说,我们是先解决了飘散问题,其次才实现了雾团效果。当然,我们需要注意的一个问题是,高斯模糊这个名称中的 “模糊” 二字,这种 “模糊” 感类似近视眼的人看到雾团,显然,对一些场景而言,不太适应。

当然,你觉得没啥问题时候,有需要考虑另外一个问题,Bitmap 缓冲大小,高斯模糊也是算法,Bitmap 缓冲越大,模糊算法就会越耗时,显然,你需要尽可能让 Bitmap 变小,比如,480 大小或者 720 大小,但这样副作用也是有的,我们知道,小图绘制到大的屏幕,也会增加模糊感。

因此而言,这种方案,局限性其实很大的。

高斯迷糊方案源码

总体流程如下

  • 初始化 View 和 RenderScript
  • 创建 Bitmap 和 Canvas
  • 生成粒子
  • 在 Bitmap 上绘制粒子
  • 高斯模糊化 Bitmap
  • 绘制 Bitmap 到 View

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;    }
}

最终效果如下

Image

从效果上看,基于高斯模糊的方案流畅度有一定欠缺(主要原因是 Bitmap 缓冲过大导致),其次,模糊感太重了。

基于雾团图片

这种方案的基本原理就是找 1 张雾团图片,然后通过随机粒子的方式喷射,喷射方式基本和高斯模糊方案一样。

但是你可能会想,既然这样,高斯模糊方案是不是也可以生成一张雾团方案,然后喷射渐变,显然,这个方案也是可以的,但是终究解决不了一个问题,那就是高斯模糊的雾团还是太模糊。

基于雾团图片方案的源码

实际上,只要有基本的绘制逻辑,实现这种效果其实并不难,这里我们借助 Github 开源代码 Leonids[6] 来实现即可。

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);    }
}
细节处理

不过,这里几个细节需要处理,首先我们需要拿到一张舞团图片,当然,此开源项目中实际上也给了,但是如你所见,这个颜色实际上泛黄。

Image

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

Image

这种问题我们可以找设计重新绘制一下,或者用 PS 换一下,如果你实在找不到方法,不妨试试 Alpha 通道替换,在之前的文章《Android 闪烁描边效果》中,我们已经使用过这种技术,具体步骤如下。

  • 解码图片资源
  • 提取 ALPHA 通道 (确保你的图片有 alpha 通道才能提取,如 argb 或者 alpha 格式才行),也就是只保留透明度数据 bitmap (Bitmap.Config.ALPHA_8)
  • 为 alpha 通道的 bitmap 添加自己喜欢的 rgb 颜色,当然本篇是白色。
Image

那么,我们调整代码

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);       this.handler = new Handler(Looper.getMainLooper(),this);

          Bitmap originBitmap = decodeBitmap(R.drawable.dust);          //提取alpha通道,注意色彩格式必须是ALPHA_8,意思是仅保留alpha通道          Bitmap alphaMaskBitmap = Bitmap.createBitmap(          originBitmap.getWidth(),           originBitmap.getHeight(),           Bitmap.Config.ALPHA_8);
          Canvas alphaMaskCanvas = new Canvas(alphaMaskBitmap);          alphaMaskCanvas.drawBitmap(originBitmap,0,0,null);
          //上色          Paint paint = new Paint();          paint.setColor(Color.WHITE);
          bitmap = Bitmap.createBitmap(alphaMaskBitmap.getWidth(), alphaMaskBitmap.getHeight(), Bitmap.Config.ARGB_8888);          Canvas canvas = new Canvas(bitmap);          canvas.drawBitmap(alphaMaskBitmap,0,0,paint);
    }
    @Override    public void onClick(View arg0) {       this.handler.sendEmptyMessage(MSG_SHOT);    }
    @Override    public boolean handleMessage(Message msg) {       if(msg.what == MSG_SHOT){
          new ParticleSystem(this, 4, bitmap, 2000)                .setSpeedByComponentsRange(-0.045f, 0.045f, -0.25f, -0.8f) //设置x方向和y方向的速度阈值                .setAcceleration(0.00001f, 30)  //设置x和y方向的加速度                .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);    }

}

最终效果如下

Image

总结

好了,以上是本篇的全部内容,两种方式都可以实现喷雾效果,但是,总体而言,比较推荐第二种,因为可以避免高斯模糊的模糊感太重的问题。但是第二种方案中,源码中也存在部分缺陷,那就是 “密度计算问题”,会导致和屏幕不适配,建议修改代码后。

问题代码

问题出现在 ParticleSystem 类中


mDpToPxScale = (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT);

建议改造成

mDpToPxScale = displayMetrics.density;

其他方案

事实上,Android 喷雾效果也有很多实现,主要在游戏应用中比较多,主要原因是类似 open gl es 或者 vulkan 可以利用 gpu,实现更真实的效果。

当然,本篇也是全网为数不多实现喷雾效果的文章,建议收藏。

综上,以上是本篇全部内容,希望对你有所帮助。

图片
点击"阅读原文"了解详情~