Android 噴霧效果實現

噴霧效果

前言

很長時間沒寫博客了,另外今年的大部分工作都是和Native層相關。不過話說回來,Android社區的活躍度已經大不如前了,很多人都轉向其他領域了。另外,去年也就過一段時間的Compose UI,目前所接觸的項目還沒見過有使用這種的。

最近的工作是和UI相關,所以順便寫一篇。Android Canvas總體上是很強大的,可以支持很多繪製,在之前的文章中,我們實現過《煙花效果》、《火焰效果》、《心跳效果》、《粒子loading效果》等等,當然,總體的文章不僅僅這些,之所以說這幾種效果,是因為他們都屬於“粒子”動畫。所以,本篇本質也和粒子動畫有關,顯然,就是這樣。

下載.jpeg

挑戰

對於噴霧效果,實際上涉及一些仿真的問題,本身也是一個很有挑戰的事情。自然界的霧都是水氣組成,基於最基本的原則,如果在繪製時,很小的一團霧都可能需要上千個粒子,這對繪製而言,壓力會很大。

基於上面的問題,粒子的繪製,顯然不能讓粒子過小,但是粒子變大就會出現圓圈效果,在之前的《火焰效果》中,我們使用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;
    }
}

最終效果如下

效果圖

從效果上看,基於高斯模糊的方案流暢度有一定欠缺(主要原因是Bitmap緩衝過大導致),其次,模糊感太重了。

基於霧團圖片

這種方案的基本原理就是找1張霧團圖片,然後通過隨機粒子的方式噴射,噴射方式基本和高斯模糊方案一樣。

但是你可能會想,既然這樣,高斯模糊方案是不是也可以生成一張霧團方案,然後噴射漸變,顯然,這個方案也是可以的,但是終究解決不了一個問題,那就是高斯模糊的霧團還是太模糊。

基於霧團圖片方案的源碼

實際上,只要有基本的繪製邏輯,實現這種效果其實並不難,這裡我們借助Github開源代碼Leonids來實現即可。

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);
    }
}
細節處理

不過,這裡幾個細節需要處理,首先我們需要拿到一張舞團圖片,當然,此開源專案中實際上也給了,但是如你所見,這個顏色實際上泛黃。

dust.png

那麼最終的效果就是這樣的

效果圖

這種問題我們可以找設計重新繪製一下,或者用PS換一下,如果你實在找不到方法,不妨試試Alpha通道替換,在之前的文章《Android 閃爍描邊效果》中,我們已經使用過這種技術,具體步驟如下。

  • 解碼圖片資源
  • 提取ALPHA通道 (確保你的圖片有alpha通道才能提取,如argb或者alpha格式才行),也就是只保留透明度數據bitmap
  • 為alpha通道的bitmap添加自己喜歡的rgb顏色,當然本篇是白色。

調整後的圖片

那麼,我們調整代碼

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類中

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

建議改造成

mDpToPxScale = displayMetrics.density;

其他方案

事實上,Android噴霧效果也有很多實現,主要在遊戲應用中比較多,主要原因是類似open gl es或者vulkan可以利用gpu,實現更真實的效果。

當然,本篇也是全網為數不多實現噴霧效果的文章,建議收藏。

綜上,以上是本篇全部內容,希望對你有所幫助。


原文出處:https://juejin.cn/post/7546582754025832457


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝10   💬8   ❤️12
427
🥈
我愛JS
📝1   💬6   ❤️4
90
🥉
酷豪
📝1   ❤️1
52
#4
AppleLily
📝1   💬4   ❤️1
41
#5
💬3  
10
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次