-
-
Notifications
You must be signed in to change notification settings - Fork 6
Using The Render Api
ArmorHiderRenderApi is the easy way for another mod to hide, fade or de-glint equipment on its own conditions. You register a predicate, Armor Hider evaluates it while it renders, and the piece disappears, fades or loses its glint when the predicate says so. No mixin, no AhRenderer, no render-pipeline knowledge.
It covers armor slots (head, chest, legs, feet), elytra wings and the off-hand item, per player and per slot.
Experimental. The whole surface is marked
@ApiStatus.Experimentaland carries@since 0.13.0. It is expected to be stable in shape, but the signatures may still change before it settles. Pin the Armor Hider version you build against.
This page is for mod developers. For getting the jar onto your classpath, see Depending On Armor Hider. For everything else the API exposes, see Api Overview.
Api Overview describes the low-level renderer SPI: AhRenderer, AhRenderInterceptionRegistryApi, AhRenderTypeFactory. That is the surface for teaching Armor Hider how to reach something it cannot reach on its own - a modded cosmetic layer, a custom armor renderer, a shader-friendly render type. You implement an interface and take over an interception scope.
ArmorHiderRenderApi is the opposite direction. Armor Hider already knows how to hide the piece; you only want to supply the condition. It sits on top of the same pipeline and feeds into the decision every render path resolves through.
| You want to… | Use |
|---|---|
| Hide/fade/de-glint an already-supported piece on your own condition |
ArmorHiderRenderApi (this page) |
| Make Armor Hider handle a render path it does not know about |
AhRenderer + AhRenderInterceptionRegistryApi
|
| Change the ARGB math or the translucent render types |
AhColorTransformer / AhRenderTypeFactory
|
import de.zannagh.armorhider.client.api.AhRenderRule;
import de.zannagh.armorhider.client.api.ArmorHiderRenderApi;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.player.Player;
// Hide the helmet while the player is sleeping.
AhRenderRule helmet = ArmorHiderRenderApi.hideArmorWhen(EquipmentSlot.HEAD, Player::isSleeping);
// ... later, when your feature is switched off:
helmet.unregister();That is the whole loop: register, keep the handle, unregister. Everything below is refinement.
Nine static registrations cover the common cases. Each returns an AhRenderRule handle and registers at ArmorHiderRenderApi.defaultPriority() (currently 1000).
// Hide
ArmorHiderRenderApi.hideArmorWhen(EquipmentSlot.CHEST, Player::isSleeping);
ArmorHiderRenderApi.hideElytraWhen(Player::isSprinting);
ArmorHiderRenderApi.hideOffhandWhen(Player::isShiftKeyDown);
// Fade - 0.0 invisible to 1.0 opaque, clamped
ArmorHiderRenderApi.setOpacityWhen(EquipmentSlot.LEGS, 0.5f, Player::isSwimming);
ArmorHiderRenderApi.setElytraOpacityWhen(0.3f, Player::isSprinting);
ArmorHiderRenderApi.setOffhandOpacityWhen(0.25f, Player::isSprinting);
// Glint only - opacity untouched
ArmorHiderRenderApi.disableGlintWhen(EquipmentSlot.FEET, Player::isSprinting);
ArmorHiderRenderApi.disableElytraGlintWhen(Player::isSprinting);hideOffhandWhen / setOffhandOpacityWhen are just the slot variants pinned to EquipmentSlot.OFFHAND; use whichever reads better.
None of them takes a priority. That is deliberate: with both a priority and an opacity in scope, a bare 0 literal would be ambiguous at the call site. Priority lives on the builder only.
Every registration above has a *Matching twin that takes a Predicate<AhHideContext> instead:
ArmorHiderRenderApi.hideArmorWhenMatching(EquipmentSlot.CHEST, ctx ->
ctx.stack().is(Items.NETHERITE_CHESTPLATE) && ctx.playerName().startsWith("[AFK]"));The full set is hideArmorWhenMatching, hideElytraWhenMatching, hideOffhandWhenMatching, setOpacityWhenMatching, setElytraOpacityWhenMatching, setOffhandOpacityWhenMatching, disableGlintWhenMatching and disableElytraGlintWhenMatching.
Take the Predicate<Player> form when your condition is about entity state - sneaking, sleeping, riding, a capability you read off the entity. Take the context form when you need any of:
AhHideContext |
What it gives you |
|---|---|
playerName() |
thelive display name Armor Hider keys everything by (rank prefixes and nicks included), never null, never blank |
player() |
the resolved entity,@Nullable - see below |
slot() |
the slot being rendered (elytra rules always reportCHEST) |
stack() |
the real wornItemStack, never null |
isElytra() |
whether this is the wings rather than a chest piece |
config() |
thePlayerConfig that resolved for this player, read-only for rule purposes |
baseOpacity() |
the config-derived opacity for this slot, combat detection included, before any rule ran |
The context form is also the one that still works when the entity is not resolvable. A Predicate<Player> rule resolves the entity by display name against the client level and does not match at all when it cannot be found - out of render distance, already removed, early client startup. If the player name alone is enough for your condition, use the *Matching form and read ctx.playerName().
Context instances are short-lived and only valid inside the predicate call. Do not keep a reference.
rule(slot) and elytraRule() start a fully controlled rule. This is the only place priority and owner can be set.
ArmorHiderRenderApi.rule(EquipmentSlot.LEGS)
.owner(MY_MOD_ID) // for unregisterAll(...)
.priority(ArmorHiderRenderApi.defaultPriority() - 1) // lower = stronger
.opacity(0.5f)
.andDisableGlint()
.whenMatching(ctx -> isStealthed(ctx.player()));
ArmorHiderRenderApi.elytraRule()
.owner(MY_MOD_ID)
.hide()
.when(Player::isSprinting);The stages are:
-
Target -
rule(EquipmentSlot)orelytraRule(). Fixed by the factory method, so a rule can never point somewhere other than where it reads. The targetable slots areHEAD,CHEST,LEGS,FEETandOFFHAND, plus elytra wings viaelytraRule(). Everything else -MAINHAND,BODYandSADDLE- throwsIllegalArgumentExceptionimmediately, because Armor Hider has no render path for those and a rule there could never fire. That is a registration-time programming error, never a per-frame one. -
Modifiers -
.priority(int)and.owner(Object), in any order, both optional. -
Effect -
.hide()(shorthand foropacity(0f)),.opacity(float)or.disableGlint(). Optionally followed by.andDisableGlint()to add glint suppression on top of an opacity effect. -
Condition -
.when(Predicate<Player>)or.whenMatching(Predicate<AhHideContext>). This registers the rule and hands back the handle.
.priority() and .owner() must come before the effect. The effect hands off to AhRenderRuleCondition and there is no way back - rule(HEAD).hide().priority(5) does not compile. Target, effect and condition are all compile-enforced, so a rule missing any of them is a compile error rather than a runtime surprise.
A builder is safe to keep and reuse. Each effect call snapshots the current target, priority and owner into an immutable stage, so a second registration never inherits the first one's effect:
var shared = ArmorHiderRenderApi.rule(EquipmentSlot.CHEST).owner(MY_MOD_ID);
shared.hide().when(a); // opacity 0
shared.disableGlint().when(b); // glint only, does NOT inherit the hide aboveEvery registration returns an AhRenderRule handle:
int priority(); // the priority it was registered at
boolean isRegistered(); // false after unregister, or if Armor Hider dropped it
void unregister(); // idempotentFor mod-level teardown, tag your rules with an owner and drop them all at once. Owners are compared with equals, so a mod-id string works fine:
ArmorHiderRenderApi.unregisterAll(MY_MOD_ID);ArmorHiderRenderApi.unregister(rule) is the static equivalent of rule.unregister() and is a no-op for null or an already-removed rule.
Unregistering takes effect immediately: the piece reverts to the user's configured appearance on the next frame, not on the next re-equip.
Opacity and glint resolve by different rules. This asymmetry is deliberate and it is the part most worth reading twice.
Opacity is priority-banded. Among the matching opacity rules, the strongest priority present decides outright, and only rules at that priority contribute anything. Lower numeric values are stronger, matching the renderer registry and MC-modding convention generally. Within that band, the lowest opacity wins - ties hide most.
The winning opacity replaces the value Armor Hider derived from the user's config (combat detection included). It does not scale it. That means a rule can also make a piece more visible than the user configured - opacity(1.0f) is a legitimate "force this fully opaque" rule.
// priority 100 beats the default 1000, so the LESS hiding rule wins: 0.75, not 0.25.
ArmorHiderRenderApi.rule(FEET).priority(1000).opacity(0.25f).when(always);
ArmorHiderRenderApi.rule(FEET).priority(100).opacity(0.75f).when(always);
// same priority: most hiding wins, 0.3.
ArmorHiderRenderApi.setOpacityWhen(FEET, 0.8f, always);
ArmorHiderRenderApi.setOpacityWhen(FEET, 0.3f, always);Glint ignores priority entirely. It is a plain OR across every matching rule: any matching glint rule suppresses the glint whatever its priority, and nothing can force a glint back on. "Disable" is the only direction a glint rule can express, so banding it would only ever let an unrelated opacity rule silently swallow another mod's glint rule.
The consequence to plan for: a rule that loses the opacity band still contributes its glint. A strong opacity(1.0f).andDisableGlint() rule can win the opacity outright and still have a much weaker rule's glint suppression applied on top of it. Rule glint is also OR'd with the user's own per-slot glint setting, so a rule can never re-enable a glint the user turned off.
User settings beat third-party rules. Rules live inside Armor Hider's own decision path, so they are skipped wherever Armor Hider deliberately renders vanilla:
- the viewer's global kill switch (Armor Hider globally disabled),
- "disable Armor Hider on others", for every player but the viewer,
- a per-player
disableArmorHider, - an item on the user's exclusion list for that slot,
- vanilla skulls in the head slot with "opacity affects hats/skulls" off.
In all of those cases no predicate runs and nothing you registered has any effect. This is by design - the user's configuration is authoritative - and it will be the most common "your integration is broken" report you get.
Armored elytra follows CHEST rules, not hideElytraWhen. hideElytraWhen / setElytraOpacityWhen / disableElytraGlintWhen cover plain elytra wings only. An armored elytra stays on the chest-armor path, because Armor Hider's own config treats it as chest armor rather than as an elytra. Use hideArmorWhen(EquipmentSlot.CHEST, ...) to catch it. See Armored Elytra.
ctx.player() is nullable. Guard it in context predicates. A Predicate<Player> rule simply does not match when the entity cannot be resolved; a Predicate<AhHideContext> rule gets handed the null and will NPE if you dereference it blindly.
A throwing predicate is a non-match, not a crash. Third-party predicates run inside the render loop, so a throw is caught, counted as "did not match" and logged. The rule is only dropped after 5 consecutive throws (AhRenderRuleImpl.MAX_CONSECUTIVE_FAILURES), and any successful evaluation resets the streak to zero. The log line is rate-limited to one per minute per rule rather than emitted once, so a predicate that throws intermittently - and therefore never reaches the streak limit - stays visible in the log instead of misbehaving silently after its first report. That is what keeps a transient unguarded-ctx.player() NPE from costing you your rule for the session. It is still a bug - fix the guard.
Opacity below 0.05 is a full hide. 0.05 is Armor Hider's smallest opacity step (ArmorOpacity.TRANSPARENCY_STEP). Anything in the open interval (0, 0.05) is promoted to hiding the piece outright rather than rendering it very faintly.
Players are identified by display name. playerName(), config resolution and combat lookups all key off the player's live display name, prefixes and nicks included - not the GameProfile name. Two players sharing one display name are genuinely ambiguous and resolve to whichever was iterated last. This matches how Armor Hider identifies players everywhere else, so it cannot be fixed in the rule layer. If your condition is identity-sensitive, use a *Matching variant and compare something stronger off the context.
Predicates run on the render thread, once per player, per slot, per modification. Keep them cheap, non-blocking and free of side effects and heavy allocation. Do not mutate the PlayerConfig you get from ctx.config().
Rules do not latch. Each evaluation re-derives from the user's configured opacity rather than layering onto the previous result, so a predicate that stops matching reverts immediately. With no rules registered, evaluation short-circuits on one volatile read per slot and allocates nothing, so an unused integration costs effectively zero.
ctx.stack() can be empty. It is ItemStack.EMPTY where Armor Hider genuinely has no stack to offer - notably the accessory-slot query, which asks "would this slot be hidden?" without a specific item. It is never null.
Main hand is not targetable. The off hand is the only hand slot the API covers - rule(EquipmentSlot.MAINHAND) throws, see the targetable set above. Armor Hider has no main-hand interceptor and no main-hand opacity in its config, so a main-hand rule could never fire; it is rejected loudly at registration rather than accepted and quietly ignored.
Rules go into a plain static registry, so there is no initialization order to respect: registering before or after Armor Hider's own client init works identically. What matters is that the registry is client-only (de.zannagh.armorhider.client.api), so the class must never be touched on a dedicated server.
The cleanest place is the ArmorHiderInitializer ServiceLoader hook documented in Api Overview. It is discovered by Armor Hider itself, so your class is only ever loaded when Armor Hider is present - a safe soft dependency with no presence check to write. Because that hook is common-side, keep the client-only part behind a side check and in its own class:
public final class MyIntegration implements ArmorHiderInitializer {
@Override
public void onInitializeArmorHider(ArmorHiderApi api) {
if (FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT) {
MyRenderRules.register(); // the only class that imports client API types
}
}
}On NeoForge the equivalent guard is FMLEnvironment.dist.isClient().
If you would rather not use the ServiceLoader hook, register from your own client entrypoint - ClientModInitializer#onInitializeClient on Fabric, the Dist.CLIENT @Mod constructor or FMLClientSetupEvent on NeoForge. Armor Hider's own client init runs from exactly those places (FabricArmorHiderClient and ArmorHiderNeoForgeClient).
Either way, mind the class-loading caveat for a soft dependency: any class loaded unconditionally that so much as mentions an Armor Hider type in an import, field, parameter or return type will throw NoClassDefFoundError when the mod is absent. Keep the integration in its own class and reach it behind a presence check. Depending On Armor Hider covers this, the artifact coordinates and the per-loader mod ids (armor-hider on Fabric, armor_hider on NeoForge) in full.
- Depending On Armor Hider - getting the jar and declaring the dependency
- Api Overview - the rest of the API, including the low-level renderer SPI
- Armored Elytra - why the armored elytra follows chest rules
-
Combat Detection - what feeds into
baseOpacity()beyond the raw config - Configuration - the user-facing settings your rules sit on top of