跳到主要内容

粒子

粒子效果是使单调的方块世界变得丰富的重要方法之一。编写合适的粒子并在需要时生成,可以极大地提升模组的观感。

我们将以MadParticle中的粒子为例,向你讲述如何编写你自己的粒子。


概述

信息

为方便起见,以及降低难度,以下示例代码将会是MadParticle(侧重于强大的灵活性)和Extinguish(侧重于专门的实用性)的混合。

像之前一样,我们先展示出完整代码,然后再来慢慢讲解。在阅读时,你可以暂时跳过以下示例代码中较长的方法。

你可以在GitHub查看MadParticle的完整代码。在编写粒子时,MadParticle的代码可以是非常有用的参考。

以下展示的内容可能在MadParticle的后续更新中有修改。新旧版本应该不会对你学习粒子编写有很大影响。

为了使你在一开始有个比较完整的认识,我们首先介绍各个类及其作用。

  1. 显然,你需要一个Particle类,负责粒子的具体逻辑;
  2. 我们当然还需要指定粒子对应的ParticleType,这样才能知道谁是谁;
  3. 还要一个ParticleOption,这样才能方便粒子生成的消息通过网络传输,以及指令生成;
  4. 一个ParticleProvider,类似一个工厂类,将ParticleOption转换为Particle

当创建一个粒子时,我们是先有一个ParticleOption,再由MC调用对应的Provider来生成Particle实例。

提示

由于粒子与世界其他部分没什么连接,粒子也仅在客户端运算(这样你就不需要考虑同步问题),故粒子逻辑的的自由度非常大。

以下展示的一些修改只是无限可能中的一小部分。

Particle

首先,我们使自己的新粒子需要继承TextureSheetParticle

public class MadParticle extends TextureSheetParticle {
protected SpriteFrom spriteFrom;
protected float rollSpeed = 0.01;
private float scale = 1;
protected final MadParticleOption child;
protected final int bounceTime = 3;
private int bounceCount = 0;
...

private static final double MAXIMUM_COLLISION_VELOCITY_SQUARED = Mth.square(100.0D);
...

public MadParticle(ClientLevel pLevel, SpriteSet spriteSet, SpriteFrom spriteFrom,
double pX, double pY, double pZ, double vx, double vy, double vz,
...) {
super(pLevel, pX, pY, pZ);
this.spriteFrom = spriteFrom;
switch (spriteFrom) {
case AGE -> this.setSpriteFromAge(spriteSet);
default -> this.pickSprite(spriteSet);
}
...
}

@Override
public ParticleRenderType getRenderType() {
return ParticleRenderType.PARTICLE_SHEET_TRANSLUCENT;
}

@Override
public void tick() {
if (this.age++ >= this.lifetime) {
this.remove();
} else {
normalTick();
}
}

private void normalTick() {
this.xo = this.x;
this.yo = this.y;
this.zo = this.z;
//alpha
this.alpha = ...;
//size
if (endScale != beginScale) {
float newScale = scaleMode.lerp(beginScale, endScale, age, lifetime);
this.scale(1 / scale * newScale);
scale = newScale;
}
//sprite
if (this.spriteFrom == SpriteFrom.AGE) {
setSpriteFromAge(sprites);
}
//interact with Entity
LivingEntity entity = level.getNearestEntity(LivingEntity.class, TargetingConditions.forNonCombat().range(4), null, x, y, z, this.getBoundingBox().inflate(0.7));
if (entity != null) {
Vec3 v = entity.getDeltaMovement();
this.xd += v.x * random.nextFloat() * horizontalInteractFactor;
double y0;
if (entity.onGround()) {
y0 = Math.max(Math.abs(v.y), Math.sqrt(v.x * v.x + v.z * v.z));
} else {
y0 = v.y;
}
y0 *= verticalInteractFactor;
if (y0 > 0) {
this.onGround = false;
}
this.yd += (v.y < 0 ? -y0 : y0);
this.zd += v.z * random.nextFloat() * horizontalInteractFactor;
this.gravity = beginGravity;
this.friction = frictionInitial;
}
//gravity
this.yd -= 0.04 * (double) this.gravity;
//move
this.move(this.xd, this.yd, this.zd);
this.xd *= this.friction;
this.yd *= this.friction;
this.zd *= this.friction;
//roll
this.oRoll = this.roll;
if (!this.onGround) {
this.roll -= (float) Math.PI * rollSpeed * 2.0F;
}
}

@SuppressWarnings("AlibabaAvoidDoubleOrFloatEqualCompare")
@Override
public void move(double pX, double pY, double pZ) {
double x0 = pX;
double y0 = pY;
double z0 = pZ;
double r2 = pX * pX + pY * pY + pZ * pZ;
if (collision && this.hasPhysics && (pX != 0.0D || pY != 0.0D || pZ != 0.0D) && r2 < MAXIMUM_COLLISION_VELOCITY_SQUARED) {
Vec3 vec3 = Entity.collideBoundingBox((Entity) null, new Vec3(pX, pY, pZ), this.getBoundingBox(), this.level, List.of());
pX = vec3.x;
pY = vec3.y;
pZ = vec3.z;
}
if (pX != 0.0D || pY != 0.0D || pZ != 0.0D) {
this.setBoundingBox(this.getBoundingBox().move(pX, pY, pZ));
this.setLocationFromBoundingbox();
}
if (collision) {
//hit XOZ
if (y0 != pY) {
if (bounceCount < bounceTime) {
Vec2 v = horizontalRelativeCollision(r2, xd, zd);
this.xd = v.x;
this.yd = -y0 * (random.nextDouble() * verticalRelativeCollisionBounce);
this.zd = v.y;
updateAfterCollision();
} else {
this.gravity = 0;
this.onGround = true;
}
this.friction = afterCollisionFriction;
return;
}
//hit YOZ
if (x0 != pX) {
if (bounceCount < bounceTime) {
Vec2 v = horizontalRelativeCollision(r2, yd, zd);
this.xd = -x0 * (random.nextDouble() * verticalRelativeCollisionBounce);
this.yd = v.x;
this.zd = v.y;
updateAfterCollision();
}
this.friction = afterCollisionFriction;
return;
}
//hit XOY
if (z0 != pZ) {
if (bounceCount < bounceTime) {
Vec2 v = horizontalRelativeCollision(r2, xd, yd);
this.xd = v.x;
this.yd = v.y;
this.zd = -z0 * (random.nextDouble() * verticalRelativeCollisionBounce);
updateAfterCollision();
}
this.friction = afterCollisionFriction;
return;
}
}
}

private void updateAfterCollision() {
bounceCount++;
this.gravity = afterCollisionGravity;
}

public Vec2 horizontalRelativeCollision(double r2, double d1, double d2) {
if (horizontalRelativeCollisionDiffuse == 0) {
return new Vec2(0, 0);
}
//generalLoss controls radius of spread circle.
r2 *= horizontalRelativeCollisionDiffuse;
float r = (float) Math.sqrt(r2);
float a = (float) Math.random() * r * (random.nextBoolean() ? -1 : 1);
float b = (float) Math.sqrt(r2 - a * a) * (random.nextBoolean() ? -1 : 1);
//lose energy/speed when bouncing to different directions.
//lose less speed when going forward. lose more speed when going backward.
float d = (float) Math.sqrt((d1 - a) * (d1 - a) + (d2 - b) * (d2 - b));
float directionalLoss = 1 - d / (2 * r) * MAX_DIRECTIONAL_LOSS;
return new Vec2((float) (a * directionalLoss * Math.random()), (float) (b * directionalLoss * Math.random()));
}

@Override
public void remove() {
super.remove();
if (this.child != null) {
AddParticleHelper.addParticle(child.inheritOrContinue(this));
}
}

@Override
protected int getLightColor(float pPartialTick) {
int unmodified = super.getLightColor(pPartialTick);
int light = 15;
return unmodified & 0b00000000_11110000_00000000_00000000 | light << 4;
}

public static class Provider implements ParticleProvider<MadParticleOption> {
private final SpriteSet sprites;

public Provider(SpriteSet pSprites) {
this.sprites = pSprites;
}

@Nullable
@Override
public Particle createParticle(MadParticleOption op, ClientLevel pLevel, double pX, double pY, double pZ, double pXSpeed, double pYSpeed, double pZSpeed) {
return new MadParticle(pLevel, sprites, op.spriteFrom(),
pX, pY, pZ, pXSpeed, pYSpeed, pZSpeed,
...);
}
}
}

你可以看到,我们首先为一些特定功能增加了一堆字段。

在初始化时,除了满足父类的构造方法,对粒子最重要的之一就是使用setSpriteFromAge(根据age选择)或者pickSprite(随机选择)来选择材质。

提示

如果粒子需要一些初始化操作,你可以向粒子的构造方法传入需要的参数,在其中进行初始化;也可以在下文即将提到的Provider#createParticle方法中先初始化再返回。你可以选一个你喜欢的方式。

像实体一样,大多数主动逻辑都将在tick里执行。在这里为方便起见,我们将实际执行的部分拆出来成为normalTick

会有抽象方法getRenderType要我们实现。现阶段直接返回ParticleRenderType.PARTICLE_SHEET_TRANSLUCENT就可以。如果粒子确定会是永远不透明的,那PARTICLE_SHEET_OPAQUE也不错。

tick

各个逻辑部分已由代码中的简单注释表明。

  • 一开始是更新粒子的位置缓存——显然这是为了粒子的平滑运动。

  • 然后是一些附加功能的逻辑,改变粒子的透明度和大小。

警告

改变子大小的scale方法用的是*=来实现——这意味着你在需要多次改变大小时尤其注意。

  • 如果粒子的材质需要根据时间而变化,我们需要使用setSpriteFromAge

  • 接下来我们想要粒子被周围的实体运动所影响,就像实体路过时带起一阵风一样。

    • 使用level.getNearestEntity来获取最近的实体,然后根据实体的运动速度来计算粒子受影响的程度;
    • entity.onGround()分别处理,这样就能让实体在地上运动时也能产生一些垂直方向的扰动;
    • 我们使用了verticalInteractFactorhorizontalInteractFactor来灵活地控制具体程度大小;
  • 接下来计算中重力对运动的影响。

  • 然后使用move执行以上种种算出来的运动。

信息

以上各个部分通常不需要特别讲究前后顺序,你可以先move再算摩擦力,也可以一上来就算玩家的扰动。

前后顺序的不同确实会对粒子的实际效果会有微妙的影响,但通常这种影响没有达到必须考虑它的程度——出了问题再说吧。

  • 最后我们给粒子加上了自转。

move

为了在碰撞后执行特殊逻辑,我们重写了move方法。

警告

我们在这里使用了自己的collision字段,而原版提供的字段是stoppedByCollision,请注意区分。

重写后的move方法前半部分并未改变,还是对碰撞的计算。

计算完成后,你可以看到我们有三段注释,分别对应粒子撞到各个平面时的逻辑:

  • 我们使用了bounceCountbounceTime来控制粒子最多发生碰撞的次数,以此来减少性能消耗。
    • 要是碰撞次数上限已经达到,我们就让天花板和地板上的粒子不再下落,而墙壁上的粒子随墙下落。
  • 通过比较原来的y0和碰撞之后算得的pY的大小(这里有一些不好的形参重赋值,已在MadParticle更新中改进),我们能够知道粒子是否撞上水平面,进一步的判断也能让我们知道是撞上了地板还是天花板。
  • 在确认撞上之后,我们可以在这里做任何我们想要的工作,而此处是一个基于能量守恒但守恒不多的非完全弹性碰撞算法:
    • 在垂直碰撞平面的方向上,我们使用简单的反弹;
    • 在水平方向上,稍复杂的horizontalRelativeCollision会根据入射方向,使反射方向更有可能倾向于继续入射方向,而更少地向后方反弹(想象一个斜水柱冲到地面,形成的层流区域的样子)。

其他

我们随后重写了remove方法,用于在消失时生成子粒子(MadParticle的特性);

为了点亮粒子,使粒子不论环境光照如何都保持明亮,我们重写了getLightColor,修改了返回值中的方块光部分。

信息

getLightColor返回值的高二字节高四位是0-15的天空光,低首字节的高四位是0-15的方块光。

提示

你需要了解原版父类TextureSheetParticleParticle提供的字段,对应的默认值/初始值和作用。有些时候你不需要自己写复杂的逻辑,只需要控制对应的字段值就行。

Provider

如你在上面的代码中见到的,ParticleProvider是一个简单的工厂类,继承ParticleProvider<ParticleOption>,在createParticle方法中实例化新粒子。

ParticleOption

ParticleOption本质上是一个存储生成粒子所需数据的容器。由于MadParticle所需要的参数非常多,我们在这里使用了record的形式。

public record MadParticleOption(int targetParticle, SpriteFrom spriteFrom, int lifeTime,
InheritableBoolean alwaysRender, int amount,
double px, double py, double pz, double xDiffuse, double yDiffuse, double zDiffuse,
double vx, double vy, double vz, double vxDiffuse, double vyDiffuse, double vzDiffuse,
float friction,...

) implements ParticleOptions {
public static final Deserializer<MadParticleOption> DESERIALIZER = new Deserializer<MadParticleOption>() {
@Override
public MadParticleOption fromCommand(ParticleType<MadParticleOption> pParticleType, StringReader pReader) throws CommandSyntaxException {
return null;
}

@Override
public MadParticleOption fromNetwork(ParticleType<MadParticleOption> pParticleType, FriendlyByteBuf buf) {
int targetParticle = buf.readInt();
SpriteFrom spriteFrom = buf.readEnum(SpriteFrom.class);
int lifeTime = buf.readInt();
InheritableBoolean alwaysRender = buf.readEnum(InheritableBoolean.class);
int amount = buf.readInt();
double px = buf.readDouble(), py = buf.readDouble(), pz = buf.readDouble();
double xDiffuse = buf.readDouble(), yDiffuse = buf.readDouble(), zDiffuse = buf.readDouble();
double vx = buf.readDouble(), vy = buf.readDouble(), vz = buf.readDouble();
double vxDiffuse = buf.readDouble(), vyDiffuse = buf.readDouble(), vzDiffuse = buf.readDouble();
float friction = buf.readFloat();
...
return new MadParticleOption(targetParticle, spriteFrom, lifeTime, alwaysRender, amount,
px, py, pz, xDiffuse, yDiffuse, zDiffuse, vx, vy, vz, vxDiffuse, vyDiffuse, vzDiffuse,
friction,...);
}
};

@Override
public void writeToNetwork(FriendlyByteBuf buf) {
buf.writeInt(targetParticle);
buf.writeEnum(spriteFrom);
buf.writeInt(lifeTime);
buf.writeEnum(alwaysRender);
buf.writeInt(amount);
buf.writeDouble(px);
buf.writeDouble(py);
buf.writeDouble(pz);
buf.writeDouble(xDiffuse);
buf.writeDouble(yDiffuse);
buf.writeDouble(zDiffuse);
buf.writeDouble(vx);
buf.writeDouble(vy);
buf.writeDouble(vz);
buf.writeDouble(vxDiffuse);
buf.writeDouble(vyDiffuse);
buf.writeDouble(vzDiffuse);
buf.writeFloat(friction);
...
}

@Override
public @NotNull ParticleType<?> getType() {
return ModParticleRegistry.MAD_PARTICLE.get();
}

@Override
public @NotNull String writeToString() {
return "MadParticle";
}
}

我们首先需要创建一个从命令行和网络包中读取信息的Decoder,在这里是一个Deserializer<MadParticleOption>对象DESERIALIZER

DESERIALIZER需要实现的两个方法非常明了:一个用于从命令行中解析出ParticleOption,另一个负责从网络包中解析。如果你的目的并不在于使用/particle来召唤粒子,那你可以直接返回null;如果你希望进行解析,那你就需要慢慢读取参数StringReader里面的内容进行解析。

fromNetwork方法则是必要的,其写入数据的过程与网络包中的@Decoder是相同的。

回到ParticleOption本身。writeToNetwork方法与网络包中的@Encoder是相同的。

getType需要返回对应的ParticleType,我们接下来会提到。

writeToString填入粒子名字就行。

ParticleType

这是一个非常简单的例子:

public class MadParticleType extends ParticleType<MadParticleOption> {
public MadParticleType() {
super(false,MadParticleOption.DESERIALIZER);
}

@Override
public Codec<MadParticleOption> codec() {
return null;
}
}

没什么好说的。


基础设施建设完成!下面投入使用吧。

注册

我们需要先注册ParticleType

public class ModParticleRegistry {
public static final DeferredRegister<ParticleType<?>> PARTICLE_TYPES = DeferredRegister.create(ForgeRegistries.PARTICLE_TYPES, MadParticle.MOD_ID);

public static final RegistryObject<ParticleType<MadParticleOption>> MAD_PARTICLE = PARTICLE_TYPES.register("mad_particle", MadParticleType::new);
}

记得在mod主类注册PARTICLE_TYPES

然后注册Provider

@Mod.EventBusSubscriber(bus = Mod.EventBusSubscriber.Bus.MOD)
public class ModParticleProviderRegistry {
@SubscribeEvent
public static void onParticleProviderRegistry(RegisterParticleProvidersEvent event) {
event.registerSpriteSet(ModParticleRegistry.MAD_PARTICLE.get(), MadParticle.Provider::new);
}
}

资源文件

你需要在resources > assets > modid > particles下创建与注册名对应的json文件:

mad_particle.json:

{
"textures": [
]
}

由于MadParticle本身并没有独特的材质,所以textures中留空。对于一般情况,像方块或物品一样填入材质路径即可,记得在对应位置放上你的材质。

使用

使用Particle有两种选择:直接使用ServerLevel.sendParticles方法,或是自己编写网络包,向需要看到粒子的人发包,传输ParticleOption,然后使用ClientLevel.addParticle方法添加粒子。

这里就可以要用到上面编写的DESERIALIZER了:

@NetPacket(modid = MadParticle.MOD_ID)
public class MadParticlePacket {
private final MadParticleOption particleOption;

public MadParticlePacket(MadParticleOption particleOption) {
this.particleOption = particleOption;
}

@Decoder
public MadParticlePacket(FriendlyByteBuf buf) {
this.particleOption = MadParticleOption.DESERIALIZER.fromNetwork(ModParticleRegistry.MAD_PARTICLE.get(), buf);
}

@Encoder
public void write(FriendlyByteBuf buf) {
particleOption.writeToNetwork(buf);
}

@Consumer
public void handler(Supplier<NetworkEvent.Context> context) {
if (context.get().getDirection().equals(NetworkDirection.PLAY_TO_SERVER)) {

} else {
clientHandler();
}
}

@OnlyIn(Dist.CLIENT)
private void clientHandler() {
Minecraft.getInstance.level.addParticle(particleOption, ...);
}
}

奇奇怪怪的注意事项

性能问题

如果你想在粒子中实现比渲染出来多一点点的逻辑,都不要忘记考虑性能问题。单个粒子可能只需要很少时间就能完成tick,但当粒子数量达到一万个甚至十万个时,其性能消耗就会变得非常可观。

以下有一些可行的办法:

  • 在生成时大概估算好数量,尽可能控制复杂逻辑粒子的数量;
  • 为粒子的每一个逻辑部分都增加开关,只在需要的时候开启,不需要时关闭——让CPU判断一万次truefalse比算一万次碰撞快多了;
  • 异步执行复杂逻辑——比如碰撞计算什么的。反正一般粒子花点时间在线程同步上也无所谓。

同步问题

也不要忘了粒子是只在客户端存在的:

  • 如果你的粒子想要对世界造成影响,比如Extinguish中的粒子灭火,那你就需要发包告诉服务器这个影响,这样其他玩家才能知道发生的改动。
    • 记得要在服务端校验影响的合理性以避免恶意攻击。
  • 需要考虑的另一点:如果你引入了随机源,例如在生成位置或者速度计算时,那每个玩家看到的粒子都会有细微差异。