From 0fe15fcbe1e0305ab6c335fdfb1b02fb41afb791 Mon Sep 17 00:00:00 2001 From: imonlyzuzu Date: Wed, 22 Jul 2026 15:07:56 +0300 Subject: [PATCH 1/5] perf: scale balloon smoothing for 100+ players - Add balloon-wearer registry so BalloonSmoothingTask iterates only balloon owners instead of every online user. - Default balloon-lerp-period 1 -> 2 (period-corrected factors keep follow feel; halves teleport rate). - Teleport dedup at the lerp tail: skip entity teleport + lead move when the rendered step is sub-epsilon (lastRendered tracking). - Fix balloon freeze/leash-desync when balloon-lerp-period <= 0: dispatchUpdate now snaps model and moves the lead pufferfish. - Add /hmccosmetics stresstest synthetic load generator (op-gated, renders the caller's equipped balloon; cleaned on disable). --- .../hmccosmetics/HMCCosmeticsPlugin.java | 36 +++ .../hmccosmetics/command/CosmeticCommand.java | 40 +++ .../hmccosmetics/config/Settings.java | 95 ++++++- .../cosmetic/types/CosmeticBalloonType.java | 54 +--- .../listener/PlayerMovementListener.java | 5 +- .../hmccosmetics/user/CosmeticUser.java | 9 +- .../hmccosmetics/user/CosmeticUsers.java | 43 +++ .../user/manager/BalloonSmoothingTask.java | 269 ++++++++++++++++++ .../user/manager/UserBalloonManager.java | 57 ++++ .../user/manager/UserWardrobeManager.java | 5 +- .../hmccosmetics/util/BalloonStressTest.java | 186 ++++++++++++ .../hmccosmetics/util/MathUtil.java | 25 ++ common/src/main/resources/config.yml | 49 +++- gradlew | 0 14 files changed, 823 insertions(+), 50 deletions(-) create mode 100644 common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java create mode 100644 common/src/main/java/com/hibiscusmc/hmccosmetics/util/BalloonStressTest.java create mode 100644 common/src/main/java/com/hibiscusmc/hmccosmetics/util/MathUtil.java mode change 100644 => 100755 gradlew diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/HMCCosmeticsPlugin.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/HMCCosmeticsPlugin.java index 8c76b3f46..b6e1df236 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/HMCCosmeticsPlugin.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/HMCCosmeticsPlugin.java @@ -26,6 +26,7 @@ import com.hibiscusmc.hmccosmetics.packets.CosmeticPacketInterface; import com.hibiscusmc.hmccosmetics.user.CosmeticUser; import com.hibiscusmc.hmccosmetics.user.CosmeticUsers; +import com.hibiscusmc.hmccosmetics.user.manager.BalloonSmoothingTask; import com.hibiscusmc.hmccosmetics.util.search.PlayerSearchManager; import com.hibiscusmc.hmccosmetics.util.MessagesUtil; import com.hibiscusmc.hmccosmetics.util.TranslationUtil; @@ -53,11 +54,16 @@ public final class HMCCosmeticsPlugin extends HibiscusPlugin { + /** Bump whenever an existing config key changes meaning, and add the matching step to migrateConfig. */ + private static final int CONFIG_VERSION = 2; + private static HMCCosmeticsPlugin instance; private static YamlConfigurationLoader configLoader; @Getter private PlayerSearchManager playerSearchManager; + @Getter + private final BalloonSmoothingTask balloonSmoothingTask = new BalloonSmoothingTask(); public HMCCosmeticsPlugin() { super(13873, 1879); @@ -170,6 +176,8 @@ public void onLoad() { @Override public void onEnd() { // Plugin shutdown logic + balloonSmoothingTask.stop(); + com.hibiscusmc.hmccosmetics.util.BalloonStressTest.stop(); for (Player player : Bukkit.getOnlinePlayers()) { CosmeticUser user = CosmeticUsers.getUser(player); if (user == null) continue; @@ -186,6 +194,7 @@ public static HMCCosmeticsPlugin getInstance() { public static void setup() { getInstance().reloadConfig(); + migrateConfig(); // Configuration setup final File file = Path.of(getInstance().getDataFolder().getPath(), "config.yml").toFile(); @@ -271,6 +280,33 @@ public static void setup() { getInstance().getLogger().info(WardrobeSettings.getWardrobes().size() + " Wardrobes Successfully Setup"); getInstance().getLogger().info("Data storage is set to " + DatabaseSettings.getDatabaseType()); + // Runs here rather than in onStart so /hmccosmetics reload picks up a changed balloon-lerp-period. + // start() cancels any previous task first, so calling it again is safe. + getInstance().getBalloonSmoothingTask().start(getInstance()); + Bukkit.getPluginManager().callEvent(new HMCCosmeticSetupEvent()); } + + /** + * Brings an existing config.yml forward to {@link #CONFIG_VERSION}. The comment sync run on enable adds + * keys that are missing but never rewrites ones that are present, so a shipped default that changes + * meaning between versions has to be migrated here or upgraded servers silently keep the old behaviour. + */ + private static void migrateConfig() { + final var config = getInstance().getConfig(); + final int version = config.getInt("config-version", 0); + // 0 means the key is absent entirely, which Settings already reports as a broken config. Rewriting + // something we can't identify would do more harm than leaving it be. + if (version <= 0 || version >= CONFIG_VERSION) return; + + if (version < 2) { + // balloon-head-unmoving flipped to true alongside the balloon smoothing rework: the smoothing + // task drives the balloon's own pitch, so mirroring the player's head pitch fights it. + config.set("cosmetic-settings.balloon-head-unmoving", true); + } + + config.set("config-version", CONFIG_VERSION); + getInstance().saveConfig(); + getInstance().getLogger().info("Migrated config.yml from config-version " + version + " to " + CONFIG_VERSION); + } } diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/command/CosmeticCommand.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/command/CosmeticCommand.java index 99b3a01d5..b61fd469e 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/command/CosmeticCommand.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/command/CosmeticCommand.java @@ -10,12 +10,14 @@ import com.hibiscusmc.hmccosmetics.cosmetic.Cosmetic; import com.hibiscusmc.hmccosmetics.cosmetic.CosmeticSlot; import com.hibiscusmc.hmccosmetics.cosmetic.Cosmetics; +import com.hibiscusmc.hmccosmetics.cosmetic.types.CosmeticBalloonType; import com.hibiscusmc.hmccosmetics.database.Database; import com.hibiscusmc.hmccosmetics.gui.Menu; import com.hibiscusmc.hmccosmetics.gui.Menus; import com.hibiscusmc.hmccosmetics.gui.special.DyeMenuProvider; import com.hibiscusmc.hmccosmetics.user.CosmeticUser; import com.hibiscusmc.hmccosmetics.user.CosmeticUsers; +import com.hibiscusmc.hmccosmetics.util.BalloonStressTest; import com.hibiscusmc.hmccosmetics.util.MessagesUtil; import com.hibiscusmc.hmccosmetics.util.HMCCServerUtils; import me.lojosho.hibiscuscommons.HibiscusCommonsPlugin; @@ -572,6 +574,44 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command user.clearHiddenReasons(); return true; } + + case "stresstest" -> { + if (!sender.hasPermission("hmccosmetics.cmd.stresstest") && !sender.isOp()) { + if (!silent) MessagesUtil.sendMessage(sender, "no-permission"); + return true; + } + if (args.length >= 2 && args[1].equalsIgnoreCase("stop")) { + BalloonStressTest.stop(); + sender.sendMessage("Balloon stress test stopped."); + return true; + } + if (player == null) { + sender.sendMessage("Run /hmccosmetics stresstest as a player (it spawns balloons at your location)."); + return true; + } + int count = 100; + if (args.length >= 2) { + try { + count = Math.max(1, Math.min(2000, Integer.parseInt(args[1]))); + } catch (NumberFormatException e) { + sender.sendMessage("Usage: /hmccosmetics stresstest "); + return true; + } + } + // Clone the balloon the caller has equipped so each stress entity renders the real + // model (ModelEngine or item), not a placeholder. Falls back to a visible helmet if none. + CosmeticUser stressUser = CosmeticUsers.getUser(player.getUniqueId()); + CosmeticBalloonType balloon = stressUser != null + && stressUser.getCosmetic(CosmeticSlot.BALLOON) instanceof CosmeticBalloonType b ? b : null; + ItemStack fallback = player.getInventory().getItemInMainHand(); + if (fallback.getType().isAir()) fallback = new ItemStack(org.bukkit.Material.CARVED_PUMPKIN); + BalloonStressTest.start(HMCCosmeticsPlugin.getInstance(), player.getLocation(), count, balloon, fallback, sender); + sender.sendMessage("Spawned " + count + " stress balloons" + + (balloon != null ? " of your equipped cosmetic" : " (no balloon equipped, using fallback item)") + + " (period=" + Math.max(1, Settings.getBalloonLerpPeriod()) + + "). Timing prints every ~5s. Run /hmccosmetics stresstest stop to end."); + return true; + } } return true; } diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/config/Settings.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/config/Settings.java index a29ff021e..e92c4f3b3 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/config/Settings.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/config/Settings.java @@ -49,8 +49,22 @@ public class Settings { private static final String COSMETIC_BACKPACK_FORCE_RIDING_PACKET_PATH = "backpack-force-riding-packet"; private static final String COSMETIC_BACKPACK_INTERCEPT_PASSENGER_PACKET_PATH = "backpack-intercept-passenger-packets"; private static final String COSMETIC_DESTROY_LOOSE_COSMETIC_PATH = "destroy-loose-cosmetics"; - private static final String COSMETIC_BALLOON_HEAD_FORWARD_PATH = "balloon-head-forward"; + private static final String COSMETIC_BALLOON_HEAD_FORWARD_PATH = "balloon-head-unmoving"; private static final String COSMETIC_BALLOON_DEFAULT_SHOW_LEAD_PATH = "balloon-lead-default"; + private static final String COSMETIC_BALLOON_LERP_PERIOD_PATH = "balloon-lerp-period"; + private static final String COSMETIC_BALLOON_POSITION_LERP_FACTOR_PATH = "balloon-position-lerp-factor"; + private static final String COSMETIC_BALLOON_VERTICAL_LERP_FACTOR_PATH = "balloon-vertical-lerp-factor"; + private static final String COSMETIC_BALLOON_YAW_LERP_FACTOR_PATH = "balloon-yaw-lerp-factor"; + private static final String COSMETIC_BALLOON_TILT_LERP_FACTOR_PATH = "balloon-tilt-lerp-factor"; + private static final String COSMETIC_BALLOON_MAX_TILT_ANGLE_PATH = "balloon-max-tilt-angle"; + private static final String COSMETIC_BALLOON_TILT_FORWARD_FACTOR_PATH = "balloon-tilt-forward-factor"; + private static final String COSMETIC_BALLOON_TILT_SIDE_FACTOR_PATH = "balloon-tilt-side-factor"; + private static final String COSMETIC_BALLOON_BOB_AMPLITUDE_PATH = "balloon-bob-amplitude"; + private static final String COSMETIC_BALLOON_BOB_PERIOD_PATH = "balloon-bob-period"; + private static final String COSMETIC_BALLOON_SWAY_ANGLE_PATH = "balloon-sway-angle"; + private static final String COSMETIC_BALLOON_SWAY_PERIOD_PATH = "balloon-sway-period"; + private static final String COSMETIC_BALLOON_IDLE_YAW_ANGLE_PATH = "balloon-idle-yaw-angle"; + private static final String COSMETIC_BALLOON_IDLE_YAW_PERIOD_PATH = "balloon-idle-yaw-period"; private static final String COSMETIC_OFFHAND_PREVENT_SWAPPING = "offhand-prevent-swapping"; private static final String MENU_SETTINGS_PATH = "menu-settings"; private static final String MENU_CLICK_COOLDOWN_PATH = "click-cooldown"; @@ -124,6 +138,34 @@ public class Settings { @Getter private static boolean balloonDefaultShowLead; @Getter + private static int balloonLerpPeriod; + @Getter + private static double balloonPositionLerpFactor; + @Getter + private static double balloonVerticalLerpFactor; + @Getter + private static double balloonYawLerpFactor; + @Getter + private static double balloonTiltLerpFactor; + @Getter + private static double balloonMaxTiltAngle; + @Getter + private static double balloonTiltForwardFactor; + @Getter + private static double balloonTiltSideFactor; + @Getter + private static double balloonBobAmplitude; + @Getter + private static int balloonBobPeriod; + @Getter + private static double balloonSwayAngle; + @Getter + private static int balloonSwayPeriod; + @Getter + private static double balloonIdleYawAngle; + @Getter + private static int balloonIdleYawPeriod; + @Getter private static boolean backpackPreventDarkness; @Getter private static boolean preferHMCColorDyeMenu; @@ -239,8 +281,24 @@ public static void load(ConfigurationNode source) { tickPeriod = cosmeticSettings.node(TICK_PERIOD_PATH).getInt(-1); engine = PlayerSearchManager.SearchEngine.valueOf(cosmeticSettings.node(PLAYER_SEARCH_IMPLEMENTATION).getString("BUKKIT").toUpperCase()); viewDistance = cosmeticSettings.node(VIEW_DISTANCE_PATH).getInt(-3); - balloonHeadForward = cosmeticSettings.node(COSMETIC_BALLOON_HEAD_FORWARD_PATH).getBoolean(false); + balloonHeadForward = cosmeticSettings.node(COSMETIC_BALLOON_HEAD_FORWARD_PATH).getBoolean(true); balloonDefaultShowLead = cosmeticSettings.node(COSMETIC_BALLOON_DEFAULT_SHOW_LEAD_PATH).getBoolean(true); + // A period of 0 or less disables the balloon smoothing task entirely, matching the -1 sentinel + // that tick-period and view-distance already use. + balloonLerpPeriod = cosmeticSettings.node(COSMETIC_BALLOON_LERP_PERIOD_PATH).getInt(2); + balloonPositionLerpFactor = loadLerpFactor(cosmeticSettings, COSMETIC_BALLOON_POSITION_LERP_FACTOR_PATH, 0.35); + balloonVerticalLerpFactor = loadLerpFactor(cosmeticSettings, COSMETIC_BALLOON_VERTICAL_LERP_FACTOR_PATH, 0.15); + balloonYawLerpFactor = loadLerpFactor(cosmeticSettings, COSMETIC_BALLOON_YAW_LERP_FACTOR_PATH, 0.15); + balloonTiltLerpFactor = loadLerpFactor(cosmeticSettings, COSMETIC_BALLOON_TILT_LERP_FACTOR_PATH, 0.25); + balloonMaxTiltAngle = loadNonNegative(cosmeticSettings, COSMETIC_BALLOON_MAX_TILT_ANGLE_PATH, 25.0); + balloonTiltForwardFactor = loadFinite(cosmeticSettings, COSMETIC_BALLOON_TILT_FORWARD_FACTOR_PATH, 4.0); + balloonTiltSideFactor = loadFinite(cosmeticSettings, COSMETIC_BALLOON_TILT_SIDE_FACTOR_PATH, 6.0); + balloonBobAmplitude = loadNonNegative(cosmeticSettings, COSMETIC_BALLOON_BOB_AMPLITUDE_PATH, 0.0); + balloonBobPeriod = Math.max(1, cosmeticSettings.node(COSMETIC_BALLOON_BOB_PERIOD_PATH).getInt(70)); + balloonSwayAngle = loadNonNegative(cosmeticSettings, COSMETIC_BALLOON_SWAY_ANGLE_PATH, 0.0); + balloonSwayPeriod = Math.max(1, cosmeticSettings.node(COSMETIC_BALLOON_SWAY_PERIOD_PATH).getInt(90)); + balloonIdleYawAngle = loadNonNegative(cosmeticSettings, COSMETIC_BALLOON_IDLE_YAW_ANGLE_PATH, 0.0); + balloonIdleYawPeriod = Math.max(1, cosmeticSettings.node(COSMETIC_BALLOON_IDLE_YAW_PERIOD_PATH).getInt(160)); backpackPreventDarkness = cosmeticSettings.node(BACKPACK_PREVENT_DARKNESS_PATH).getBoolean(true); ConfigurationNode menuSettings = source.node(MENU_SETTINGS_PATH); @@ -305,6 +363,39 @@ public static Vector loadVector(final ConfigurationNode config) { return new Vector(config.node("x").getDouble(), config.node("y").getDouble(), config.node("z").getDouble()); } + /** + * Reads a lerp factor, clamped into [0, 1]. Non-finite values fall back to the default rather than + * being clamped: NaN survives every clamp and would propagate through the smoothing maths into + * Location#setX, making the balloon teleport throw on every tick. + */ + private static double loadLerpFactor(final ConfigurationNode config, final String path, final double def) { + final double value = config.node(path).getDouble(def); + if (!Double.isFinite(value)) return warnAndDefault(path, value, def); + return Math.clamp(value, 0.0, 1.0); + } + + /** + * Reads a double that has no meaning below zero. A negative max tilt angle in particular would invert + * the bounds handed to {@link Math#clamp} and pin every balloon at full lean. + */ + private static double loadNonNegative(final ConfigurationNode config, final String path, final double def) { + final double value = config.node(path).getDouble(def); + if (!Double.isFinite(value) || value < 0) return warnAndDefault(path, value, def); + return value; + } + + private static double loadFinite(final ConfigurationNode config, final String path, final double def) { + final double value = config.node(path).getDouble(def); + if (!Double.isFinite(value)) return warnAndDefault(path, value, def); + return value; + } + + private static double warnAndDefault(final String path, final double value, final double def) { + HMCCosmeticsPlugin.getInstance().getLogger().warning( + "Invalid value for " + path + " (" + value + "), falling back to " + def); + return def; + } + public static SlotOptionConfig getSlotOption(EquipmentSlot slot) { if (!slotOptions.containsKey(slot)) slotOptions.put(slot, new SlotOptionConfig(slot, false, false, false, false)); return slotOptions.get(slot); diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/cosmetic/types/CosmeticBalloonType.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/cosmetic/types/CosmeticBalloonType.java index 038e855bd..a63739301 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/cosmetic/types/CosmeticBalloonType.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/cosmetic/types/CosmeticBalloonType.java @@ -2,11 +2,9 @@ import com.hibiscusmc.hmccosmetics.config.Settings; import com.hibiscusmc.hmccosmetics.cosmetic.Cosmetic; -import com.hibiscusmc.hmccosmetics.cosmetic.behavior.CosmeticMovementBehavior; import com.hibiscusmc.hmccosmetics.cosmetic.behavior.CosmeticUpdateBehavior; import com.hibiscusmc.hmccosmetics.user.CosmeticUser; import com.hibiscusmc.hmccosmetics.user.manager.UserBalloonManager; -import com.hibiscusmc.hmccosmetics.util.MessagesUtil; import com.hibiscusmc.hmccosmetics.util.packets.HMCCPacketManager; import lombok.Getter; import me.lojosho.shaded.configurate.ConfigurationNode; @@ -20,7 +18,9 @@ import java.util.List; -public class CosmeticBalloonType extends Cosmetic implements CosmeticUpdateBehavior, CosmeticMovementBehavior { +// Balloons deliberately do not implement CosmeticMovementBehavior: BalloonSmoothingTask owns their +// position and runs far more often than move events fire, so a move handler has nothing left to do. +public class CosmeticBalloonType extends Cosmetic implements CosmeticUpdateBehavior { @Getter private final String modelName; @@ -72,6 +72,18 @@ public void dispatchUpdate(@NotNull CosmeticUser user) { newLocation = newLocation.clone().add(getBalloonOffset()); if (Settings.isBalloonHeadForward()) newLocation.setPitch(0); + // Smoothing task is off (balloon-lerp-period <= 0): it is the only thing that moves the balloon + // AND its lead, so drive both from this low-frequency tick instead of leaving them frozen at spawn. + // Gated on <= 0 so it never fights the smoothing task while that is running. + if (Settings.getBalloonLerpPeriod() <= 0) { + userBalloonManager.snapTo(newLocation); + // snapTo only moves the model entity; the lead is anchored to the pufferfish, which the + // smoothing task would normally teleport. Move it here for existing viewers so the lead follows. + if (!userBalloonManager.getPufferfish().getViewers().isEmpty()) { + userBalloonManager.getPufferfish().teleport(newLocation); + } + } + if (!user.isHidden() && showLead) { List sendTo = userBalloonManager.getPufferfish().refreshViewers(newLocation); if (sendTo.isEmpty()) return; @@ -80,42 +92,6 @@ public void dispatchUpdate(@NotNull CosmeticUser user) { } } - @Override - public void dispatchMove(@NotNull CosmeticUser user, @NotNull Location from, @NotNull Location to) { - Entity entity = Bukkit.getEntity(user.getUniqueId()); - UserBalloonManager userBalloonManager = user.getBalloonManager(); - - if (entity == null || userBalloonManager == null) return; - if (user.isInWardrobe()) return; - - if (!userBalloonManager.getModelEntity().isValid()) { - return; - } - - Location newLocation = entity.getLocation(); - Location currentLocation = user.getBalloonManager().getLocation(); - newLocation = newLocation.clone().add(getBalloonOffset()); - if (Settings.isBalloonHeadForward()) newLocation.setPitch(0); - - List viewers = HMCCPacketManager.getViewers(entity.getLocation()); - - if (entity.getLocation().getWorld() != userBalloonManager.getLocation().getWorld()) { - userBalloonManager.getModelEntity().teleport(newLocation); - HMCCPacketManager.sendTeleportPacket(userBalloonManager.getPufferfishBalloonId(), newLocation, false, viewers); - return; - } - - //Vector velocity = newLocation.toVector().subtract(currentLocation.toVector()); - userBalloonManager.setLocation(newLocation); - - MessagesUtil.sendDebugMessages("Balloon Cosmetic Update for " + user.getEntity().getName()); - MessagesUtil.sendDebugMessages("Ballon previous location is " + currentLocation); - MessagesUtil.sendDebugMessages("Balloon location set to " + newLocation); - - HMCCPacketManager.sendTeleportPacket(userBalloonManager.getPufferfishBalloonId(), newLocation, false, viewers); - HMCCPacketManager.sendLeashPacket(userBalloonManager.getPufferfishBalloonId(), entity.getEntityId(), viewers); - } - public boolean isDyeablePart(String name) { // If player does not define parts, dye whole model if (dyeableParts == null) return true; diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/listener/PlayerMovementListener.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/listener/PlayerMovementListener.java index 291fe593b..7be4a2099 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/listener/PlayerMovementListener.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/listener/PlayerMovementListener.java @@ -20,9 +20,10 @@ @Slf4j public class PlayerMovementListener implements Listener { + // Balloons are absent on purpose - BalloonSmoothingTask drives them every tick, so dispatching move + // events at them only logged a "does not implement CosmeticMovementBehavior" debug line. private static final List MOVEMENT_COSMETICS = List.of( - CosmeticSlot.BACKPACK, - CosmeticSlot.BALLOON + CosmeticSlot.BACKPACK ); // Player Id -> Small Location diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/CosmeticUser.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/CosmeticUser.java index 60438cc7e..cff4284e5 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/CosmeticUser.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/CosmeticUser.java @@ -590,19 +590,24 @@ public void spawnBalloon(CosmeticBalloonType cosmeticBalloonType) { org.bukkit.entity.Entity entity = getEntity(); - UserBalloonManager userBalloonManager1 = new UserBalloonManager(this, entity.getLocation()); - userBalloonManager1.getModelEntity().teleport(entity.getLocation().add(cosmeticBalloonType.getBalloonOffset())); + // Spawn straight at the offset position. Spawning at the player's feet and teleporting up would + // leave the smoothing task's follow-lerp base at the feet, so every balloon would visibly climb + // into place over the first second after being equipped. + Location spawnLocation = entity.getLocation().add(cosmeticBalloonType.getBalloonOffset()); + UserBalloonManager userBalloonManager1 = new UserBalloonManager(this, spawnLocation); userBalloonManager1.spawnModel(cosmeticBalloonType, getCosmeticColor(cosmeticBalloonType.getSlot())); userBalloonManager1.addPlayerToModel(this, cosmeticBalloonType, getCosmeticColor(cosmeticBalloonType.getSlot())); this.userBalloonManager = userBalloonManager1; + CosmeticUsers.addBalloonUser(this); } public void despawnBalloon() { if (this.userBalloonManager == null) return; this.userBalloonManager.remove(); this.userBalloonManager = null; + CosmeticUsers.removeBalloonUser(this); } public void respawnBackpack() { diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/CosmeticUsers.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/CosmeticUsers.java index 889fdb000..3f4d08123 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/CosmeticUsers.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/CosmeticUsers.java @@ -8,6 +8,8 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.util.Collection; +import java.util.Collections; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; @@ -16,6 +18,11 @@ public class CosmeticUsers { private static final ConcurrentHashMap COSMETIC_USERS = new ConcurrentHashMap<>(); + // Users who currently have a balloon spawned. Balloons are opt-in and usually a minority, so the + // per-tick BalloonSmoothingTask iterates this instead of walking every user. Kept in sync by + // spawnBalloon/despawnBalloon, the only two places CosmeticUser assigns/clears its balloon manager. + private static final Set BALLOON_USERS = ConcurrentHashMap.newKeySet(); + private static CosmeticUserProvider PROVIDER = CosmeticUserProvider.Default.INSTANCE; /** @@ -111,4 +118,40 @@ public static Set values() { // fix this later; this is a temporary fix. It was originally a set, now it's a collection return Set.copyOf(COSMETIC_USERS.values()); } + + /** + * A live, read-only view over the users. Unlike {@link #values()} this does not copy, so it is safe to + * call from per-tick code. The backing map is a {@link ConcurrentHashMap}, so the returned view is safe + * to iterate while users join and quit; it reflects those changes rather than snapshotting them. + * @return the CosmeticUsers currently registered. Never null, might be empty. + */ + @NotNull + public static Collection view() { + return Collections.unmodifiableCollection(COSMETIC_USERS.values()); + } + + /** + * Registers a user as having a spawned balloon. Called from {@link CosmeticUser#spawnBalloon}. + */ + public static void addBalloonUser(@NotNull CosmeticUser user) { + BALLOON_USERS.add(user); + } + + /** + * Unregisters a user's balloon. Called from {@link CosmeticUser#despawnBalloon}, which every + * teardown path (unequip, hide, quit/destroy, respawn) funnels through. + */ + public static void removeBalloonUser(@NotNull CosmeticUser user) { + BALLOON_USERS.remove(user); + } + + /** + * A live, read-only view over the users who currently have a balloon. Safe to iterate from + * per-tick code; backed by a {@link ConcurrentHashMap} key set, so it reflects joins/quits. + * @return the CosmeticUsers with a spawned balloon. Never null, might be empty. + */ + @NotNull + public static Collection balloonView() { + return Collections.unmodifiableCollection(BALLOON_USERS); + } } diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java new file mode 100644 index 000000000..7d4f31e01 --- /dev/null +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java @@ -0,0 +1,269 @@ +package com.hibiscusmc.hmccosmetics.user.manager; + +import com.hibiscusmc.hmccosmetics.config.Settings; +import com.hibiscusmc.hmccosmetics.cosmetic.CosmeticSlot; +import com.hibiscusmc.hmccosmetics.cosmetic.types.CosmeticBalloonType; +import com.hibiscusmc.hmccosmetics.user.CosmeticUser; +import com.hibiscusmc.hmccosmetics.user.CosmeticUsers; +import com.hibiscusmc.hmccosmetics.util.MathUtil; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Entity; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.logging.Level; + +/** + * Drives smooth, high-frequency position lerping and movement-based tilt for balloon cosmetics, + * independent of the much slower general {@link CosmeticUser} tick heartbeat. + *

+ * Balloons that have caught up with their owner and have no idle animation configured short-circuit + * before doing any work, so a server full of standing-still players costs a walk over the user map and + * nothing else. + */ +public class BalloonSmoothingTask implements Runnable { + + private static final double TWO_PI = Math.PI * 2; + /** Follow lag below this is under the position quantisation of a teleport, so nothing would move. */ + private static final double SETTLED_DISTANCE_SQUARED = 1.0E-6; + /** Residual yaw step below this is not worth a position update. Degrees. */ + private static final double SETTLED_YAW = 0.05; + /** Residual tilt below this is invisible, so the tilt lerp is allowed to stop here. Degrees. */ + private static final double SETTLED_TILT = 0.05; + /** Squared follow lag past which the balloon is teleported outright instead of lerped. */ + private static final double SNAP_DISTANCE_SQUARED = 8 * 8; + /** Squared follow lag by which the idle yaw wander has fully faded out. */ + private static final double IDLE_YAW_FADE_DISTANCE_SQUARED = 0.05 * 0.05; + /** Squared follow lag above which the balloon is considered to be travelling, not drifting. */ + private static final double MOVING_DISTANCE_SQUARED = 0.02 * 0.02; + /** Render step below this (0.001 blocks) is invisible, so the entity teleport can be skipped. */ + private static final double RENDER_EPSILON_SQUARED = 0.001 * 0.001; + + private Plugin plugin; + private BukkitTask task; + private long tick; + private int period = 1; + // Users whose smoothing has already thrown once. Keeps a persistently broken balloon from dumping a + // stack trace twenty times a second. + private final Set failed = new HashSet<>(); + + public void start(Plugin plugin) { + stop(); + this.plugin = plugin; + // Read the period once, here: run() has to agree with the rate the scheduler was actually given, + // and a reload that changes it goes back through start(). + this.period = Settings.getBalloonLerpPeriod(); + if (this.period <= 0) return; + + task = Bukkit.getScheduler().runTaskTimer(plugin, this, 0L, this.period); + } + + public void stop() { + if (task != null) { + task.cancel(); + task = null; + } + failed.clear(); + } + + @Override + public void run() { + // Advance the oscillator time base by the task period so idle animations run at the same + // real-time speed regardless of balloon-lerp-period. + tick += period; + + final Tuning tuning = Tuning.snapshot(period); + + for (CosmeticUser user : CosmeticUsers.balloonView()) { + try { + smooth(user, tuning); + } catch (Exception e) { + // Without this, one broken balloon starves every user after it in iteration order - the + // same victims, every tick. + if (failed.add(user.getUniqueId())) { + plugin.getLogger().log(Level.WARNING, "Balloon smoothing failed for " + user.getUniqueId() + + ", suppressing further reports for this player", e); + } + } + } + } + + private void smooth(CosmeticUser user, Tuning tuning) { + if (!user.isBalloonSpawned() || user.isHidden() || user.isInWardrobe()) return; + + UserBalloonManager balloonManager = user.getBalloonManager(); + Entity entity = user.getEntity(); + if (entity == null || balloonManager == null) return; + if (!balloonManager.getModelEntity().isValid()) return; + + if (!(user.getCosmetic(CosmeticSlot.BALLOON) instanceof CosmeticBalloonType cosmeticBalloonType)) return; + + Location target = entity.getLocation().add(cosmeticBalloonType.getBalloonOffset()); + if (tuning.headForward) target.setPitch(0); + + Location current = balloonManager.getSmoothedBase(); + if (current.getWorld() != target.getWorld()) { + // Lerping across a dimension change is meaningless - the balloon has to arrive with the player. + balloonManager.snapTo(target); + return; + } + + double dx = target.getX() - current.getX(); + double dy = target.getY() - current.getY(); + double dz = target.getZ() - current.getZ(); + double horizontalLagSq = dx * dx + dz * dz; + + if (horizontalLagSq + dy * dy > SNAP_DISTANCE_SQUARED) { + // A long teleport would otherwise walk a real entity through a handful of intermediate + // positions, force-loading chunks nobody is near, before the respawn in PlayerGameListener + // catches up four ticks later. + balloonManager.snapTo(target); + return; + } + + double currentYaw = current.getYaw(); + double desiredYaw = horizontalLagSq > MOVING_DISTANCE_SQUARED + ? Math.toDegrees(Math.atan2(-dx, dz)) + : currentYaw; + double newYaw = MathUtil.lerpAngle(currentYaw, desiredYaw, tuning.yawFactor); + + // Nothing to send once the balloon has caught up and no oscillator is running. + if (!tuning.animated + && horizontalLagSq + dy * dy < SETTLED_DISTANCE_SQUARED + && Math.abs(newYaw - currentYaw) < SETTLED_YAW + && Math.abs(balloonManager.getTiltPitch()) < SETTLED_TILT + && Math.abs(balloonManager.getTiltRoll()) < SETTLED_TILT) { + return; + } + + Location base = MathUtil.lerpLocation(current, target, tuning.positionFactor, tuning.verticalFactor); + base.setYaw((float) newYaw); + balloonManager.setSmoothedBase(base); + + // Idle life: per-user phase offset so nearby balloons don't animate in lockstep. + double phase = (user.getUniqueId().hashCode() & 0xFFFF) * TWO_PI / 0x10000; + double bob = tuning.bobAmplitude * Math.sin(TWO_PI * tick / tuning.bobPeriod + phase); + double swayTheta = TWO_PI * tick / tuning.swayPeriod + phase; + double swayPitch = tuning.swayAngle * Math.sin(swayTheta); + double swayRoll = tuning.swayAngle * Math.cos(swayTheta); // cos: circular pendulum swing, not a diagonal line + + // The wander is an idle behaviour, and it also rotates the frame the follow lag is decomposed in + // below - left alone it would bleed forward lean into the roll channel at speed. Fade it out as + // soon as the balloon is actually travelling. + double idleYaw = tuning.idleYawAngle == 0 ? 0 + : tuning.idleYawAngle + * (1 - Math.min(1, horizontalLagSq / IDLE_YAW_FADE_DISTANCE_SQUARED)) + * Math.sin(TWO_PI * tick / tuning.idleYawPeriod + phase); + + double renderYaw = newYaw + idleYaw; + + // Display-only offsets, kept off `base` so the bob and the wander never feed back into the + // follow-lerp and compound tick over tick. + Location render = base.clone(); + render.setY(render.getY() + bob); + render.setYaw((float) renderYaw); + + // The head pose is applied in the armor stand's own frame, whose yaw is renderYaw (movement-driven), + // not the player's. Decomposing the follow-lag in any other frame rotates the lean by the difference. + double yawRad = Math.toRadians(renderYaw); + double fwdX = -Math.sin(yawRad); + double fwdZ = Math.cos(yawRad); + // Right of forward in Minecraft's frame: at yaw 0 the balloon faces +Z (south), so right is -X (west). + double rightX = -fwdZ; + double rightZ = fwdX; + + double forwardLag = dx * fwdX + dz * fwdZ; + double sideLag = dx * rightX + dz * rightZ; + + double maxTilt = tuning.maxTilt; + // Sway is added after the clamp so the idle swing is never eaten by the movement-tilt cap. + double desiredPitch = Math.clamp(-forwardLag * tuning.tiltForwardFactor, -maxTilt, maxTilt) + swayPitch; + double desiredRoll = Math.clamp(-sideLag * tuning.tiltSideFactor, -maxTilt, maxTilt) + swayRoll; + + // Only teleport the entity when the rendered position actually changed by a visible amount. The + // tail of a lerp (and an idle balloon whose only motion is a settling tilt) otherwise pays a full + // entity teleport + tracker broadcast every tick for a sub-millimetre step. Tilt still updates via + // setTilt below, which self-dedups its own metadata packet. + Location lastRendered = balloonManager.getLastRendered(); + boolean rendered = lastRendered == null + || lastRendered.getWorld() != render.getWorld() + || lastRendered.distanceSquared(render) > RENDER_EPSILON_SQUARED + || Math.abs(render.getYaw() - lastRendered.getYaw()) > SETTLED_YAW; + + if (rendered) { + balloonManager.setLocation(render); + balloonManager.setLastRendered(render.clone()); + + // The armor stand is broadcast by the vanilla entity tracker; the only packet this task owes is + // for the pufferfish the lead is tied to. Reusing the entity's own viewer list avoids re-deriving + // it, and leaves balloons with show-lead off - whose pufferfish is never spawned client-side - free. + UserBalloonPufferfish pufferfish = balloonManager.getPufferfish(); + if (!pufferfish.getViewers().isEmpty()) pufferfish.teleport(render); + } + + balloonManager.setTilt( + MathUtil.lerp(balloonManager.getTiltPitch(), desiredPitch, tuning.tiltFactor), + MathUtil.lerp(balloonManager.getTiltRoll(), desiredRoll, tuning.tiltFactor) + ); + } + + /** + * The configuration this run is using, resolved once instead of per balloon. + */ + private record Tuning( + boolean headForward, + double positionFactor, + double verticalFactor, + double yawFactor, + double tiltFactor, + double maxTilt, + double tiltForwardFactor, + double tiltSideFactor, + double bobAmplitude, + int bobPeriod, + double swayAngle, + int swayPeriod, + double idleYawAngle, + int idleYawPeriod, + boolean animated + ) { + + static Tuning snapshot(int period) { + double bobAmplitude = Settings.getBalloonBobAmplitude(); + double swayAngle = Settings.getBalloonSwayAngle(); + double idleYawAngle = Settings.getBalloonIdleYawAngle(); + + return new Tuning( + Settings.isBalloonHeadForward(), + perPeriod(Settings.getBalloonPositionLerpFactor(), period), + perPeriod(Settings.getBalloonVerticalLerpFactor(), period), + perPeriod(Settings.getBalloonYawLerpFactor(), period), + perPeriod(Settings.getBalloonTiltLerpFactor(), period), + Settings.getBalloonMaxTiltAngle(), + Settings.getBalloonTiltForwardFactor(), + Settings.getBalloonTiltSideFactor(), + bobAmplitude, + Settings.getBalloonBobPeriod(), + swayAngle, + Settings.getBalloonSwayPeriod(), + idleYawAngle, + Settings.getBalloonIdleYawPeriod(), + bobAmplitude != 0 || swayAngle != 0 || idleYawAngle != 0 + ); + } + + /** + * Converts a factor expressed as "fraction of the remaining gap closed per tick" into the + * equivalent for a run that only happens every {@code period} ticks, so raising the period trades + * smoothness for cost without also changing how closely the balloon follows. + */ + private static double perPeriod(double factor, int period) { + if (period <= 1) return factor; + return 1 - Math.pow(1 - factor, period); + } + } +} diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/UserBalloonManager.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/UserBalloonManager.java index 1c6138fc5..2e8239a46 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/UserBalloonManager.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/UserBalloonManager.java @@ -13,6 +13,7 @@ import com.ticxo.modelengine.api.model.ActiveModel; import com.ticxo.modelengine.api.model.ModeledEntity; import lombok.Getter; +import lombok.Setter; import me.lojosho.hibiscuscommons.hooks.Hooks; import me.lojosho.hibiscuscommons.nms.NMSHandlers; import org.bukkit.Color; @@ -22,6 +23,7 @@ import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.persistence.PersistentDataType; +import org.bukkit.util.EulerAngle; import org.bukkit.util.Vector; import org.jetbrains.annotations.NotNull; @@ -31,6 +33,9 @@ public class UserBalloonManager { + /** Pose changes smaller than this are not worth a metadata broadcast. Degrees. */ + private static final double TILT_EPSILON = 0.05; + private final CosmeticUser user; @Getter private BalloonType balloonType; @@ -39,8 +44,60 @@ public class UserBalloonManager { private UserBalloonPufferfish pufferfish; private final ArmorStand modelEntity; + // Smoothing state, updated by the balloon smoothing ticker. Degrees. + @Getter + private double tiltPitch = 0; + @Getter + private double tiltRoll = 0; + // Last pose actually broadcast. Tracked apart from the logical tilt above so the tilt lerp keeps + // converging normally while the metadata packet only goes out on a visible change. + private double sentTiltPitch = 0; + private double sentTiltRoll = 0; + // Un-bobbed follow-lerp position. The entity's rendered location is this plus the idle bob offset; + // lerping from the rendered location instead would feed the bob back into the follow-lerp. + @Getter + @Setter + private Location smoothedBase; + // Last position actually broadcast by the smoothing task. Lets the tail of a lerp - where the + // per-tick step shrinks below visibility - stop teleporting the entity before it fully settles. + @Getter + @Setter + private Location lastRendered; + + /** + * Applies the movement and idle tilt. + *

+ * Only ITEM balloons - a real armor stand wearing the cosmetic as a helmet - are actually posed. + * ModelEngine renders its bones from its own transform data and never reads the stand's head pose + * ({@code BukkitEntityData} exposes only yRot/yHeadRot/xHeadRot/yBodyRot), so posing a ME balloon is + * an entity metadata broadcast to every tracker for no visual change at all. + */ + public void setTilt(double pitch, double roll) { + this.tiltPitch = pitch; + this.tiltRoll = roll; + if (balloonType != BalloonType.ITEM) return; + if (Math.abs(pitch - sentTiltPitch) < TILT_EPSILON && Math.abs(roll - sentTiltRoll) < TILT_EPSILON) return; + + this.sentTiltPitch = pitch; + this.sentTiltRoll = roll; + modelEntity.setHeadPose(new EulerAngle(Math.toRadians(pitch), 0, Math.toRadians(roll))); + } + + /** + * Places the balloon at {@code location} with no smoothing carry-over: the entity, the follow-lerp + * base and the tilt all snap to it. Use whenever the balloon has to appear somewhere instantly - + * spawn, world change, long-distance teleport - rather than lerping across the gap. + */ + public void snapTo(@NotNull Location location) { + setLocation(location); + this.smoothedBase = location.clone(); + this.lastRendered = location.clone(); + setTilt(0, 0); + } + public UserBalloonManager(CosmeticUser user, @NotNull Location location) { this.user = user; + this.smoothedBase = location.clone(); this.pufferfish = new UserBalloonPufferfish(user.getUniqueId(), NMSHandlers.getHandler().getUtilHandler().getNextEntityId(location.getWorld()), UUID.randomUUID()); this.modelEntity = location.getWorld().spawn(location, ArmorStand.class, (e) -> { e.setInvisible(true); diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/UserWardrobeManager.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/UserWardrobeManager.java index 56eda3a30..64e81dd31 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/UserWardrobeManager.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/UserWardrobeManager.java @@ -190,8 +190,9 @@ public void start() { Location balloonLocation = npcLocation.clone().add(cosmetic.getBalloonOffset()); HMCCPacketManager.sendTeleportPacket(user.getBalloonManager().getPufferfishBalloonId(), balloonLocation, false, viewer); - user.getBalloonManager().getModelEntity().teleport(balloonLocation); - user.getBalloonManager().setLocation(balloonLocation); + // snapTo, not setLocation: it also resets the smoothing base, so leaving the wardrobe + // doesn't make the balloon lerp back in from wherever it was standing beforehand. + user.getBalloonManager().snapTo(balloonLocation); } } diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/util/BalloonStressTest.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/util/BalloonStressTest.java new file mode 100644 index 000000000..399889ea9 --- /dev/null +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/util/BalloonStressTest.java @@ -0,0 +1,186 @@ +package com.hibiscusmc.hmccosmetics.util; + +import com.hibiscusmc.hmccosmetics.config.Settings; +import com.hibiscusmc.hmccosmetics.cosmetic.types.CosmeticBalloonType; +import com.hibiscusmc.hmccosmetics.user.manager.BalloonSmoothingTask; +import com.ticxo.modelengine.api.ModelEngineAPI; +import com.ticxo.modelengine.api.entity.data.BukkitEntityData; +import com.ticxo.modelengine.api.model.ActiveModel; +import com.ticxo.modelengine.api.model.ModeledEntity; +import me.lojosho.hibiscuscommons.hooks.Hooks; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.ArmorStand; +import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; + +/** + * Synthetic load generator for the balloon system. Spawns {@code count} real, invisible marker armor + * stands - the exact entity {@link com.hibiscusmc.hmccosmetics.user.manager.UserBalloonManager} uses - + * each carrying the caller's equipped balloon cosmetic (ModelEngine model or item helmet), and drives + * every one of them toward a wandering target on the same cadence as {@link BalloonSmoothingTask} + * ({@code balloon-lerp-period}). The target keeps moving so all balloons are always lagging, i.e. the + * worst case where every balloon teleports every update. + *

+ * This reproduces the dominant per-balloon cost - a real {@link ArmorStand#teleport} plus the vanilla + * entity-tracker broadcast to every nearby viewer, and the ModelEngine render for a real model - at + * whatever count you ask for, without needing that many players. It measures its own main-thread + * self-time and reports avg ms/update to the console, so a profiler is optional. It does NOT spawn the + * lead pufferfish; the armor-stand teleport and model render are the costs that scale, and the lead is a + * comparatively cheap packet guarded by having viewers. + *

+ * Debug/benchmark tool only. Spawned stands are non-persistent (gone on restart) and are also cleared by + * {@link #stop()} and on plugin disable. + */ +public final class BalloonStressTest { + + private BalloonStressTest() {} + + private static final List STANDS = new ArrayList<>(); + private static BukkitTask task; + private static Location anchor; + private static long tick; + private static boolean usedModelEngine; + + // Self-timing accumulators, reset each reporting window. + private static double windowNanos; + private static long windowSamples; + private static CommandSender reportTo; + + public static synchronized boolean isRunning() { + return task != null; + } + + public static synchronized int count() { + return STANDS.size(); + } + + /** + * Spawns {@code count} stress balloons around {@code origin} and starts driving them. + * + * @param balloon the cosmetic to render on every stand (its model/item). May be null. + * @param fallback helmet item worn when {@code balloon} is null or has no ModelEngine model, so the + * swarm is still visible. May be null for a bare marker stand. + * @param report where to echo the periodic timing summary; may be null. + */ + public static synchronized void start(@NotNull Plugin plugin, @NotNull Location origin, int count, + @Nullable CosmeticBalloonType balloon, @Nullable ItemStack fallback, + @Nullable CommandSender report) { + stop(); + anchor = origin.clone(); + reportTo = report; + final World world = origin.getWorld(); + if (world == null) return; + + final boolean modelEngine = balloon != null && balloon.getModelName() != null + && Hooks.isActiveHook("ModelEngine") + && ModelEngineAPI.getBlueprint(balloon.getModelName()) != null; + usedModelEngine = modelEngine; + final ItemStack helmet = balloon != null && balloon.getItem() != null ? balloon.getItem() : fallback; + + for (int i = 0; i < count; i++) { + // Small spread so the caller (and any nearby players) tracks them all, exercising the broadcast. + final Location spawn = origin.clone().add((Math.random() - 0.5) * 6, 0, (Math.random() - 0.5) * 6); + final ArmorStand stand = world.spawn(spawn, ArmorStand.class, e -> { + e.setInvisible(true); + e.setGravity(false); + e.setSilent(true); + e.setInvulnerable(true); + e.setSmall(true); + e.setMarker(true); + e.setPersistent(false); + e.setAI(false); + if (!modelEngine && helmet != null) e.getEquipment().setHelmet(helmet); + e.getPersistentDataContainer().set(HMCCServerUtils.getCosmemeticMobKey(), PersistentDataType.BOOLEAN, true); + }); + if (modelEngine) applyModel(stand, balloon.getModelName()); + STANDS.add(stand); + } + + final int period = Math.max(1, Settings.getBalloonLerpPeriod()); + tick = 0; + windowNanos = 0; + windowSamples = 0; + task = Bukkit.getScheduler().runTaskTimer(plugin, BalloonStressTest::run, period, period); + } + + // Attaches a real ModelEngine model to the stand, mirroring UserBalloonManager.spawnModel so the + // render cost measured is the genuine one. + private static void applyModel(@NotNull ArmorStand stand, @NotNull String modelId) { + ModeledEntity modeledEntity = ModelEngineAPI.getOrCreateModeledEntity(stand); + ActiveModel model = ModelEngineAPI.createActiveModel(ModelEngineAPI.getBlueprint(modelId)); + model.setCanHurt(false); + modeledEntity.addModel(model, false); + BukkitEntityData data = (BukkitEntityData) modeledEntity.getBase().getData(); + data.setBlockedCullIgnoreRadius((double) Settings.getViewDistance()); + } + + public static synchronized void stop() { + if (task != null) { + task.cancel(); + task = null; + } + for (ArmorStand stand : STANDS) { + if (stand == null || !stand.isValid()) continue; + if (usedModelEngine) { + ModeledEntity me = ModelEngineAPI.getModeledEntity(stand); + if (me != null) me.destroy(); + } + stand.remove(); + } + STANDS.clear(); + anchor = null; + reportTo = null; + usedModelEngine = false; + } + + private static synchronized void run() { + final int period = Math.max(1, Settings.getBalloonLerpPeriod()); + tick += period; + + // Wander the shared target so every balloon keeps a follow-lag and thus keeps teleporting. + final double t = tick * 0.02; + final Location target = anchor.clone().add(Math.sin(t) * 8.0, Math.sin(t * 0.5) * 2.0, Math.cos(t) * 8.0); + + final double posFactor = 1 - Math.pow(1 - 0.35, period); + final double vertFactor = 1 - Math.pow(1 - 0.15, period); + final double yawFactor = 1 - Math.pow(1 - 0.15, period); + + final long start = System.nanoTime(); + for (ArmorStand stand : STANDS) { + if (!stand.isValid()) continue; + final Location cur = stand.getLocation(); + final double dx = target.getX() - cur.getX(); + final double dz = target.getZ() - cur.getZ(); + final double desiredYaw = Math.toDegrees(Math.atan2(-dx, dz)); + final Location render = MathUtil.lerpLocation(cur, target, posFactor, vertFactor); + render.setYaw((float) MathUtil.lerpAngle(cur.getYaw(), desiredYaw, yawFactor)); + stand.teleport(render); + } + final long elapsed = System.nanoTime() - start; + + windowNanos += elapsed; + windowSamples++; + + // Report roughly every 5 seconds of wall time. + if (windowSamples * period >= 100) { + final double avgMs = (windowNanos / windowSamples) / 1_000_000.0; + final String line = String.format( + "[BalloonStressTest] %d balloons, period=%d: avg %.3f ms/update (%.2f%% of a 50ms tick budget)", + STANDS.size(), period, avgMs, avgMs / 50.0 * 100.0); + Bukkit.getLogger().info(line); + if (reportTo != null) reportTo.sendMessage(line); + windowNanos = 0; + windowSamples = 0; + } + } +} diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/util/MathUtil.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/util/MathUtil.java new file mode 100644 index 000000000..82832628d --- /dev/null +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/util/MathUtil.java @@ -0,0 +1,25 @@ +package com.hibiscusmc.hmccosmetics.util; + +import org.bukkit.Location; + +public final class MathUtil { + + private MathUtil() {} + + public static double lerp(double start, double end, double t) { + return start + (end - start) * Math.clamp(t, 0.0, 1.0); + } + + public static Location lerpLocation(Location current, Location target, double horizontalT, double verticalT) { + Location result = target.clone(); + result.setX(lerp(current.getX(), target.getX(), horizontalT)); + result.setY(lerp(current.getY(), target.getY(), verticalT)); + result.setZ(lerp(current.getZ(), target.getZ(), horizontalT)); + return result; + } + + public static double lerpAngle(double start, double end, double t) { + double diff = ((end - start + 180) % 360 + 360) % 360 - 180; + return start + diff * Math.clamp(t, 0.0, 1.0); + } +} diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index a607a0c03..b5594f250 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -1,7 +1,7 @@ # # HMCCosmetics Configuration (Remapped) # -config-version: 1 +config-version: 2 default-menu: defaultmenu_hats debug-mode: false @@ -95,9 +95,10 @@ cosmetic-settings: # setting this to lower than the server player view distance should fix the # bug where players see random backpacks. Put -1 to ignore and send packets to everyone. view-distance: 32 - # If the plugin should set the pitch of balloons to always be 0, to prevent players looking up affecting the balloon. + # If the plugin should set the pitch of balloons to always be 0, so the balloon floats level instead of + # tilting with the player's head when they look up/down. # This only applies to models that have a "head" section to them. THIS DOES NOT IMPACT THE REST OF THE ENTITY. - balloon-head-unmoving: false + balloon-head-unmoving: true # Should, by default, the lead be spawned for the balloon balloon-lead-default: true # how the balloon should be positioned relative to the player @@ -105,6 +106,48 @@ cosmetic-settings: x: 0.5 y: 3 z: 0.5 + # ticks between balloon smoothing updates. 1 = every tick (smoothest), higher = less cpu cost. + # How closely the balloon follows is unaffected: the lerp factors below are corrected for this period. + # Default 2 (10 updates/sec) is smooth and roughly halves the per-tick teleport cost vs 1 - recommended + # for high player counts. 0 or -1 turns balloon smoothing off entirely (balloons then follow coarsely + # at the cosmetic tick-period instead). + balloon-lerp-period: 2 + # 0-1, how quickly the balloon catches up to the player horizontally (X/Z) each smoothing update. Higher = snappier/tighter follow, lower = more trailing. + balloon-position-lerp-factor: 0.35 + # 0-1, how quickly the balloon's height (Y) catches up. Lower than balloon-position-lerp-factor on purpose + # so jump spikes ease smoothly instead of snapping, while sustained climbs (stairs/ladders) still track. + balloon-vertical-lerp-factor: 0.15 + # 0-1, how quickly the balloon's facing (yaw) turns to match its movement direction. Independent of + # the player's look direction — the balloon only turns when it's actually moving, not on mouse-look. + balloon-yaw-lerp-factor: 0.15 + # + # The tilt and sway settings below only apply to item balloons. ModelEngine balloons are posed by + # ModelEngine itself and ignore them. + # + # 0-1, how quickly the balloon's lean/bank eases toward its target angle each smoothing update. + balloon-tilt-lerp-factor: 0.25 + # degrees, maximum lean/bank angle the balloon can reach. + balloon-max-tilt-angle: 25.0 + # scales horizontal movement speed into forward/back lean degrees. + balloon-tilt-forward-factor: 4.0 + # scales turning speed (yaw change) into side-to-side bank degrees. + balloon-tilt-side-factor: 6.0 + # degrees, amplitude of the gentle idle pendulum swing (pitch/roll). 0 disables the sway. + balloon-sway-angle: 0.0 + # ticks for one full sway circle. + balloon-sway-period: 90 + # + # The idle animations below never let a balloon settle, so every balloon on the server keeps costing a + # position update even while its owner stands still. They are off by default for that reason. + # + # blocks, how far the balloon gently drifts up/down while floating. 0 disables the bob. + balloon-bob-amplitude: 0.0 + # ticks for one full up-down bob cycle. + balloon-bob-period: 70 + # degrees, how far the balloon slowly wanders left/right in yaw while idle. 0 disables it. + balloon-idle-yaw-angle: 0.0 + # ticks for one full idle yaw wander cycle. + balloon-idle-yaw-period: 160 menu-settings: click-cooldown: diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 1ced9d0e33d9a66069368d224fe74c1d4cab5553 Mon Sep 17 00:00:00 2001 From: imonlyzuzu Date: Wed, 22 Jul 2026 15:23:04 +0300 Subject: [PATCH 2/5] feat: add balloon-sag-factor and balloon-max-sag settings --- .../java/com/hibiscusmc/hmccosmetics/config/Settings.java | 8 ++++++++ common/src/main/resources/config.yml | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/config/Settings.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/config/Settings.java index e92c4f3b3..63963411d 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/config/Settings.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/config/Settings.java @@ -65,6 +65,8 @@ public class Settings { private static final String COSMETIC_BALLOON_SWAY_PERIOD_PATH = "balloon-sway-period"; private static final String COSMETIC_BALLOON_IDLE_YAW_ANGLE_PATH = "balloon-idle-yaw-angle"; private static final String COSMETIC_BALLOON_IDLE_YAW_PERIOD_PATH = "balloon-idle-yaw-period"; + private static final String COSMETIC_BALLOON_SAG_FACTOR_PATH = "balloon-sag-factor"; + private static final String COSMETIC_BALLOON_MAX_SAG_PATH = "balloon-max-sag"; private static final String COSMETIC_OFFHAND_PREVENT_SWAPPING = "offhand-prevent-swapping"; private static final String MENU_SETTINGS_PATH = "menu-settings"; private static final String MENU_CLICK_COOLDOWN_PATH = "click-cooldown"; @@ -166,6 +168,10 @@ public class Settings { @Getter private static int balloonIdleYawPeriod; @Getter + private static double balloonSagFactor; + @Getter + private static double balloonMaxSag; + @Getter private static boolean backpackPreventDarkness; @Getter private static boolean preferHMCColorDyeMenu; @@ -299,6 +305,8 @@ public static void load(ConfigurationNode source) { balloonSwayPeriod = Math.max(1, cosmeticSettings.node(COSMETIC_BALLOON_SWAY_PERIOD_PATH).getInt(90)); balloonIdleYawAngle = loadNonNegative(cosmeticSettings, COSMETIC_BALLOON_IDLE_YAW_ANGLE_PATH, 0.0); balloonIdleYawPeriod = Math.max(1, cosmeticSettings.node(COSMETIC_BALLOON_IDLE_YAW_PERIOD_PATH).getInt(160)); + balloonSagFactor = loadNonNegative(cosmeticSettings, COSMETIC_BALLOON_SAG_FACTOR_PATH, 0.6); + balloonMaxSag = loadNonNegative(cosmeticSettings, COSMETIC_BALLOON_MAX_SAG_PATH, 0.4); backpackPreventDarkness = cosmeticSettings.node(BACKPACK_PREVENT_DARKNESS_PATH).getBoolean(true); ConfigurationNode menuSettings = source.node(MENU_SETTINGS_PATH); diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index b5594f250..ac79488e3 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -148,6 +148,11 @@ cosmetic-settings: balloon-idle-yaw-angle: 0.0 # ticks for one full idle yaw wander cycle. balloon-idle-yaw-period: 160 + # blocks the balloon dips downward for each block it lags behind its owner while moving, like a rope + # being dragged. Eases back up as the owner stops. 0 disables the effect. + balloon-sag-factor: 0.6 + # blocks, the maximum the balloon may dip. Caps the drop during sprints or teleport catch-up. + balloon-max-sag: 0.4 menu-settings: click-cooldown: From 3a08938bfff6258838a815c2003efed6733f7db8 Mon Sep 17 00:00:00 2001 From: imonlyzuzu Date: Wed, 22 Jul 2026 15:23:04 +0300 Subject: [PATCH 3/5] feat: balloon rope-drag sag while moving Balloon dips downward proportional to horizontal follow-lag and eases back up on stop, read off the lag BalloonSmoothingTask already computes. Applied as a display-only render offset next to the idle bob; clamped by balloon-max-sag. balloon-sag-factor: 0 is a no-op. --- .../user/manager/BalloonSmoothingTask.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java index 7d4f31e01..9e635733e 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java @@ -163,8 +163,12 @@ private void smooth(CosmeticUser user, Tuning tuning) { // Display-only offsets, kept off `base` so the bob and the wander never feed back into the // follow-lerp and compound tick over tick. + // Rope drag: the balloon rides lower the further it is lagging behind its owner horizontally, and + // eases back up as that lag decays on stop. Display-only, kept off `base` like the bob so it never + // feeds back into the follow-lerp. Clamped so a sprint or teleport catch-up can't drag it into the floor. + double dip = Math.min(tuning.sagFactor * Math.sqrt(horizontalLagSq), tuning.maxSag); Location render = base.clone(); - render.setY(render.getY() + bob); + render.setY(render.getY() + bob - dip); render.setYaw((float) renderYaw); // The head pose is applied in the armor stand's own frame, whose yaw is renderYaw (movement-driven), @@ -229,6 +233,8 @@ private record Tuning( int swayPeriod, double idleYawAngle, int idleYawPeriod, + double sagFactor, + double maxSag, boolean animated ) { @@ -252,6 +258,8 @@ static Tuning snapshot(int period) { Settings.getBalloonSwayPeriod(), idleYawAngle, Settings.getBalloonIdleYawPeriod(), + Settings.getBalloonSagFactor(), + Settings.getBalloonMaxSag(), bobAmplitude != 0 || swayAngle != 0 || idleYawAngle != 0 ); } From 19c995d5ab3b061525c44c1095f7e3b476753196 Mon Sep 17 00:00:00 2001 From: imonlyzuzu Date: Wed, 22 Jul 2026 15:35:32 +0300 Subject: [PATCH 4/5] refactor: share smoothing math between production and stress test Extract the per-balloon computation in BalloonSmoothingTask.smooth() into a public static step() (position lerp, idle bob/sway, rope-drag dip, movement yaw and tilt). BalloonStressTest now drives step() per synthetic balloon with real per-stand follow-lerp base, teleport dedup, and item-balloon head-pose tilt broadcast, so the benchmark measures the actual production path instead of a divergent hand-rolled copy. Production behavior unchanged. --- .../user/manager/BalloonSmoothingTask.java | 118 +++++++++++------- .../hmccosmetics/util/BalloonStressTest.java | 97 +++++++++++--- 2 files changed, 149 insertions(+), 66 deletions(-) diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java index 9e635733e..886632c82 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/user/manager/BalloonSmoothingTask.java @@ -115,9 +115,8 @@ private void smooth(CosmeticUser user, Tuning tuning) { double dx = target.getX() - current.getX(); double dy = target.getY() - current.getY(); double dz = target.getZ() - current.getZ(); - double horizontalLagSq = dx * dx + dz * dz; - if (horizontalLagSq + dy * dy > SNAP_DISTANCE_SQUARED) { + if (dx * dx + dz * dz + dy * dy > SNAP_DISTANCE_SQUARED) { // A long teleport would otherwise walk a real entity through a handful of intermediate // positions, force-loading chunks nobody is near, before the respawn in PlayerGameListener // catches up four ticks later. @@ -125,47 +124,95 @@ private void smooth(CosmeticUser user, Tuning tuning) { return; } + // Per-user phase offset so nearby balloons don't animate in lockstep. + double phase = (user.getUniqueId().hashCode() & 0xFFFF) * TWO_PI / 0x10000; + Step step = step(current, target, tick, phase, tuning); + double currentYaw = current.getYaw(); - double desiredYaw = horizontalLagSq > MOVING_DISTANCE_SQUARED - ? Math.toDegrees(Math.atan2(-dx, dz)) - : currentYaw; - double newYaw = MathUtil.lerpAngle(currentYaw, desiredYaw, tuning.yawFactor); + double newYaw = step.base().getYaw(); // Nothing to send once the balloon has caught up and no oscillator is running. if (!tuning.animated - && horizontalLagSq + dy * dy < SETTLED_DISTANCE_SQUARED + && dx * dx + dz * dz + dy * dy < SETTLED_DISTANCE_SQUARED && Math.abs(newYaw - currentYaw) < SETTLED_YAW && Math.abs(balloonManager.getTiltPitch()) < SETTLED_TILT && Math.abs(balloonManager.getTiltRoll()) < SETTLED_TILT) { return; } + balloonManager.setSmoothedBase(step.base()); + + Location render = step.render(); + double desiredPitch = step.desiredPitch(); + double desiredRoll = step.desiredRoll(); + + // Only teleport the entity when the rendered position actually changed by a visible amount. The + // tail of a lerp (and an idle balloon whose only motion is a settling tilt) otherwise pays a full + // entity teleport + tracker broadcast every tick for a sub-millimetre step. Tilt still updates via + // setTilt below, which self-dedups its own metadata packet. + Location lastRendered = balloonManager.getLastRendered(); + boolean rendered = lastRendered == null + || lastRendered.getWorld() != render.getWorld() + || lastRendered.distanceSquared(render) > RENDER_EPSILON_SQUARED + || Math.abs(render.getYaw() - lastRendered.getYaw()) > SETTLED_YAW; + + if (rendered) { + balloonManager.setLocation(render); + balloonManager.setLastRendered(render.clone()); + + // The armor stand is broadcast by the vanilla entity tracker; the only packet this task owes is + // for the pufferfish the lead is tied to. Reusing the entity's own viewer list avoids re-deriving + // it, and leaves balloons with show-lead off - whose pufferfish is never spawned client-side - free. + UserBalloonPufferfish pufferfish = balloonManager.getPufferfish(); + if (!pufferfish.getViewers().isEmpty()) pufferfish.teleport(render); + } + + balloonManager.setTilt( + MathUtil.lerp(balloonManager.getTiltPitch(), desiredPitch, tuning.tiltFactor), + MathUtil.lerp(balloonManager.getTiltRoll(), desiredRoll, tuning.tiltFactor) + ); + } + + /** + * The per-balloon smoothing computation, factored out so the synthetic {@link + * com.hibiscusmc.hmccosmetics.util.BalloonStressTest} benchmark exercises the exact same math - + * position lerp, idle bob/sway, rope-drag dip, movement yaw and tilt - as production, instead of a + * divergent copy that silently drifts. Pure: reads only its arguments, mutates nothing, and returns + * the new follow-lerp base (with movement yaw), the display location (base plus the display-only bob + * and dip and idle-yaw wander), and the target tilt the caller lerps toward. + */ + public static Step step(Location current, Location target, long tick, double phase, Tuning tuning) { + double dx = target.getX() - current.getX(); + double dz = target.getZ() - current.getZ(); + double horizontalLagSq = dx * dx + dz * dz; + + double currentYaw = current.getYaw(); + double desiredYaw = horizontalLagSq > MOVING_DISTANCE_SQUARED + ? Math.toDegrees(Math.atan2(-dx, dz)) + : currentYaw; + double newYaw = MathUtil.lerpAngle(currentYaw, desiredYaw, tuning.yawFactor); + Location base = MathUtil.lerpLocation(current, target, tuning.positionFactor, tuning.verticalFactor); base.setYaw((float) newYaw); - balloonManager.setSmoothedBase(base); - // Idle life: per-user phase offset so nearby balloons don't animate in lockstep. - double phase = (user.getUniqueId().hashCode() & 0xFFFF) * TWO_PI / 0x10000; + // Idle life: bob and sway ride on the per-user phase so nearby balloons don't animate in lockstep. double bob = tuning.bobAmplitude * Math.sin(TWO_PI * tick / tuning.bobPeriod + phase); double swayTheta = TWO_PI * tick / tuning.swayPeriod + phase; double swayPitch = tuning.swayAngle * Math.sin(swayTheta); double swayRoll = tuning.swayAngle * Math.cos(swayTheta); // cos: circular pendulum swing, not a diagonal line - // The wander is an idle behaviour, and it also rotates the frame the follow lag is decomposed in - // below - left alone it would bleed forward lean into the roll channel at speed. Fade it out as - // soon as the balloon is actually travelling. + // The wander also rotates the frame the follow lag is decomposed in below - left alone it would + // bleed forward lean into the roll channel at speed. Fade it out as soon as the balloon travels. double idleYaw = tuning.idleYawAngle == 0 ? 0 : tuning.idleYawAngle * (1 - Math.min(1, horizontalLagSq / IDLE_YAW_FADE_DISTANCE_SQUARED)) * Math.sin(TWO_PI * tick / tuning.idleYawPeriod + phase); - double renderYaw = newYaw + idleYaw; - // Display-only offsets, kept off `base` so the bob and the wander never feed back into the - // follow-lerp and compound tick over tick. - // Rope drag: the balloon rides lower the further it is lagging behind its owner horizontally, and - // eases back up as that lag decays on stop. Display-only, kept off `base` like the bob so it never - // feeds back into the follow-lerp. Clamped so a sprint or teleport catch-up can't drag it into the floor. + // Rope drag: rides lower the further it lags behind horizontally, eases back up as the lag decays. + // Display-only offsets, kept off `base` so the bob, dip and wander never feed back into the + // follow-lerp and compound tick over tick. Dip is clamped so a sprint or teleport catch-up can't + // drag it into the floor. double dip = Math.min(tuning.sagFactor * Math.sqrt(horizontalLagSq), tuning.maxSag); Location render = base.clone(); render.setY(render.getY() + bob - dip); @@ -188,37 +235,16 @@ private void smooth(CosmeticUser user, Tuning tuning) { double desiredPitch = Math.clamp(-forwardLag * tuning.tiltForwardFactor, -maxTilt, maxTilt) + swayPitch; double desiredRoll = Math.clamp(-sideLag * tuning.tiltSideFactor, -maxTilt, maxTilt) + swayRoll; - // Only teleport the entity when the rendered position actually changed by a visible amount. The - // tail of a lerp (and an idle balloon whose only motion is a settling tilt) otherwise pays a full - // entity teleport + tracker broadcast every tick for a sub-millimetre step. Tilt still updates via - // setTilt below, which self-dedups its own metadata packet. - Location lastRendered = balloonManager.getLastRendered(); - boolean rendered = lastRendered == null - || lastRendered.getWorld() != render.getWorld() - || lastRendered.distanceSquared(render) > RENDER_EPSILON_SQUARED - || Math.abs(render.getYaw() - lastRendered.getYaw()) > SETTLED_YAW; - - if (rendered) { - balloonManager.setLocation(render); - balloonManager.setLastRendered(render.clone()); - - // The armor stand is broadcast by the vanilla entity tracker; the only packet this task owes is - // for the pufferfish the lead is tied to. Reusing the entity's own viewer list avoids re-deriving - // it, and leaves balloons with show-lead off - whose pufferfish is never spawned client-side - free. - UserBalloonPufferfish pufferfish = balloonManager.getPufferfish(); - if (!pufferfish.getViewers().isEmpty()) pufferfish.teleport(render); - } - - balloonManager.setTilt( - MathUtil.lerp(balloonManager.getTiltPitch(), desiredPitch, tuning.tiltFactor), - MathUtil.lerp(balloonManager.getTiltRoll(), desiredRoll, tuning.tiltFactor) - ); + return new Step(base, render, desiredPitch, desiredRoll); } + /** Output of {@link #step}: new follow-lerp base, display location, and the target tilt to lerp toward. */ + public record Step(Location base, Location render, double desiredPitch, double desiredRoll) {} + /** * The configuration this run is using, resolved once instead of per balloon. */ - private record Tuning( + public record Tuning( boolean headForward, double positionFactor, double verticalFactor, @@ -238,7 +264,7 @@ private record Tuning( boolean animated ) { - static Tuning snapshot(int period) { + public static Tuning snapshot(int period) { double bobAmplitude = Settings.getBalloonBobAmplitude(); double swayAngle = Settings.getBalloonSwayAngle(); double idleYawAngle = Settings.getBalloonIdleYawAngle(); diff --git a/common/src/main/java/com/hibiscusmc/hmccosmetics/util/BalloonStressTest.java b/common/src/main/java/com/hibiscusmc/hmccosmetics/util/BalloonStressTest.java index 399889ea9..8f2c2dc55 100644 --- a/common/src/main/java/com/hibiscusmc/hmccosmetics/util/BalloonStressTest.java +++ b/common/src/main/java/com/hibiscusmc/hmccosmetics/util/BalloonStressTest.java @@ -14,6 +14,7 @@ import org.bukkit.command.CommandSender; import org.bukkit.entity.ArmorStand; import org.bukkit.inventory.ItemStack; +import org.bukkit.util.EulerAngle; import org.bukkit.persistence.PersistentDataType; import org.bukkit.plugin.Plugin; import org.bukkit.scheduler.BukkitTask; @@ -28,8 +29,11 @@ * stands - the exact entity {@link com.hibiscusmc.hmccosmetics.user.manager.UserBalloonManager} uses - * each carrying the caller's equipped balloon cosmetic (ModelEngine model or item helmet), and drives * every one of them toward a wandering target on the same cadence as {@link BalloonSmoothingTask} - * ({@code balloon-lerp-period}). The target keeps moving so all balloons are always lagging, i.e. the - * worst case where every balloon teleports every update. + * ({@code balloon-lerp-period}). Each balloon is stepped through {@link BalloonSmoothingTask#step} - the + * exact production math (position lerp, idle bob/sway, rope-drag dip, movement yaw and tilt) - and pays + * the same teleport dedup and item-balloon head-pose tilt broadcast, so the measured cost is the real + * path rather than a divergent copy. The target keeps moving so all balloons are always lagging, i.e. + * the worst case where every balloon teleports every update. *

* This reproduces the dominant per-balloon cost - a real {@link ArmorStand#teleport} plus the vanilla * entity-tracker broadcast to every nearby viewer, and the ModelEngine render for a real model - at @@ -45,12 +49,40 @@ public final class BalloonStressTest { private BalloonStressTest() {} - private static final List STANDS = new ArrayList<>(); + private static final List BALLOONS = new ArrayList<>(); private static BukkitTask task; private static Location anchor; private static long tick; private static boolean usedModelEngine; + // Dedup thresholds mirrored from BalloonSmoothingTask / UserBalloonManager so the benchmark skips the + // same sub-visible teleports and tilt packets production does. + private static final double RENDER_EPSILON_SQUARED = 0.001 * 0.001; + private static final double SETTLED_YAW = 0.05; + private static final double TILT_EPSILON = 0.05; + + // Per-balloon smoothing state, mirroring what UserBalloonManager holds for a real balloon, so the + // benchmark can drive BalloonSmoothingTask.step() exactly like production: the follow-lerp base is + // tracked apart from the rendered (bob + dip) location so the display offsets never feed back into it, + // and the sent tilt is tracked apart from the logical tilt so the head-pose packet only goes out on a + // visible change. + private static final class Balloon { + final ArmorStand stand; + final double phase; + Location base; + Location lastRendered; + double tiltPitch; + double tiltRoll; + double sentTiltPitch; + double sentTiltRoll; + + Balloon(ArmorStand stand, double phase, Location base) { + this.stand = stand; + this.phase = phase; + this.base = base; + } + } + // Self-timing accumulators, reset each reporting window. private static double windowNanos; private static long windowSamples; @@ -61,7 +93,7 @@ public static synchronized boolean isRunning() { } public static synchronized int count() { - return STANDS.size(); + return BALLOONS.size(); } /** @@ -103,7 +135,10 @@ public static synchronized void start(@NotNull Plugin plugin, @NotNull Location e.getPersistentDataContainer().set(HMCCServerUtils.getCosmemeticMobKey(), PersistentDataType.BOOLEAN, true); }); if (modelEngine) applyModel(stand, balloon.getModelName()); - STANDS.add(stand); + // Golden-angle phase spread so the idle bob/sway of adjacent balloons is out of lockstep, + // the same de-synchronisation UserBalloonManager gets from the per-user UUID hash. + final double phase = 2.399963229728653 * i; + BALLOONS.add(new Balloon(stand, phase, spawn.clone())); } final int period = Math.max(1, Settings.getBalloonLerpPeriod()); @@ -129,7 +164,8 @@ public static synchronized void stop() { task.cancel(); task = null; } - for (ArmorStand stand : STANDS) { + for (Balloon b : BALLOONS) { + final ArmorStand stand = b.stand; if (stand == null || !stand.isValid()) continue; if (usedModelEngine) { ModeledEntity me = ModelEngineAPI.getModeledEntity(stand); @@ -137,7 +173,7 @@ public static synchronized void stop() { } stand.remove(); } - STANDS.clear(); + BALLOONS.clear(); anchor = null; reportTo = null; usedModelEngine = false; @@ -151,20 +187,41 @@ private static synchronized void run() { final double t = tick * 0.02; final Location target = anchor.clone().add(Math.sin(t) * 8.0, Math.sin(t * 0.5) * 2.0, Math.cos(t) * 8.0); - final double posFactor = 1 - Math.pow(1 - 0.35, period); - final double vertFactor = 1 - Math.pow(1 - 0.15, period); - final double yawFactor = 1 - Math.pow(1 - 0.15, period); + // The exact tuning production resolves once per run - real lerp factors, dip, bob, sway, tilt. + final BalloonSmoothingTask.Tuning tuning = BalloonSmoothingTask.Tuning.snapshot(period); final long start = System.nanoTime(); - for (ArmorStand stand : STANDS) { - if (!stand.isValid()) continue; - final Location cur = stand.getLocation(); - final double dx = target.getX() - cur.getX(); - final double dz = target.getZ() - cur.getZ(); - final double desiredYaw = Math.toDegrees(Math.atan2(-dx, dz)); - final Location render = MathUtil.lerpLocation(cur, target, posFactor, vertFactor); - render.setYaw((float) MathUtil.lerpAngle(cur.getYaw(), desiredYaw, yawFactor)); - stand.teleport(render); + for (Balloon b : BALLOONS) { + if (!b.stand.isValid()) continue; + + // The exact per-balloon computation production runs, from this balloon's own follow-lerp base. + final BalloonSmoothingTask.Step step = BalloonSmoothingTask.step(b.base, target, tick, b.phase, tuning); + b.base = step.base(); + + // Teleport dedup, mirroring BalloonSmoothingTask: skip a sub-visible step so the benchmark + // does not overcount teleports the real path would have elided. + final Location render = step.render(); + if (b.lastRendered == null + || b.lastRendered.getWorld() != render.getWorld() + || b.lastRendered.distanceSquared(render) > RENDER_EPSILON_SQUARED + || Math.abs(render.getYaw() - b.lastRendered.getYaw()) > SETTLED_YAW) { + b.stand.teleport(render); + b.lastRendered = render.clone(); + } + + // Movement tilt. An item balloon pays a head-pose metadata broadcast on a visible change - the + // real cost production incurs and the old benchmark ignored entirely. A ModelEngine balloon + // reads its own transform data and never the stand's head pose, so it is not posed (matching + // UserBalloonManager.setTilt), which is why this is gated on !usedModelEngine. + b.tiltPitch = MathUtil.lerp(b.tiltPitch, step.desiredPitch(), tuning.tiltFactor()); + b.tiltRoll = MathUtil.lerp(b.tiltRoll, step.desiredRoll(), tuning.tiltFactor()); + if (!usedModelEngine + && (Math.abs(b.tiltPitch - b.sentTiltPitch) >= TILT_EPSILON + || Math.abs(b.tiltRoll - b.sentTiltRoll) >= TILT_EPSILON)) { + b.sentTiltPitch = b.tiltPitch; + b.sentTiltRoll = b.tiltRoll; + b.stand.setHeadPose(new EulerAngle(Math.toRadians(b.tiltPitch), 0, Math.toRadians(b.tiltRoll))); + } } final long elapsed = System.nanoTime() - start; @@ -176,7 +233,7 @@ private static synchronized void run() { final double avgMs = (windowNanos / windowSamples) / 1_000_000.0; final String line = String.format( "[BalloonStressTest] %d balloons, period=%d: avg %.3f ms/update (%.2f%% of a 50ms tick budget)", - STANDS.size(), period, avgMs, avgMs / 50.0 * 100.0); + BALLOONS.size(), period, avgMs, avgMs / 50.0 * 100.0); Bukkit.getLogger().info(line); if (reportTo != null) reportTo.sendMessage(line); windowNanos = 0; From 3ca73238b2f9159208fc7c0f31ef1c8be3e8f675 Mon Sep 17 00:00:00 2001 From: imonlyzuzu Date: Wed, 22 Jul 2026 15:44:24 +0300 Subject: [PATCH 5/5] config: ship tuned balloon defaults Set the bundled default balloon tuning to the values validated in production (faster follow, stronger tilt, idle sway/bob/yaw, softer sag) so fresh installs match the tuned feel. Non-balloon defaults and DB placeholders untouched. --- common/src/main/resources/config.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/common/src/main/resources/config.yml b/common/src/main/resources/config.yml index ac79488e3..641dbd5bf 100644 --- a/common/src/main/resources/config.yml +++ b/common/src/main/resources/config.yml @@ -111,9 +111,9 @@ cosmetic-settings: # Default 2 (10 updates/sec) is smooth and roughly halves the per-tick teleport cost vs 1 - recommended # for high player counts. 0 or -1 turns balloon smoothing off entirely (balloons then follow coarsely # at the cosmetic tick-period instead). - balloon-lerp-period: 2 + balloon-lerp-period: 1 # 0-1, how quickly the balloon catches up to the player horizontally (X/Z) each smoothing update. Higher = snappier/tighter follow, lower = more trailing. - balloon-position-lerp-factor: 0.35 + balloon-position-lerp-factor: 0.12 # 0-1, how quickly the balloon's height (Y) catches up. Lower than balloon-position-lerp-factor on purpose # so jump spikes ease smoothly instead of snapping, while sustained climbs (stairs/ladders) still track. balloon-vertical-lerp-factor: 0.15 @@ -125,15 +125,15 @@ cosmetic-settings: # ModelEngine itself and ignore them. # # 0-1, how quickly the balloon's lean/bank eases toward its target angle each smoothing update. - balloon-tilt-lerp-factor: 0.25 + balloon-tilt-lerp-factor: 1.0 # degrees, maximum lean/bank angle the balloon can reach. - balloon-max-tilt-angle: 25.0 + balloon-max-tilt-angle: 65.0 # scales horizontal movement speed into forward/back lean degrees. - balloon-tilt-forward-factor: 4.0 + balloon-tilt-forward-factor: 8.0 # scales turning speed (yaw change) into side-to-side bank degrees. - balloon-tilt-side-factor: 6.0 + balloon-tilt-side-factor: 12.0 # degrees, amplitude of the gentle idle pendulum swing (pitch/roll). 0 disables the sway. - balloon-sway-angle: 0.0 + balloon-sway-angle: 2.5 # ticks for one full sway circle. balloon-sway-period: 90 # @@ -141,18 +141,18 @@ cosmetic-settings: # position update even while its owner stands still. They are off by default for that reason. # # blocks, how far the balloon gently drifts up/down while floating. 0 disables the bob. - balloon-bob-amplitude: 0.0 + balloon-bob-amplitude: 0.08 # ticks for one full up-down bob cycle. balloon-bob-period: 70 # degrees, how far the balloon slowly wanders left/right in yaw while idle. 0 disables it. - balloon-idle-yaw-angle: 0.0 + balloon-idle-yaw-angle: 4.0 # ticks for one full idle yaw wander cycle. balloon-idle-yaw-period: 160 # blocks the balloon dips downward for each block it lags behind its owner while moving, like a rope # being dragged. Eases back up as the owner stops. 0 disables the effect. - balloon-sag-factor: 0.6 + balloon-sag-factor: 0.2 # blocks, the maximum the balloon may dip. Caps the drop during sprints or teleport catch-up. - balloon-max-sag: 0.4 + balloon-max-sag: 2.0 menu-settings: click-cooldown: