Fabric API

Fabric API

106M Downloads

API/Utilities for Particle creation and registration

kgscialdone opened this issue ยท 15 comments

commented

As it currently stands, creating a custom Particle is... quite the ordeal. There are several steps involved in doing so:

  • Creating the Particle subclass (client)
  • Registering any required sprites (client)
  • Registering the appropriate ParticleType (client/server)
  • Registering a ParticleFactory (client)
  • Actually spawning the particle (client)

Of these 5, only registering the ParticleType is straightforward, and even that can't be easily understood by looking at how Vanilla handles particles. Thus, I'd like to propose a Fabric wrapper around the particle system to simplify this process.

Simple Steps - Particle and ParticleType

Creating a custom Particle is almost as straightforward and easy as a Block or Item as long as you don't need to do anything particularly fancy with it. The trouble only really comes when you try to apply a custom texture. It's almost as simple as this:

@Environment(EnvType.CLIENT)
public class TestParticle extends SpriteBillboardParticle {
    static final Identifier sprite = new Identifier("particletest:particle/test_particle");

    public TestParticle(World world, double x, double y, double z) { this(world, x, y, z, 0, 0, 0); }
    public TestParticle(World world, double x, double y, double z, double vx, double vy, double vz) {
        super(world, x, y, z, vx, vy, vz);
        this.setSprite(MinecraftClient.getInstance().getSpriteAtlas().getSprite(sprite));
    }

    public ParticleTextureSheet getType() { return ParticleTextureSheet.PARTICLE_SHEET_OPAQUE; }
}

Registering the ParticleType is similarly straightforward, albeit extremely obtuse:

public class ParticleTestCommon implements ModInitializer {
    public static DefaultParticleType testParticleType = new DefaultParticleType(false){};
    public void onInitialize() {
        Registry.register(Registry.PARTICLE_TYPE, "particletest:test_particle", testParticleType);
    }
}

Problem 1 - Registering ParticleFactorys

ParticleFactory registry, on the other hand, is quite the ordeal. There is no Registry type for ParticleFactorys, and all the relevant methods on ParticleManager are private. The only solution I've found is a client-side mixin:

@Mixin(ParticleManager.class)
public abstract class MixinParticleManager {
    @Shadow
    private <T extends ParticleEffect> void registerFactory(ParticleType<T> pt, ParticleFactory<T> pf) {}

    @Inject(method = "net/minecraft/client/particle/ParticleManager.registerDefaultFactories()V", at = @At("RETURN"))
    private void registerCustomFactories(CallbackInfo cbi) {
        this.registerFactory(ParticleTestCommon.testParticleType, (pt, world, x, y, z, vx, vy, vz) -> {
            return new TestParticle(world, x, y, z, vx, vy, vz);
        });
    }
}

We can then spawn our particle as follows:

world.addParticle(testParticleType, x, y, z, 0, 0, 0);

Or, if we need to pass custom properties of some kind, we can bypass the ParticleFactory altogether (keep in mind that this will make your particle incompatible with or feature-limited when spawned via the /particle command - this should really be done through a custom ParticleType rather than bypassing ParticleFactory but I haven't messed with that yet so I can't give examples):

MinecraftClient.getInstance().particleManager.addParticle(new TestParticle(world, x, y, z, vx, vy, vz, ...));

This works passably, but as we'll see in just a moment, it comes with an inherent and deadly problem.

Problem 2 - Registering Textures

Let's get the biggest problem out of the way now - registering custom particle textures is nearly if not impossible to do "Vanilla-esque". There are two separate levels involved; the client texture registry, and a modification to how your ParticleFactory is registered. Registering the texture with the client is (theoretically) simple, via ClientSpriteRegistryCallback:

@Environment(EnvType.CLIENT)
public class ParticleTestClient implements ClientModInitializer {
    public void onInitializeClient() {
        ClientSpriteRegistryCallback.EVENT.register((atlas, registry) -> {
            if(atlas == MinecraftClient.getInstance().getSpriteAtlas()) {
                registry.register(TestParticle.sprite);
            }
        });
    }
}

However, the problem comes when we try to update our ParticleFactory to properly use this texture. Registering a ParticleFactory that can use this texture requires a SpriteProvider, which uses a different version of ParticleManager#registerFactory. Unfortunately, ParticleManager uses two package-private inner classes in the process of this version of the registry method: SimpleSpriteProvider and class_4091 (henceforth called ParticleFactoryFactory for simplicity, because that's literally what it is). It's impossible to shadow the proper version of registerFactory, because it uses ParticleFactoryFactory in its signature. It's also impossible to shadow the maps that registerFactory saves the registered information to and create our own registerFactory method, since one of those maps has SimpleSpriteProvider in its signature. As of the time of writing, I have been unable to find any alternative method that uses the built-in particle sprite registry.

The Workaround

I have, however, found one workaround - skipping the vanilla particle texture registry altogether. This reduces the process to only 4 steps:

  • Creating the Particle subclass (client)
  • Registering the appropriate ParticleType (client/server)
  • Registering a ParticleFactory (client)
  • Actually spawning the particle (client)

Since we no longer need to use the vanilla sprite registry, there's no need to use the more complex version of ParticleManager#registerFactory, and we bypass the issue of the package-private inner classes altogether. There are a couple of other changes that come along with this:

  • The client-side entrypoint is no longer needed
  • Our custom Particle needs to extend BillboardParticle instead of SpriteBillboardParticle (cutting one link out of the inheritance chain), which will require a couple of extra abstract method implementations
  • We need to manually acquire and bind our particle texture when we're ready to draw our particle

This means the following changes to our Particle class:

@Environment(EnvType.CLIENT)
public class TestParticle extends /*Sprite*/BillboardParticle { // <-- HERE
    static final Identifier sprite = new Identifier("particletest:textures/particle/test_particle.png"); // <-- HERE

    public TestParticle(World world, double x, double y, double z) { this(world, x, y, z, 0, 0, 0); }
    public TestParticle(World world, double x, double y, double z, double vx, double vy, double vz) {
        super(world, x, y, z, vx, vy, vz);
//        this.setSprite(MinecraftClient.getInstance().getSpriteAtlas().getSprite(sprite)); // <-- HERE
    }

    public ParticleTextureSheet getType() { return ParticleTextureSheet.PARTICLE_SHEET_OPAQUE; }

    // v ADDED METHODS v

    public void buildGeometry(BufferBuilder var1, Camera var2, float var3, float var4, float var5, float var6, float var7, float var8) {
        MinecraftClient.getInstance().getTextureManager().bindTexture(texture);
        super.buildGeometry(var1, var2, var3, var4, var5, var6, var7, var8);
    }

    protected float getMinU() { return 0; }
    protected float getMaxU() { return 1; }
    protected float getMinV() { return 0; }
    protected float getMaxV() { return 1; }
}

This works perfectly fine. We can revert our MixinParticleManager to before we tried to shadow the more complex overload of registerFactory, and spawn our particles into the world the same way.

Proposed API

I would personally propose the following additions to Fabric's API:

  • A FabricParticle class which can be extended rather than extending BillboardParticle directly, which implements the basic method changes like getMin/MaxU/V and buildGeometry and makes the texture location a superctor parameter (potentially simplified from the full path needed?)
    • Any particles which are more complicated than BillboardParticle is set up for will need to do this for themselves, but currently all Vanilla particles except the following extend BillboardParticle at least indirectly, so these cases should be rare.
      • ElderGuardianAppearanceParticle
      • EmitterParticle
      • ExplosionEmitterParticle
      • FireworksSparkParticle and FireworkParticle
      • ItemPickupParticle
      • Some of the base "extend me" particle classes that aren't actual ingame particles on their own
  • A FabricParticleTypeRegistry and/or FabricParticleFactoryRegistry which can be publicly accessed
    • The former would be for registering ParticleTypes - DefaultParticleType is almost always sufficient for this, but it would be trivial to add an overload that accepts a manually-constructed ParticleType. The registry method would take an identifier and optional custom ParticleType, call Registry.register(PARTICLE_TYPE, ...), and return the registered ParticleType so it could be saved for later use.
      • Note that while this is not strictly necessary for the API to function, it would smooth the process of understanding how to register a particle significantly, as it cuts out the middle man of understanding where you're supposed to get a ParticleType and how to construct a trivial one for simple particles.
    • The latter would be for registering ParticleFactorys. The registry method would take a ParticleType (usually from the above method) and a lambda for the factory body, and store them in a temporary map.
  • A mixin to ParticleManager#registerDefaultFactories, as done manually above, that accesses the temporary map from FabricParticleFactoryRegistry and registers all of the contained factories with ParticleManager#registerFactory.
  • Potentially a simplified way of creating custom ParticleTypes?

These additions would drastically simplify the process of creating a custom particle, allowing modders to bypass potential hours of digging through Vanilla's code before realizing that they can't actually register a particle the same way Vanilla does.

commented

I'm having 3 problems...

  1. getting errors for super.buildGeometry variables 1 - 8: "buildGeometry(net.minecraft.client.render.VertexConsumer, net.minecraft.client.render.Camera, float)' in 'net.minecraft.client.particle.BillboardParticle' cannot be applied to '(net.minecraft.client.render.BufferBuilder, net.minecraft.client.render.Camera, float, float, float, float, float, float"

  2. java.lang.IllegalStateException: Failed to load description for particle endmod:end_bubble

  3. java.io.FileNotFoundException: endmod:particles/end_bubble.json

commented

@Shadowhunter22 This issue is extremely outdated and none of the code in it is relevant to current Fabric development. It was replaced by a PR in #264, which itself was superceded by #341, which has been merged for almost 2 years now. If you're trying to use the code from this issue, don't. If you're not, you're in the wrong place.

commented

Thank you for the detailed write-up, by the way! This is probably the first issue/PR in Fabric where I didn't have to go back to the source code to understand the problem domain.

commented

Shouldn't you be able to use fabric-textures to register on the particle sheet already? (The tough part is detecting that specific sheet, but I'm not too worried)

My proposal differs a bit:

  • A FabricParticleType class, extending ParticleType, which requires you to specify your own ParticleFactory (and registers it for you!) in fabric-object-builders
  • Adding additional methods to fabric-textures to detect the particle sheet
  • A simplified variant, letting you register Identifiers for textures right in the FabricParticleType.
commented

I hadn't seen fabric-textures, I'll go through and test that later today/tomorrow. Assuming that works, though, there are a couple other issues with your reproposal:

  • ParticleFactory is a client side concept, while ParticleType is both. It would significantly complicate the process to mix the two directly.
  • In Vanilla, textures are specified (indirectly via SpriteProvider) in the ParticleFactory for all particles that use the particle texture atlas. Why would that move to ParticleType?
  • Custom ParticleTypes are necessary for purposely handling additional required data. This kind of leaves people who need that out in the cold.

The only way I could really see your version working is if we completely hide the concept of a ParticleFactory from modders and replace it with a lambda returning a constructed Particle. We'd then have to, on the client side only, wrap that lambda in a proper ParticleFactory to provide sprite data, and register that factory silently in the background. If that lambda ever leaks to the server side and gets called, we're screwed and the game crashes, because Particle doesn't exist on the server side. This still leaves custom ParticleTypes out in the cold a bit, but is perhaps feasible.

This also doesn't really touch on Particles themselves - the actual Particle class, while simple once understood, is a nightmare to figure out how to extend by looking at Vanilla. Would it be feasible to have a FabricSimpleParticle or similar that can be more painlessly extended than SpriteBillboardParticle, perhaps by passing the aforementioned FabricParticleType to the superctor to provide sprite location info?

commented

Ah, I see. ParticleFactoryRegistry it would be, then.

commented

Actually, on second thought that may be better anyways. I'm sure there's a best solution we haven't thought of yet, but an @Enviroment(Env.CLIENT) getter for a private mapping of ParticleTypes to factory bodies would more or less solve the side conflict issues. Removing the idea of an overarching ParticleFactory system from the front end would definitely simplify understanding how to do particles for modders. At that point, our only major issues are Particle and custom ParticleTypes, both of which are certainly feasible to design the API to handle. And since the only server-side reference to the Particle class would be wrapped in a lambda that never gets called on the server side, we'd be golden in terms of side safety (if potentially slightly confusing when you look closely).

commented

Is PR #195 an adequate solution for Problem 2? If so, that leaves Problem 1.

commented

I need to do a couple tests, but at a glance it seems like it should solve the sprite registry side of problem 2. However, it's still not possible to register a sprite factory that properly makes use of said sprite, at least in the same way Vanilla does - that would require the ParticleManager mixin to shadow either a method with ParticleFactoryFactory in its signature or a field with SimpleSpriteProvider in its signature, both of which are package-private. Bypassing the client sprite registry is just a side effect of the workaround, really, not the goal in itself.

commented

OK, thank you!

commented

After extensive testing, I finally made a breakthrough and figured out what was happening to prevent sprites from loading when not passed through the whole ParticleFactory/SpriteProvider chain. This means that sprites can now be registered and used as expected, rather than using the above workaround.

The problem turned out to be a lack of access to ParticleManager#particleAtlasTexture. The method I was using to get a SpriteAtlasTexture before was MinecraftClient.getInstance().getSpriteAtlas(), which returns the block atlas texture rather than the particle atlas texture. We should definitely still wrap this in an API module to make it more intuitive, but it should be much simpler to wrap now.

Below is the test code I used to achieve this, which will need to be cleaned up and reorganized for a full API addition, and should probably be abstracted away behind a SpriteBillboardParticle subclass.

@Environment(EnvType.CLIENT)
public class ParticleTestClient implements ClientModInitializer {
    public void onInitializeClient() {
        // This throws a couple nonfatal errors, should replace with sheet-specific code from #195
        ClientSpriteRegistryCallback.EVENT.register((atlas, registry) -> {
            registry.register(TestParticle.sprite);
        });
    }
}

public interface ParticleManagerHooks {
    public SpriteAtlasTexture getParticleAtlasTexture();
}

@Mixin(ParticleManager.class)
public abstract class MixinParticleManager implements ParticleManagerHooks {
    // Omitted - the registerFactory code from earlier in the proposal

    @Shadow
    private SpriteAtlasTexture particleAtlasTexture;
    public SpriteAtlasTexture getParticleAtlasTexture() { return particleAtlasTexture; }
}

@Environment(EnvType.CLIENT)
public class TestParticle extends SpriteBillboardParticle {
    static final Identifier sprite = new Identifier("particletest:test_particle");

    public TestParticle(World world, double x, double y, double z) { this(world, x, y, z, 0, 0, 0); }
    public TestParticle(World world, double x, double y, double z, double vx, double vy, double vz) {
        super(world, x, y, z, vx, vy, vz);

        SpriteAtlasTexture sat = ((ParticleManagerHooks)MinecraftClient.getInstance().particleManager).getParticleAtlasTexture();
        this.setSprite(sat.getSprite(this.sprite));
    }

    public ParticleTextureSheet getType() { return ParticleTextureSheet.PARTICLE_SHEET_OPAQUE; }
}
commented

Well... yes.

I'm glad we don't have to replicate the SpriteProvider system, at least.

commented

Yes, I know I'm blind. That said, it's not exactly like there were any other visibly relevant methods ๐Ÿ˜›

Either way, this and #195 resolves problem 2 entirely. Problem 1 should be trivial to wrap the API around, so that leaves us with the following (imo):

  • Create an API for easily creating/registering ParticleFactorys and ParticleTypes
  • Implement a SpriteBillboardParticle subclass that abstracts away the process of assigning the sprite

I need to look more into how custom ParticleTypes work, but they don't seem particularly complicated all things considered, so they should be trivial to either support directly in the API or allow for using them by bypassing the createParticleType (or w/e) method.

commented

Closing this now that #264 is open.

commented

I'd say leave this open until it's merged