Android 喷雾效果实现
点击关注公众号,“技术干货” 及时达!
前言
很长时间没写博客了,另外今年的大分部工作都是都和 Native 层相关。不过话说回来,Android 社区的活跃度已经大不如从前了,很多人都转向其他领域了。另外,去年也就过一段时间的 Compose UI,目前所接触的项目还没见过有使用这种的。
最近的工作是和 UI 相关,所以顺便写一篇。Android Canvas 总体上是很强大的,可以支持很多绘制,在之前的文章中,我们实现过《烟花效果》、《燃烧起来吧》、《心跳动画》、《Android 四种常见的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
class FogView extends View {private final Paint particlePaint = new Paint();private final Paint clearPaint = new Paint();private final List<Particle> particles = new ArrayList<>();// 离屏渲染的缓存 Bitmap 和 Canvasprivate 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); // 设置模糊强度}@Overrideprotected 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);}}@Overrideprotected 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[6] 来实现即可。
public class DustExampleActivity extends Activity implements OnClickListener, Handler.Callback {private static final int MSG_SHOT = 1;private Handler handler;@Overrideprotected 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);}@Overridepublic void onClick(View arg0) {this.handler.sendEmptyMessage(MSG_SHOT);}@Overridepublic 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 通道才能提取,如 argb 或者 alpha 格式才行),也就是只保留透明度数据 bitmap (Bitmap.Config.ALPHA_8) 为 alpha 通道的 bitmap 添加自己喜欢的 rgb 颜色,当然本篇是白色。
那么,我们调整代码
public class DustExampleActivity extends Activity implements OnClickListener, Handler.Callback {private static final int MSG_SHOT = 1;private Handler handler;private Bitmap bitmap;@Overrideprotected 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);}@Overridepublic void onClick(View arg0) {this.handler.sendEmptyMessage(MSG_SHOT);}@Overridepublic 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);}}
最终效果如下
总结
好了,以上是本篇的全部内容,两种方式都可以实现喷雾效果,但是,总体而言,比较推荐第二种,因为可以避免高斯模糊的模糊感太重的问题。但是第二种方案中,源码中也存在部分缺陷,那就是 “密度计算问题”,会导致和屏幕不适配,建议修改代码后。
问题代码
问题出现在 ParticleSystem 类中
mDpToPxScale = (displayMetrics.xdpi / DisplayMetrics.DENSITY_DEFAULT);
建议改造成
mDpToPxScale = displayMetrics.density;其他方案
事实上,Android 喷雾效果也有很多实现,主要在游戏应用中比较多,主要原因是类似 open gl es 或者 vulkan 可以利用 gpu,实现更真实的效果。
当然,本篇也是全网为数不多实现喷雾效果的文章,建议收藏。
综上,以上是本篇全部内容,希望对你有所帮助。