前言
时间过的很快,不知不觉大家年龄又要长一岁了,也就是这几年,很多人才意识到自己的渺小,无形的手和有形手不断提醒你和指导你,做人应该逆来顺受,犹如进入了一个崭新的时代,而我们仅仅是时代中的一粒尘埃,风一吹就会到处乱漂。经济数据比想象的差,裁员潮一波接着一波,股市也是跌跌不休,因此大家都在卷,并且很多人思想上保守了,生活上也保守了,也变得不听"话"了。
但是,我们不能因为过去的不快而影响新的一年,我们反思过去的问题,重新制定新年计划,重新启航吧。客观因素上大家都面临不确定性,选择保守是本能使然,但并不意味着沉沦甚至躺平。我个人观点,只要还活着,就应该力所能及的让寒冬来得晚一些,走得早一些。
下面是本篇的效果实现,期待新的一年大家的工作生活红红火火。
火焰,象征一种祝福,希望大家在今年取得新的成就和收益。
预览效果
本篇主要是火焰效果实现,至于效果图中的龙是gif图片。途中的🔥看起来有些差,主要原因是GIF录制不够饱和。
火焰原理
实际上火焰的实现是通过粒子叠加效果实现的。
也就是说,本篇会用到Xfermode对图像进行叠加,但是我们知道,叠加是有一定要求的,比如颜色值不能相同的,因此,我们对火焰的定义需要尽可能让颜色相似火焰,但同时也要有一定的随机性,当然,这里还有个问题,纯色叠加容易有明显的切割感,比如我们在 《Android 粒子喷泉动效》中使用的LIGHTEN 叠加,我们需要的是融合效果,因此,我们还需要使用RadialGradient来增强融合效果。
现有方案
在前段时间,掘金上也有大佬实现火焰动效的效果,但是存在一些问题,就是没有使用RadialGradient + Blend,而是使用了Blur,这就导致火焰比较模糊且融合的不够好,其次Blur往往是修改像素实现,因此要尽可能让图片小,否则也有一定的性能问题。
我们先来看看普通的叠加效果
下面,我们使用了圆来构成粒子,其实火焰如果使用椭圆或许会更好,不过android Canvas 椭圆绘制还是比较费事的,同一家公司设计的产品,html5的Canvas api总是比Android的Canvas api好用的多。
下面是LIGHTEN 模式的叠加效果
BlendMode模式
在Android 9开始,Paint开始支持BLEND模式了,但是为了兼容低版本,使用Xfermode去兼容,当然有些效果是不支持的,兼容的API是andriodx中提供的PaintCompat
java
public static boolean setBlendMode(@NonNull Paint paint, @Nullable BlendModeCompat blendMode) {
if (Build.VERSION.SDK_INT >= 29) {
Object blendModePlatform = blendMode != null
? BlendModeUtils.Api29Impl.obtainBlendModeFromCompat(blendMode) : null;
Api29Impl.setBlendMode(paint, blendModePlatform);
// All blend modes supported in Q
return true;
} else if (blendMode != null) {
PorterDuff.Mode mode = obtainPorterDuffFromCompat(blendMode);
paint.setXfermode(mode != null ? new PorterDuffXfermode(mode) : null);
// If the BlendMode has an equivalent PorterDuff mode, return true,
// otherwise return false
return mode != null;
} else {
// Configuration of a null BlendMode falls back to the default which is supported in
// all platform levels
paint.setXfermode(null);
return true;
}
}
不过目前最好用的是PLUS和LIGHTEN效果,前者叠加后亮度更高,后者稍差一些。
关于粒子动效
粒子运动比较重要的是三个方面
- 起始点
- 矢量速度
- 符合运动学方程
在绘制粒子时,一定要使用容器管理粒子,我们前面一些文章中实际上定义过粒子,粒子需要被管理起来,因此,使用数据结构是必须的。
火焰实现
我们前面说过,火焰是粒子实现,因此我们首先应该实现粒子对象,一般情况下,Canvas是2D坐标系,因此我们只需要管理好x,y坐标即可。
定义粒子对象
下面我们来定义一个FireParticle来描述粒子,当然,有2个时长,第一个比较好理解,而remaining_life 的作用是为了计算透明度。
另外,我们会实现reset方法,重置粒子状态,方便粒子复用。
java
static class FireParticle {
private final float initX; //初始值x
private final float initY; //初始值y
public float opacity; //透明度
private PointF location; // 当前位置
private int r; // red 色值
private int g; // green 色值
private int b; // blue 色值
private float radius; // 粒子半径
private float life; // 粒子生存时长
private float remaining_life; //粒子剩余生存时长
private PointF speed; //x,y方向的矢量速度
FireParticle(float x,float y) {
this.initX = x;
this.initY = y;
init();
}
public void reset() {
init();
}
private void init() {
//计算X,Y、轴速度
this.speed = new PointF(-4.5f + (float) (Math.random() * 8f), -20f + (float) (Math.random() * 10f));
//初始位置
this.location = new PointF(initX, initY);
//随机半径
this.radius = (float) (30 + Math.random() * 55);
//duration - 生存时间
this.life = (float) (20 + Math.random() * 20);
//剩余生存时间
this.remaining_life = this.life;
//colors
this.r = 255;
this.g = 0;
this.b = 0;
}
}
融合
下面我们来简单看下纯色效果
是不是已经很像火焰了,但是更像在水中"吐泡泡",为了让火焰更加逼真,我们调整rgb的色值,让其趋近火焰效果,红绿蓝中,红绿用来叠加黄色效果,这里我们将色值调整如下。
颜色修改
java
this.r = 200 + (int) Math.round(Math.random() * 55);
this.g = (int) Math.round(Math.random() * 155);
this.b = (int) Math.round(Math.random() * 100);
为什么需要随机呢,我们前面说过,不同的颜色才能在Xformode合成时进行叠加。
设置BlendMode
为了让颜色能叠加,我们使用PaintCompat
java
PaintCompat.setBlendMode(mPaint, BlendModeCompat.PLUS);
图像边缘融合
我们前面提到过,纯色融合边缘会很明显出现"交集",因此,我们不是用纯色,而是使用Shader中的RadialGrendient
java
int[] colors = new int[3];
float[] colorStops = new float[]{
0,
0.5f,
1f
};
colors[0] = argb((int) (p.opacity * 255f), p.r, p.g, p.b);
colors[1] = argb((int) (p.opacity * 255f), p.r, p.g, p.b);
colors[2] = argb(0, p.r, p.g, p.b);
RadialGradient radialGradient = new RadialGradient(p.location.x, p.location.y, p.radius, colors, colorStops, Shader.TileMode.CLAMP);
paint.setShader(radialGradient);
// mPaint.setAlpha((int) (p.opacity * 255));
// mPaint.setColor(Color.RED);
canvas.drawCircle(p.location.x, p.location.y, p.radius, paint);
接下来我们看看效果
是不是好看多了
火焰范围控制
上面我们只是实现了单个火焰,而我们想要的是一大团火焰效果,能不能控制火焰的面积呢?
大范围控制火焰也是可以的,不过,如果想让火焰效果更具可扩展性,所以变更火焰位置,是不是更好呢?当然,本篇就是这么写的。
定义粒子发射器
下面是单个粒子绘制的完整逻辑,我们通过改变初始位置来调整起始点,这样我们就能决定粒子的范围了
java
static class FireParticleShooter {
public void init(float x, float y) {
if (particles.isEmpty()) {
for (int i = 0; i < count; i++) {
particles.add(new FireParticle(x,y));
}
}
}
// 省略绘制逻辑
}
以上是粒子控制的核心逻辑
View实现
下面我们来实现View承载火焰效果,当然Drawable也是可以的,这里我们通过一些手段,控制绘制逻辑。
java
FireParticleShooter[] fireParticleShooters = null; //发射器管理容器
private int fireNum = -1; // 火焰数量, -1 表示平铺整个宽度
private boolean isStart = true; //是否开始绘制
final float fireCircleRange = 100; //单个火焰范围
那么怎么初始化呢
其实很简单,我们通过数量来控制位置,计算第一把火的位置,公式如下 float startX = - fireCircleRange * NUM /2 + fireCircleRange/2;
那么,其他火把的位置就是
(startX + fireCircleRange * i) 下面是火焰发射器初始化和调度逻辑
java
int NUM = fireNum; // 火焰数量
if(NUM < 0){
NUM = (int) (width / fireCircleRange) ; // 负数表示平铺
}
if(fireParticleShooters == null){
fireParticleShooters = new FireParticleShooter[NUM];
}
//第一把🔥的位置
float startX = - fireCircleRange * NUM /2 + fireCircleRange/2;
for (int i = 0; i < NUM; i++) {
FireParticleShooter fireParticleShooter = fireParticleShooters[i];
if(fireParticleShooter == null){
fireParticleShooter = new FireParticleShooter();
fireParticleShooters[i] = fireParticleShooter;
//初始化不位置
fireParticleShooter.init((int) (startX + fireCircleRange * i), 0);
}
//绘制
fireParticleShooter.drawAndUpdate(canvas, mPaint);
}
我们现在的效果就完成了
画龙
我们这里的龙使用gif,毕竟涉及复杂的解码操作,因此就从简了,资源如下。
这里我们使用Glide + ImageView实现Gif图片的展示,因为Glide又一个强大的Gif解码引擎。当然,Android 9提供的AnimatedDrawable应该也是可以的,但是不兼容低版本,这里就不使用了
java
Glide.with(imageView).asGif().load(R.mipmap.pic_long).into(imageView);
效果
最终效果就是我们开头的效果了,当然,开头部分是有特效的,我们这里展示一下。
以上整个绘制流程就到此结束了
总结
本篇核心就是定义火焰特效,一直以来很多类似的效果是通过专业软件来做的,实际上专业软件的很多东西也是基于系统api实现,他们能实现的,我们理论上也可以。
附录
这里我们继续延续以前的风格,提供代码供大家参考。
火焰效果源码
下面是火焰效果源码,当然也做了很多封装逻辑
ini
public class FireView extends View {
private TextPaint mPaint;
private DisplayMetrics mDM;
public FireView(Context context) {
this(context, null);
}
public FireView(Context context, AttributeSet attrs) {
super(context, attrs);
mDM = getResources().getDisplayMetrics();
initPaint();
}
private void initPaint() {
//否则提供给外部纹理绘制
mPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mPaint.setAntiAlias(true);
mPaint.setStrokeCap(Paint.Cap.BUTT);
mPaint.setStyle(Paint.Style.FILL);
PaintCompat.setBlendMode(mPaint, BlendModeCompat.PLUS);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
}
FireParticleShooter[] fireParticleShooters = null;
private int fireNum = -1;
private boolean isStart = true;
final float fireCircleRange = 100; //单个火焰范围
public void startFire(int fireNum){
this.fireNum = fireNum;
this.isStart = true;
invalidate();
}
public void startFireFillWidth(){
startFire(-1);
}
public void stopFire(){
isStart = false;
fireParticleShooters = null;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (!isStart || fireNum == 0) {
return;
}
int width = getWidth();
int height = getHeight();
canvas.drawColor(Color.BLACK);
int save = canvas.save();
canvas.translate(width / 2f, height - getPaddingBottom());
int NUM = fireNum; // 火焰数量
if(NUM < 0){
NUM = (int) (width / fireCircleRange) ; // 负数表示平铺
}
if(fireParticleShooters == null){
fireParticleShooters = new FireParticleShooter[NUM];
}
//第一把🔥的位置
float startX = - fireCircleRange * NUM /2 + fireCircleRange/2;
for (int i = 0; i < NUM; i++) {
FireParticleShooter fireParticleShooter = fireParticleShooters[i];
if(fireParticleShooter == null){
fireParticleShooter = new FireParticleShooter();
fireParticleShooters[i] = fireParticleShooter;
//初始化不位置
fireParticleShooter.init((int) (startX + fireCircleRange * i), 0);
}
//绘制
fireParticleShooter.drawAndUpdate(canvas, mPaint);
}
canvas.restoreToCount(save);
postInvalidate();
}
public static int argb(
@IntRange(from = 0, to = 255) int alpha,
@IntRange(from = 0, to = 255) int red,
@IntRange(from = 0, to = 255) int green,
@IntRange(from = 0, to = 255) int blue) {
return (alpha << 24) | (red << 16) | (green << 8) | blue;
}
static class FireParticleShooter {
int count = 50; //粒子数量
List<FireParticle> particles = new ArrayList<>(); //粒子管理
int[] colors = new int[3]; //radial gradient范围
float[] colorStops = new float[]{
0,
0.5f,
1f
};
public void init(float x, float y) {
if (particles.isEmpty()) {
for (int i = 0; i < count; i++) {
particles.add(new FireParticle(x,y));
}
}
}
public void drawAndUpdate(Canvas canvas, Paint paint) {
for (int i = 0; i < particles.size(); i++) {
FireParticle p = particles.get(i);
p.opacity = Math.round(p.remaining_life / p.life * 100) / 100f;
//RadialGradient 火焰模糊
if (p.radius > 0) {
colors[0] = argb((int) (p.opacity * 255f), p.r, p.g, p.b);
colors[1] = argb((int) (p.opacity * 255f), p.r, p.g, p.b);
colors[2] = argb(0, p.r, p.g, p.b);
RadialGradient radialGradient = new RadialGradient(p.location.x, p.location.y, p.radius, colors, colorStops, Shader.TileMode.CLAMP);
paint.setShader(radialGradient);
// mPaint.setAlpha((int) (p.opacity * 255));
// mPaint.setColor(Color.RED);
canvas.drawCircle(p.location.x, p.location.y, p.radius, paint);
}
p.remaining_life--;
p.radius--;
p.location.x += p.speed.x;
p.location.y += p.speed.y;
// 对不符合规则的进行重置
if (p.remaining_life < 0 || p.radius <= 0) {
p.reset();
}
}
}
}
static class FireParticle {
private final float initX;
private final float initY;
public float opacity;
private PointF location;
private int r;
private int g;
private int b;
private float radius;
private float life;
private float remaining_life;
private PointF speed;
FireParticle(float x,float y) {
this.initX = x;
this.initY = y;
init();
}
public void reset() {
init();
}
private void init() {
//计算X,Y、轴速度
this.speed = new PointF(-4.5f + (float) (Math.random() * 8f), -20f + (float) (Math.random() * 10f));
//初始位置
this.location = new PointF(initX, initY);
//随机半径
this.radius = (float) (30 + Math.random() * 55);
//duration - 生存时间
this.life = (float) (20 + Math.random() * 20);
//剩余生存时间
this.remaining_life = this.life;
//colors
this.r = 200 + (int) Math.round(Math.random() * 55);
this.g = (int) Math.round(Math.random() * 155);
this.b = (int) Math.round(Math.random() * 100);
}
}
}
双发射粒子绕环效果
本篇开头的粒子动画,实际上这种动画很多,实现起来也很简单,主要源码如下
ini
public class CircleFireView extends View {
private TextPaint mPaint;
private DisplayMetrics mDM;
public CircleFireView(Context context) {
this(context, null);
}
public CircleFireView(Context context, AttributeSet attrs) {
super(context, attrs);
setClickable(true);
mDM = getResources().getDisplayMetrics();
initPaint();
}
private void initPaint() {
//否则提供给外部纹理绘制
mPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG);
mPaint.setAntiAlias(true);
mPaint.setStrokeCap(Paint.Cap.BUTT);
mPaint.setStyle(Paint.Style.FILL);
PaintCompat.setBlendMode(mPaint, BlendModeCompat.LIGHTEN);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
if (widthMode != MeasureSpec.EXACTLY) {
widthSize = mDM.widthPixels / 2;
}
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
if (heightMode != MeasureSpec.EXACTLY) {
heightSize = widthSize / 2;
}
setMeasuredDimension(widthSize, heightSize);
}
List<Particle> particles = new ArrayList<>();
float angle = 0;
float circleRadius = 20;
float speed = 3.5f;
final int NUM = 300;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int width = getWidth();
int height = getHeight();
float radius = Math.min(width, height) / 2f;
canvas.drawColor(Color.BLACK);
int save = canvas.save();
canvas.translate(width / 2f, height / 2f);
float distance = radius / 2f;
double preRadian = Math.toRadians(angle);
float lx = (float) (distance * Math.cos(preRadian));
float ly = (float) (distance * Math.sin(preRadian));
mPaint.setColor(Color.CYAN);
canvas.drawCircle(lx , ly, circleRadius, mPaint);
if (particles.size() < NUM) {
particles.add(new Particle(lx, ly, circleRadius, 1, speed, speed));
}
double rightRadian = Math.toRadians(angle - 180);
float rx = (float) (distance * Math.cos(rightRadian));
float ry = (float) (distance * Math.sin(rightRadian));
mPaint.setColor(Color.CYAN);
canvas.drawCircle(rx , ry, circleRadius, mPaint);
if (particles.size() < NUM) {
particles.add(new Particle(rx, ry, circleRadius, 1, speed, speed));
}
Iterator<Particle> iterator = particles.iterator();
while (iterator.hasNext()) {
Particle next = iterator.next();
next.draw(canvas, mPaint);
if (next.alpha < 0.05f) {
iterator.remove();
}
}
canvas.restoreToCount(save);
postInvalidate();
angle -= 3.0f;
if (angle <-360f) {
angle = 0;
}
}
static class Particle {
private float alpha;
private float radius;
private int color;
private float x;
private float y;
private float speedx;
private float speedy;
Particle(float x, float y, float radius, float alpha, float spx, float spy) {
this.x = x;
this.y = y;
this.speedx = (float) ((Math.random() - 0.5) * spx);
this.speedy = (float) ((Math.random() - 0.5) * spy);
this.radius = radius;
this.alpha = alpha;
this.color = argb((float) Math.random(), (float) Math.random(), (float) Math.random());
}
void update() {
this.alpha *= 0.97;
this.x += this.speedx;
this.y += this.speedy;
}
void draw(Canvas canvas, Paint paint) {
paint.setColor(color);
paint.setAlpha((int) (this.alpha * 255));
canvas.drawCircle(this.x, this.y, this.radius, paint);
this.update();
}
}
public static int argb(float red, float green, float blue) {
return ((int) (1 * 255.0f + 0.5f) << 24) |
((int) (red * 255.0f + 0.5f) << 16) |
((int) (green * 255.0f + 0.5f) << 8) |
(int) (blue * 255.0f + 0.5f);
}
}
好了,以上是本篇的核心内容
龙年,祝所有人都能腾飞。