Android 喷雾效果实现

前言

很长时间没写博客了,另外今年的大分部工作都是都和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;

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

相关推荐
凉、介5 小时前
U-Boot 多 CPU 执行状态引导
java·服务器·前端
一个尚在学习的计算机小白5 小时前
spring
android·java·spring
南囝coding5 小时前
Claude 封禁中国?为啥我觉得是个好消息
前端·后端
wordbaby5 小时前
备忘录模式(Memento Pattern)详解
前端
tangweiguo030519875 小时前
Android应用完全重启指南:从任务重置到进程重生
android
小鱼儿亮亮5 小时前
二、React基础精讲:编写TodoList、事件绑定、JSX语法、组件之间传值
前端·react.js
Mintopia5 小时前
实时 AIGC:Web 端低延迟生成的技术难点与突破
前端·javascript·aigc
小鱼儿亮亮5 小时前
五、Redux进阶:UI组件、容器组件、无状态组件、异步请求、Redux中间件:Redux-thunk、redux-saga,React-redux
前端·react.js
Mintopia5 小时前
Next.js 性能优化双绝:Image 与 next/font 的底层修炼手册
前端·javascript·next.js