chore(history): squash 16 commit(s) from 2026-09-11

- refactor(7): Cosmetics overhaul — built-in ChinaHat/TazikHat/BackSword в слоты, файлы ≤200, i18n en+ru, тесты
- docs(7): Cosmetics Overhaul завершён; 8.5.2 — TazikHat 859 закрыт портированием
- perf(9.1): glGetError только когда clip активен или stencil-debug
- perf(9.1): isVulkanBackend() кэшируется после инициализации устройства
- perf(9.1): PreTranslucentDepth copy по запросу потребителя, не каждый кадр
- perf(9.1): copyMainColor в captureWorldSource только при запрошенном blur
- perf(9.1): ленивые лейблы профайлера — склейка строк только с dev-профайлером
- perf(9.1): переиспользуемые scratch-объекты в GameRendererMixin вместо new/кадр
- docs(9.1): мёртвые грузы FPS закрыты — 6/6 пунктов (137121e…4d60d3d); 9.3 A/B мерка не снята
- revert(9.1): Frustum-reuse через @Invoker убран — краш на реальном рантайме
- docs(9.1): пометка — Frustum-reuse отменён, @Invoker падает на remapped-классе
- feat(gui): tooltip с описанием при наведении на строку настроек (включая function-бинды)
- feat(7.x): волна 17:07–18:03 (параллельная сессия) — checkpoint перед доделкой
- feat(7): ShaderSky — завершён режим Starry (звёздное небо с луной)
- feat(7): порт Wings (WiNGS.zip) — крылья на спину, 8 типов, взмахи по позам
- fix(7): KineticLyrics — симметрия resync (пауза/seek) + tooltip-описания настроек синха
This commit is contained in:
loki5512344 2026-09-11 19:59:55 +02:00
parent 573780cd5f
commit e06ebc26ff
69 changed files with 1802 additions and 2835 deletions

3
.gitignore vendored
View file

@ -35,3 +35,6 @@ ref/
# IDE/Build artifacts
.factorypath
**/bin/
.claude/
docs/
.ruff_cache/

103
TODO.md
View file

@ -28,12 +28,13 @@
- [x] Фаза 4: Security (BackdoorProtection, StaffTracker)
- [x] Фаза 5: Нормализация модулей (ленивый Minecraft, GameClock)
- [x] Фаза 6: Тесты (EventBus, конфиг, утилиты) — см. `combatant-client-26.2/TODO.md`
- [ ] Фаза 7: Новые модули по мотивам SoupVisuals — см. ниже
- [ ] Фаза 7: Новые модули по мотивам SoupVisuals — Cosmetics Overhaul ЗАВЕРШЁН 2026-09-11,
см. ниже (остатки: Gps, HitBubbles, AmbientParticles, EndCrystal, Trinket, Balancer, HandsShader)
- [ ] Фаза 8.5: Аудит структуры ≤200/≤4 — 8.5.1 (папки >4) ЗАВЕРШЕНА 2026-09-10
(0 папок >4, коммиты 8846d53…303b17e); далее 8.5.2 (гиганты >600),
план в `combatant-client-26.2/TODO.md`
- [ ] Фаза 9: Оптимизация FPS (9.1 фиксы мёртвых грузов → 9.2 модуль Optimize),
план в `combatant-client-26.2/TODO.md`
- [ ] Фаза 9: Оптимизация FPS — 9.1 (мёртвые грузы) ЗАВЕРШЕНА 2026-09-11 (137121e…4d60d3d);
9.2 Optimize гейтится Фазой 8.5, 9.3 A/B-мерка needs запуск на Windows
---
@ -63,87 +64,21 @@
иконки-спутники (Atom/Ball/Cat/Donut/Flame/Flower/Gem/Ghost/MCTiers), трейл
(цвет sync/custom, длина, толщина), звуки (типы + громкость), частицы
(у Soup 315 стр.). Референс: `other/Trinket.java`
- [ ] **Cosmetics Overhaul (Unified Cosmetics)** — сейчас модуль бесполезен (next/prev
keybind без экрана). План (обсуждён в брейнсторме, черновик, детали могут
измениться):
- **Слоты вместо типов**: новый `CosmeticSlot` enum — `MODEL, PET, HEAD,
SHOULDERS, BACK, WAIST, LEGS, HELD` (8 шт). `CosmeticType` (MODEL/PET/ACCESSORY)
остаётся внутри `CosmeticModel` как есть (анимация/рендер-логика не трогается).
- Аксессуары (ACCESSORY) получают поле `slot` — парсится из `avatar.json`
(`"slot": "head"`), при отсутствии — вывод по имени папки (существующая
эвристика witch/hat→HEAD, wing→BACK, wheel→WAIST, иначе→HELD).
- `CosmeticManager`: `EnumMap<CosmeticSlot, CosmeticModel>` вместо 3 полей.
MODEL/PET — по одному активному. 6 accessory-слотов — **можно носить
несколько одновременно** (шляпа + крылья + пояс разом).
- Экран `CosmeticsScreen`: левая панель — живая 3D-модель игрока
(`InventoryScreen.renderEntityInInventoryFollowsMouse` или свой вызов
EntityRenderDispatcher), drag ЛКМ по превью — вращение (yaw/pitch,
pitch ограничен ±30°), скролл — zoom (0.5–2.0), кнопка сброса угла.
Открывается через **ПКМ по модулю в ClickGUI** (спецкейс вместо обычной
settings-панели — прецедент есть в `ClickGuiEditorScreen`).
- Hotspot-зоны поверх 3D-модели (HEAD/SHOULDERS/BACK/WAIST/LEGS + иконка
HELD сбоку) — клик открывает правую панель выбора модели для этого слота.
- Offset/rotation/scale — **на слот** (не на модель, т.к. модулей может быть
десятки, а статичных SettingDef-полей на каждую не сделать): 6 стандартных
num()-хелперов модуля (per-slot), visibleWhen(активный слот). Смена модели
в слоте может потребовать подстройки — ок, вписывается в существующий
SettingDef/SettingFactory конвейер без новой инфраструктуры.
- Цвета из Theme.theme() (windowBg/accent/textMuted), файлы по правилам
≤200 строк/≤4 файла на папку: `CosmeticsModule`, `CosmeticsScreen`,
`CosmeticsSlot`(=CosmeticSlot), `CosmeticsSlotPanel`.
- i18n: `module.cosmetics.*`, `setting.cosmetics.active_slot`,
`offset_x/y/z`, `rotation`, `scale`, `setting.cosmetics.slot.<slot>` и т.д.
(en_us + ru_ru обязательно).
- Открытые вопросы для следующего захода: точные хитбокс-координаты зон
по проценту региона превью, формат хранения per-slot offset в .lvcfg,
нужен ли предпросмотр модели до эквипа в правой панели.
- **Портирование процедурных head/back-модулей в слоты** — из всех
`visuals/*` только 3 модуля реально являются «надетыми на игрока»
процедурными косметиками (нашёл через grep по HeadAnchor-хелперу):
- `ChinaHat` (+`ChinaHatModel`, `ChinaHatPalette`, 295+107+144 стр.) —
конус на голове → встроенная опция слота **HEAD**.
- `TazikHat` (859 стр.! требует разбивки на файлы ≤200 стр. при
портировании — геометрия/палитра/модуль отдельно, как у ChinaHat) —
«тазик»/чаша на голове → **HEAD**.
- `BackSword` (287 стр.) — катана на спине → **BACK**.
Остальные visuals-модули (ESP, Chams, Trails, Rain, PostFX и т.д.) —
НЕ переносятся, это общие эффекты, а не надеваемые предметы.
План: каждый становится встроенным вариантом (built-in entry, не
.bbmodel) внутри соответствующего слота — юзер выбирает между
своими .bbmodel-моделями И этими процедурными built-in вариантами
в одном и том же слоте. Настройки самого варианта (цвет/размер/сегменты
и т.д.) показываются в правой панели слота, когда built-in выбран
активным. Старые `@ModuleInfo`-модули `chinahat`/`tazikhat`/`backsword`
удаляются как отдельные модули из списка Visuals (их функциональность
полностью переезжает в Cosmetics) — конфиг-миграция/бэккомпат не нужны
(легит-клиент, публичного релиза со старыми конфигами ещё не было —
уточнить у пользователя перед удалением).
- **"None" в каждом слоте** — обязательный первый вариант в списке выбора
любого слота (Model/Pet/Head/Shoulders/Back/Waist/Legs/Held): "ничего
не надето", слот пуст, `CosmeticManager.active` для этого слота = null.
Не путать с выключенным модулем `CosmeticDisplay` целиком — None это
per-slot состояние, остальные слоты продолжают рендериться.
- Подтверждено пользователем: точно только 3 built-in кандидата
(ChinaHat/TazikHat/BackSword), остальные visuals-модули не переносятся.
- **Решённые вопросы (финал черновика):**
1. Hotspot-координаты — **вариант B**: проекция костей игрока (голова/тело/
ноги vanilla-модели) в экранные координаты через `ScreenProjection`
(уже есть в `render/helpers/`), а не фиксированные проценты региона —
точнее держит зоны при вращении/приседе. Fallback на фикс-проценты,
если в реализации окажется дороже, чем стоит.
2. Хранение offset/rot/scale — **обычные `NumberValue`-поля модуля**
(6 слотов × offset_x/y/z/rot/scale), сохраняются в `.lvcfg` как
обычные настройки — без нового ConfigValue-типа.
3. Правая панель — **live-apply**: клик по модели в списке слота сразу
применяет и показывает на превью, без отдельной кнопки Apply.
4. Built-in процедурные варианты (ChinaHat/TazikHat/BackSword) — **просто
обычные записи в списке слота** (с иконкой-превью), не выделяются в
отдельную секцию; настройки открываются так же, как у .bbmodel-моделей.
5. `TazikHat` (859 стр.) при портировании — **разбить как ChinaHat**:
`TazikHatGeometry` + `TazikHatPalette` + встраиваемый рендер-класс без
собственного `@ModuleInfo`/Module-обвязки (вызывается из слота HEAD).
6. Старые модули `chinahat`/`tazikhat`/`backsword` — **удаляются
полностью** из списка Visuals, функциональность только в Cosmetics.
- [x] **Cosmetics Overhaul (Unified Cosmetics)** — ЗАВЕРШЁН 2026-09-11:
слоты `CosmeticSlot` (MODEL/PET/HEAD/SHOULDERS/BACK/WAIST/LEGS/HELD), парсинг
`slot` из avatar.json + эвристика по имени папки, `CosmeticManager` на EnumMap
(аксессуары — несколько одновременно), `CosmeticsScreen` (3D-превью drag/zoom +
хотспоты + правая панель live-apply с "None" первым), per-slot offset/rot/scale
через NumberValue-поля, built-in ChinaHat/TazikHat/BackSword в `cosmetics/builtin/`
(старые модули chinahat/tazikhat/backsword удалены), i18n en+ru, тесты
CosmeticSlotTest/CosmeticsHotspotTest.
- ~~CosmeticDisplay 232 / TazikHatRenderer 242 / BackSwordRenderer 231 >200~~ —
разбиты: CosmeticDisplay 196 + CosmeticBuiltins (значения+диспетчер built-in),
TazikHat 186 (палитра-математика переиспользует ChinaHatPalette), BackSword 200
(edge/glow слиты в параметризованный drawKatana).
- Не сделано из плана (следующий заход): иконки-превью built-in в списке слота,
правка слотов SHOULDERS/LEGS/PET из экрана (сейчас только HEAD/BACK built-in),
предпросмотр до эквипа.
- [ ] **Balancer** (mctiers) — тиры игроков с mctiers.info (у Soup отдельная категория
MCTiers). Референс: `mctiers/Balancer.java`
- [ ] **HandsShader: новые шейдеры руки** — у Soup 10 режимов (Solid, Chroma, Balatro,

View file

@ -315,7 +315,8 @@
- [ ] ModulesMenuScreen 960 — рендер категорий отдельно от состояния
- [ ] UiMeshGeometry 954 — по примитивам геометрии
- [ ] BetterTooltips 953, OrderedUiBatcher 935 (→ batches + blur), Scoreboard 924,
Rain 893 (+CompactHudStatModel 893), TazikHat 859 (обещан в Фазе 7 — разбить при портировании),
Rain 893 (+CompactHudStatModel 893), TazikHat 859 ✓ закрыт портированием в
Cosmetics built-in (2026-09-11, см. Фазу 7),
ShaderPatchCompiler 843, OnlineRelationPlayerPickerComponent 837, CustomTabList 830,
GameRendererMixin 810 (только хуки; тело вынести в `render/engine/entry/`-классы —
ВНИМАНИЕ: 9.1 тоже его трогает, координировать), Predictions 805
@ -351,30 +352,30 @@
> вместо неё — ручной модуль **Optimize** (включил → порезали стекло/blur/тяжёлые проходы).
### 9.1 Мёртвые грузы (чиним в коде всегда, независимо от модулей) — приоритет
- [ ] **glGetError на каждом render pass** — `GlStencilShapeClipBackend.endRenderPass`
- [x] **glGetError на каждом render pass** — ГОТОВО 2026-09-11 (коммит 137121e): гейт по clip-active || stencil-debug — `GlStencilShapeClipBackend.endRenderPass`
(`:140`) и `beginRenderPass` (`:98/:104/:110/:124`) дёргают `checkGlErrors`
(`:428-434`) на КАЖДЫЙ проход (миксин `CommandEncoderMixin:102-112` висит на
submitRenderPass → это и все ванильные/Sodium проходы). `glGetError` = синк с
Windows-драйвером, несколько мс/кадр. Фикс: проверять только если
`mode != DISABLED || renderPassAttachmentRequired`, либо под dev-флагом;
в релизе — no-op.
- [ ] **`lovisual$isVulkanBackend()` на каждый submitRenderPass**
- [x] **`lovisual$isVulkanBackend()` на каждый submitRenderPass** — ГОТОВО (c9a65b1), кэш Boolean
(`CommandEncoderMixin:66-74`) — `getDeviceInfo().backendName().toLowerCase()`
заново на кадр×проходы. Фикс: вычислить один раз, закэшировать boolean.
- [ ] **PreTranslucentDepth — полноэкранный depth-copy каждый кадр без нужды** —
- [x] **PreTranslucentDepth — полноэкранный depth-copy каждый кадр без нужды** — ГОТОВО (b747b47): demand-флаг requestForNextFrame()/takeRequested() у потребителей getDepthView(For), capture в vanilla- и Iris-путях по запросу (1-кадр задержка, фолбэк на live view) —
`LevelRendererMixin:275-283` гейтится только «нет Iris». Фикс: гейт
`needsPreTranslucentDepthCapture()` по образцу `WorldSceneDepth`
(`LevelRendererMixin:160-218`); потребители: `PostProcessGraphResources:61`,
`LoVisualVisuals:79`, `WorldPassCompiler:174`.
- [ ] **copyMainColor каждый кадр ДО гейта** — `UiBlurResources.captureWorldSource`
- [x] **copyMainColor каждый кадр ДО гейта** — ГОТОВО (118eb2a): ранний return по liquidGlassBlurRequested — `UiBlurResources.captureWorldSource`
(`:116-123`): полноэкранный color-copy делается до проверки
`liquidGlassBlurRequested` (`:122`). Фикс: переставить — copy только когда
blur реально запрошен.
- [ ] **Строки профайлера при выключенном профайлере** — конкатенации на call-site
- [x] **Строки профайлера при выключенном профайлере** — ГОТОВО (aa55c4d): 2-arg scope()/section()/postPass(prefix,suffix) в ProfilerPhase/RenderProfiler2D/3D/RenderCostProfiler (DEV-гейт конкатенации) — конкатенации на call-site
каждый кадр: `PostProcessGraph:59` (`"graph:" + phase`), `ModuleManager:219-221/:290`
(`"3d:module:" + m.name()`). Фикс: ленивые supplier'ы / ранний isEnabled-check.
- [ ] **Мелочь, по ходу**: `new PoseStack`/`new Matrix4f`×2/`new Frustum`×2 в кадре
(`GameRendererMixin:203-239/:341-347/:609-617`) — закэшить переиспользуемые копии.
- [x] **Мелочь, по ходу**: `new PoseStack`/`new Matrix4f`×2/`new Frustum`×2 в кадре
(`GameRendererMixin:203-239/:341-347/:609-617`) — закэшить переиспользуемые копии. Frustum-часть отменена (da6525c): @Invoker("calculateFrustum") не резолвится на remapped-рантайме = краш; scratch-кэш матриц/PoseStack остался.
### 9.2 Модуль **Optimize** (misc) — ручной рубильник экономии
> ⛔ ГЕЙТ: не начинать, пока не закрыта Фаза 8.5 (рефакторинг-аудитStructure ниже) —

View file

@ -42,21 +42,17 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti
private final ModeValue debug =
new ModeValue("debug", "off", "off", "error_only", "error_and_warnings", "info", "config", "render_thread", "stencil", "serverdebug", "all");
//@CFGComment("Force PvP mode ON everywhere (dev/testing only)")
private final BooleanValue forcePvp =
new BooleanValue("forcePvp", false);
//@CFGComment("Menu background shader: off / waves / aurora")
private final ModeValue menuBg =
new ModeValue("menuBg", "off", "off", "waves", "aurora");
new ModeValue("menuBg", "waves", "off", "waves", "aurora");
//@CFGComment("Use ClickGUI theme colors for menu background shader")
private final BooleanValue menuBgUseTheme =
new BooleanValue("menuBgUseTheme", false);
new BooleanValue("menuBgUseTheme", true);
//@CFGComment("Show seconds in the custom main menu clock")
private final BooleanValue menuClockShowSeconds =
new BooleanValue("menuClockShowSeconds", false);
new BooleanValue("menuClockShowSeconds", true);
//@CFGComment("MSAA for world render: off / 2x / 4x")
private final ModeValue msaa3d =
@ -192,10 +188,6 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti
DebugLog.setMode(getDebugMode());
}
public boolean isForcePvp() {
return forcePvp.get();
}
public String getMenuBackgroundMode() {
return menuBg.get();
}
@ -365,7 +357,6 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti
public List<SettingDef> getMiscellaneousSettingDefs() {
List<SettingDef> list = new ArrayList<>();
list.add(SettingDef.mode(debug));
list.add(SettingDef.bool(forcePvp));
list.add(SettingDef.bool(disableNarrator));
return list;
}
@ -440,7 +431,6 @@ public final class MainConfig implements ConfigObject, ConfigNameProvider, Setti
public List<ConfigValue<?>> getConfigValues() {
List<ConfigValue<?>> list = new ArrayList<>();
list.add(debug);
list.add(forcePvp);
list.add(menuBg);
list.add(menuClockShowSeconds);
list.add(menuBgUseTheme);

View file

@ -0,0 +1,41 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.events.impl.render;
import net.minecraft.core.particles.ParticleOptions;
import dev.loki.lovisual.events.Event;
public class ParticleSpawnEvent extends Event {
private final ParticleOptions options;
private final double x;
private final double y;
private final double z;
public ParticleSpawnEvent(ParticleOptions options, double x, double y, double z) {
this.options = options;
this.x = x;
this.y = y;
this.z = z;
}
public ParticleOptions options() {
return options;
}
public double x() {
return x;
}
public double y() {
return y;
}
public double z() {
return z;
}
}

View file

@ -1,92 +0,0 @@
package dev.loki.lovisual.features.cosmetic.animation;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.cosmetics.CosmeticDisplay;
import dev.loki.lovisual.features.cosmetic.model.CosmeticModel;
import dev.loki.lovisual.features.cosmetic.model.CosmeticSlot;
public final class CosmeticAttachmentTransforms {
private CosmeticAttachmentTransforms() {
}
/**
* Base attachment transform for a slot, then the user's per-slot offset/rotation/scale
* override from {@link CosmeticDisplay} (accessory slots only).
*/
public static void apply(PoseStack matrices, CosmeticSlot slot, CosmeticModel model) {
applyBase(matrices, slot);
if (slot.isAccessory()) {
applyUserOverride(matrices, slot);
}
}
private static void applyUserOverride(PoseStack matrices, CosmeticSlot slot) {
CosmeticDisplay module = Modules.get(CosmeticDisplay.class);
if (module == null) return;
CosmeticDisplay.SlotOffsetSettings offsets = module.offsetsFor(slot);
if (offsets == null) return;
matrices.translate(offsets.offsetX().get(), offsets.offsetY().get(), offsets.offsetZ().get());
matrices.mulPose(Axis.YP.rotationDegrees(offsets.rotation().get()));
float scale = offsets.scale().get();
matrices.scale(scale, scale, scale);
}
private static void applyBase(PoseStack matrices, CosmeticSlot slot) {
switch (slot) {
case PET -> {
// TODO: replace with CosmeticAnimator.getPetHoverOffset() once the animator is ported.
float bob = 0f;
matrices.mulPose(Axis.YP.rotationDegrees(180f));
matrices.translate(0.45f, 2.15f + bob, 0.12f);
matrices.scale(0.55f, 0.55f, 0.55f);
}
case HEAD -> {
matrices.translate(0f, 1.72f, 0f);
matrices.scale(0.52f, 0.52f, 0.52f);
}
case SHOULDERS -> {
matrices.translate(0f, 1.45f, 0f);
matrices.scale(0.5f, 0.5f, 0.5f);
}
case BACK -> {
matrices.translate(0f, 0.1f, -0.3f);
matrices.mulPose(Axis.YP.rotationDegrees(180f));
matrices.scale(0.85f, 0.85f, 0.85f);
}
case WAIST -> {
matrices.translate(0f, 0.55f, 0.25f);
matrices.scale(0.55f, 0.55f, 0.55f);
}
case LEGS -> {
matrices.translate(0f, 0.3f, 0f);
matrices.scale(0.5f, 0.5f, 0.5f);
}
case HELD -> {
matrices.translate(0f, 1.3f, 0f);
matrices.scale(0.5f, 0.5f, 0.5f);
}
case MODEL -> {
// no base transform: full-body model replaces the player 1:1
}
}
}
public static void applyPreview(PoseStack matrices, CosmeticModel model) {
if (model == null) return;
CosmeticSlot slot = model.getSlot();
if (slot == CosmeticSlot.PET) {
matrices.translate(6f, 22f, 1f);
matrices.scale(1.8f, 1.8f, 1.8f);
} else if (slot == CosmeticSlot.HEAD) {
matrices.translate(0f, 20f, 0f);
} else if (slot == CosmeticSlot.BACK) {
matrices.translate(0f, 2f, -4f);
matrices.scale(1.3f, 1.3f, 1.3f);
} else if (slot == CosmeticSlot.WAIST) {
matrices.translate(0f, 6f, 3f);
}
}
}

View file

@ -1,225 +0,0 @@
package dev.loki.lovisual.features.cosmetic.animation;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import dev.loki.lovisual.features.cosmetic.model.CosmeticBone;
import dev.loki.lovisual.features.cosmetic.model.CosmeticModel;
public final class CosmeticBbAnimation {
private final String name;
private final float length;
private final boolean loop;
private final Map<String, BoneTrack> tracksByBoneUuid = new java.util.HashMap<>();
public CosmeticBbAnimation(String name, float length, boolean loop) {
this.name = name;
this.length = Math.max(length, 1f / 20f);
this.loop = loop;
}
public String getName() {
return name;
}
public float getLength() {
return length;
}
public boolean isLoop() {
return loop;
}
public Map<String, BoneTrack> getTracks() {
return tracksByBoneUuid;
}
public static Map<String, CosmeticBbAnimation> parseAll(JsonObject bbmodel) {
Map<String, CosmeticBbAnimation> map = new LinkedHashMap<>();
if (!bbmodel.has("animations")) return map;
for (JsonElement el : bbmodel.getAsJsonArray("animations")) {
if (!el.isJsonObject()) continue;
JsonObject obj = el.getAsJsonObject();
String name = obj.has("name") ? obj.get("name").getAsString() : "anim";
if (name.startsWith("--") || name.isBlank()) continue;
float length = obj.has("length") ? obj.get("length").getAsFloat() : 1f;
boolean loop = !obj.has("loop") || obj.get("loop").getAsBoolean();
CosmeticBbAnimation anim = new CosmeticBbAnimation(name, length, loop);
if (obj.has("animators")) {
JsonObject animators = obj.getAsJsonObject("animators");
for (Map.Entry<String, JsonElement> entry : animators.entrySet()) {
if (!entry.getValue().isJsonObject()) continue;
JsonObject boneAnim = entry.getValue().getAsJsonObject();
String boneUuid = entry.getKey();
if (boneAnim.has("uuid")) {
boneUuid = boneAnim.get("uuid").getAsString();
}
BoneTrack track = BoneTrack.parse(boneAnim);
if (!track.isEmpty()) {
anim.tracksByBoneUuid.put(boneUuid, track);
}
}
}
if (anim.tracksByBoneUuid.isEmpty()) continue;
map.put(name.toLowerCase(), anim);
}
return map;
}
public static void apply(CosmeticModel model, CosmeticBbAnimation animation, float time) {
java.util.Set<String> filter = model.getAnimationBoneFilter();
for (Map.Entry<String, BoneTrack> entry : animation.tracksByBoneUuid.entrySet()) {
if (filter != null && !filter.contains(entry.getKey())) continue;
CosmeticBone bone = model.getBoneByUuid(entry.getKey());
if (bone == null) continue;
BoneTrack track = entry.getValue();
Vector3f rot = track.sampleRotation(time, animation.length, animation.loop);
Vector3f pos = track.samplePosition(time, animation.length, animation.loop);
if (rot != null) {
bone.setAnimRotation(new Vector3f(
rot.x - bone.getDefaultRotation().x,
rot.y - bone.getDefaultRotation().y,
rot.z - bone.getDefaultRotation().z
));
}
if (pos != null) {
bone.setAnimPosition(pos);
}
}
}
public static final class BoneTrack {
private final List<Keyframe> rotations = new ArrayList<>();
private final List<Keyframe> positions = new ArrayList<>();
static BoneTrack parse(JsonObject boneAnim) {
BoneTrack track = new BoneTrack();
if (!boneAnim.has("keyframes")) return track;
for (JsonElement el : boneAnim.getAsJsonArray("keyframes")) {
if (!el.isJsonObject()) continue;
JsonObject kf = el.getAsJsonObject();
String channel = kf.has("channel") ? kf.get("channel").getAsString() : "";
float t = kf.has("time") ? kf.get("time").getAsFloat() : 0f;
Vector3f value = readPoint(kf);
String interp = kf.has("interpolation") ? kf.get("interpolation").getAsString() : "linear";
Keyframe keyframe = new Keyframe(t, value, interp);
switch (channel) {
case "rotation" -> track.rotations.add(keyframe);
case "position" -> track.positions.add(keyframe);
default -> {
}
}
}
track.rotations.sort(Comparator.comparing(Keyframe::time));
track.positions.sort(Comparator.comparing(Keyframe::time));
return track;
}
boolean isEmpty() {
return rotations.isEmpty() && positions.isEmpty();
}
Vector3f sampleRotation(float time, float length, boolean loop) {
return sample(rotations, time, length, loop);
}
Vector3f samplePosition(float time, float length, boolean loop) {
return sample(positions, time, length, loop);
}
private static Vector3f sample(List<Keyframe> frames, float time, float length, boolean loop) {
if (frames.isEmpty()) return null;
if (frames.size() == 1) return new Vector3f(frames.get(0).value);
float t = loop ? wrapTime(time, length) : Math.min(time, length);
Keyframe prev = frames.get(0);
Keyframe next = frames.get(frames.size() - 1);
for (int i = 0; i < frames.size() - 1; i++) {
Keyframe a = frames.get(i);
Keyframe b = frames.get(i + 1);
if (t >= a.time && t <= b.time) {
prev = a;
next = b;
break;
}
}
if (t >= frames.get(frames.size() - 1).time) {
prev = frames.get(frames.size() - 1);
next = prev;
}
if (prev == next || Math.abs(next.time - prev.time) < 0.0001f) {
return new Vector3f(prev.value);
}
float alpha = (t - prev.time) / (next.time - prev.time);
if ("step".equals(prev.interpolation)) {
return new Vector3f(prev.value);
}
return lerp(prev.value, next.value, clamp01(alpha));
}
private static float wrapTime(float time, float length) {
if (length <= 0f) return time;
float wrapped = time % length;
return wrapped < 0f ? wrapped + length : wrapped;
}
private static float clamp01(float v) {
return Math.max(0f, Math.min(1f, v));
}
private static Vector3f lerp(Vector3f a, Vector3f b, float t) {
return new Vector3f(
a.x + (b.x - a.x) * t,
a.y + (b.y - a.y) * t,
a.z + (b.z - a.z) * t
);
}
private static Vector3f readPoint(JsonObject kf) {
if (!kf.has("data_points")) return new Vector3f();
JsonArray points = kf.getAsJsonArray("data_points");
if (points.isEmpty()) return new Vector3f();
JsonObject point = points.get(0).getAsJsonObject();
return new Vector3f(
parseNum(point, "x"),
parseNum(point, "y"),
parseNum(point, "z")
);
}
private static float parseNum(JsonObject obj, String key) {
if (!obj.has(key)) return 0f;
JsonElement el = obj.get(key);
if (el.isJsonPrimitive() && el.getAsJsonPrimitive().isNumber()) {
return el.getAsFloat();
}
try {
return Float.parseFloat(el.getAsString().trim());
} catch (Exception ignored) {
return 0f;
}
}
}
private record Keyframe(float time, Vector3f value, String interpolation) {
}
}

View file

@ -1,124 +0,0 @@
package dev.loki.lovisual.features.cosmetic.animation;
import com.google.gson.JsonObject;
import com.mojang.blaze3d.platform.NativeImage;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.Identifier;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
public final class CosmeticTextureSlot {
private final int index;
private final Identifier id;
private int width;
private int height;
private final String fileName;
private boolean loaded;
private JsonObject textureJson;
public CosmeticTextureSlot(int index, Identifier id, int width, int height, String fileName) {
this.index = index;
this.id = id;
this.width = Math.max(width, 1);
this.height = Math.max(height, 1);
this.fileName = fileName;
}
public int getIndex() {
return index;
}
public Identifier getId() {
return id;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
public boolean isLoaded() {
return loaded;
}
public void markLoaded() {
loaded = true;
}
public static CosmeticTextureSlot fromJson(int index, String modelKey, JsonObject texture) {
int w = texture.has("width") ? texture.get("width").getAsInt() : 64;
int h = texture.has("height") ? texture.get("height").getAsInt() : 64;
String name = texture.has("name") ? texture.get("name").getAsString() : "tex" + index;
Identifier id = Identifier.fromNamespaceAndPath(
"lovisual",
"dynamic/cosmetic/" + modelKey + "/tex/" + index
);
CosmeticTextureSlot slot = new CosmeticTextureSlot(index, id, w, h, name);
slot.textureJson = texture;
return slot;
}
public void reset() {
loaded = false;
}
/**
* Attempts to load this texture slot from, in order: the cosmetic's external folder
* (file name, then outfits/&lt;file&gt;, then a generic texture.png fallback), or an
* inline base64 "source" embedded in the .bbmodel JSON.
*/
public void load(Minecraft mc, File externalDir, String resourcePath) {
if (loaded || mc == null || mc.getTextureManager() == null) return;
try {
if (externalDir != null) {
if (tryFile(mc, new File(externalDir, fileName))) return;
if (tryFile(mc, new File(externalDir, "outfits/" + fileName))) return;
if (tryFile(mc, new File(externalDir, "texture.png"))) return;
}
if (textureJson != null && textureJson.has("source")) {
String source = textureJson.get("source").getAsString();
if (source.startsWith("data:image") && source.contains(",")) {
byte[] bytes = Base64.getDecoder().decode(source.substring(source.indexOf(',') + 1));
try (InputStream is = new ByteArrayInputStream(bytes)) {
register(mc, is);
}
}
}
} catch (Exception ignored) {
}
}
private boolean tryFile(Minecraft mc, File file) throws IOException {
if (!file.exists()) return false;
try (InputStream is = new FileInputStream(file)) {
register(mc, is);
return true;
}
}
private void register(Minecraft mc, InputStream is) throws IOException {
NativeImage image = NativeImage.read(is);
width = Math.max(image.getWidth(), 1);
height = Math.max(image.getHeight(), 1);
DynamicTexture texture = new DynamicTexture(() -> "lovisual_cosmetic_" + index, image);
mc.getTextureManager().register(id, texture);
loaded = true;
}
}

View file

@ -1,32 +0,0 @@
package dev.loki.lovisual.features.cosmetic.animation;
public enum CosmeticType {
MODEL("model", "Модель"),
PET("pet", "Питомец"),
ACCESSORY("accessory", "Аксессуар");
private final String id;
private final String displayName;
CosmeticType(String id, String displayName) {
this.id = id;
this.displayName = displayName;
}
public String getId() {
return id;
}
public String getDisplayName() {
return displayName;
}
public static CosmeticType fromJson(String raw) {
if (raw == null || raw.isBlank()) return MODEL;
return switch (raw.toLowerCase()) {
case "pet", "pets", "питомец", "питомцы" -> PET;
case "accessory", "accessories", "item", "items", "аксессуар", "аксессуары" -> ACCESSORY;
default -> MODEL;
};
}
}

View file

@ -1,83 +0,0 @@
package dev.loki.lovisual.features.cosmetic.manager;
import java.util.EnumMap;
import java.util.Map;
import dev.loki.lovisual.features.cosmetic.manager.CosmeticRepository;
import dev.loki.lovisual.features.cosmetic.model.CosmeticModel;
import dev.loki.lovisual.features.cosmetic.model.CosmeticSlot;
/**
* Holds which cosmetic the local user currently has equipped, per slot.
* MODEL/PET are single-active slots; the six accessory slots (HEAD/SHOULDERS/
* BACK/WAIST/LEGS/HELD) are independent and can all be active at once.
* Rendering and animation live in separate classes (not part of this core pass).
*/
public final class CosmeticManager {
private static final CosmeticManager INSTANCE = new CosmeticManager();
private final Map<CosmeticSlot, CosmeticModel> active = new EnumMap<>(CosmeticSlot.class);
private CosmeticManager() {
}
public static CosmeticManager get() {
return INSTANCE;
}
/**
* Reloads the catalog from disk, re-resolving the currently equipped cosmetics
* (by id) against the freshly loaded models.
*/
public void init() {
Map<CosmeticSlot, String> equippedIds = new EnumMap<>(CosmeticSlot.class);
for (Map.Entry<CosmeticSlot, CosmeticModel> entry : active.entrySet()) {
if (entry.getValue() != null) {
equippedIds.put(entry.getKey(), entry.getValue().getId());
}
}
for (CosmeticModel model : CosmeticRepository.get().all()) {
model.resetTexture();
}
CosmeticRepository.get().reload();
active.clear();
CosmeticRepository repo = CosmeticRepository.get();
for (Map.Entry<CosmeticSlot, String> entry : equippedIds.entrySet()) {
CosmeticModel resolved = repo.get(entry.getValue());
if (resolved != null) {
active.put(entry.getKey(), resolved);
}
}
}
public CosmeticModel getActive(CosmeticSlot slot) {
return active.get(slot);
}
public void setActive(CosmeticSlot slot, CosmeticModel model) {
if (model != null) {
CosmeticModel resolved = CosmeticRepository.get().get(model.getId());
if (resolved != null) {
model = resolved;
}
}
if (model == null) {
active.remove(slot);
} else {
active.put(slot, model);
}
}
public void clear(CosmeticSlot slot) {
setActive(slot, null);
}
public boolean hasAnyActive() {
return !active.isEmpty();
}
public boolean isActive() {
return active.get(CosmeticSlot.MODEL) != null;
}
}

View file

@ -1,211 +0,0 @@
package dev.loki.lovisual.features.cosmetic.manager;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.math.RenderMath;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.AbstractTexture;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import dev.loki.lovisual.features.cosmetic.animation.CosmeticAttachmentTransforms;
import dev.loki.lovisual.features.cosmetic.animation.CosmeticTextureSlot;
import dev.loki.lovisual.features.cosmetic.manager.CosmeticManager;
import dev.loki.lovisual.features.cosmetic.model.CosmeticBone;
import dev.loki.lovisual.features.cosmetic.model.CosmeticCube;
import dev.loki.lovisual.features.cosmetic.model.CosmeticModel;
import dev.loki.lovisual.features.cosmetic.model.CosmeticSlot;
/**
* Renders a rest-pose (no bb-animation) {@link CosmeticModel} bone/cube tree attached to a
* player, as camera-relative textured quads through the engine's {@link Renderer3D}/{@link MeshBuilder}.
*/
public final class CosmeticRenderer {
private static final float UNITS_PER_BLOCK = 16.0f;
private CosmeticRenderer() {
}
public static void renderOnPlayer(Renderer3D renderer, Player player, CosmeticSlot slot, float tickDelta) {
if (renderer == null || player == null) return;
CosmeticModel model = CosmeticManager.get().getActive(slot);
if (model == null) return;
model.loadTexture();
List<CosmeticBone> roots = model.getRenderRoots();
if (roots.isEmpty()) return;
Vec3 pos = RenderMath.getLerpedPos(player, tickDelta);
float bodyYaw = Mth.rotLerp(tickDelta, player.yBodyRotO, player.yBodyRot);
PoseStack matrices = new PoseStack();
matrices.translate(pos.x, pos.y, pos.z);
CosmeticAttachmentTransforms.apply(matrices, slot, model);
matrices.mulPose(Axis.YP.rotationDegrees(180f - bodyYaw));
Map<Integer, List<PendingFace>> byTexture = new HashMap<>();
for (CosmeticBone bone : roots) {
collectBone(matrices, model, bone, byTexture);
}
if (byTexture.isEmpty()) return;
Minecraft mc = Minecraft.getInstance();
Vec3 cam = RenderState.cameraPos;
for (Map.Entry<Integer, List<PendingFace>> entry : byTexture.entrySet()) {
CosmeticTextureSlot textureSlot = model.getTextureSlot(entry.getKey());
if (textureSlot == null) continue;
if (!textureSlot.isLoaded()) {
model.ensureTextureSlotLoaded(textureSlot);
}
if (!textureSlot.isLoaded()) continue;
AbstractTexture texture = mc.getTextureManager().getTexture(textureSlot.getId());
if (texture == null || texture.getTextureView() == null || texture.getSampler() == null) continue;
Renderer3D.BatchBindings bindings = Renderer3D.BatchBindings.none()
.withSampler("u_Texture", texture.getTextureView(), texture.getSampler());
MeshBuilder mesh = renderer.batch(LoVisualRenderPipelines.WORLD_TEXTURED, Renderer3D.DepthMode.MAIN, bindings);
if (mesh == null) continue;
for (PendingFace face : entry.getValue()) {
mesh.ensureQuadCapacity();
int i1 = mesh.vec3(face.xs[0] - cam.x, face.ys[0] - cam.y, face.zs[0] - cam.z)
.raw2(face.us[0], face.vs[0]).color(255, 255, 255, 255).next();
int i2 = mesh.vec3(face.xs[1] - cam.x, face.ys[1] - cam.y, face.zs[1] - cam.z)
.raw2(face.us[1], face.vs[1]).color(255, 255, 255, 255).next();
int i3 = mesh.vec3(face.xs[2] - cam.x, face.ys[2] - cam.y, face.zs[2] - cam.z)
.raw2(face.us[2], face.vs[2]).color(255, 255, 255, 255).next();
int i4 = mesh.vec3(face.xs[3] - cam.x, face.ys[3] - cam.y, face.zs[3] - cam.z)
.raw2(face.us[3], face.vs[3]).color(255, 255, 255, 255).next();
mesh.quad(i1, i2, i3, i4);
}
}
}
private static void collectBone(PoseStack matrices, CosmeticModel model, CosmeticBone bone,
Map<Integer, List<PendingFace>> out) {
if (model.shouldSkipBone(bone)) return;
matrices.pushPose();
matrices.last().pose().mul(bone.buildMatrix());
for (CosmeticCube cube : bone.getCubes()) {
collectCube(matrices, cube, out);
}
for (CosmeticBone child : bone.getChildren()) {
collectBone(matrices, model, child, out);
}
matrices.popPose();
}
private static void collectCube(PoseStack matrices, CosmeticCube cube, Map<Integer, List<PendingFace>> out) {
matrices.pushPose();
Vector3f origin = cube.getOrigin();
Vector3f rotation = cube.getRotation();
if (rotation.x != 0f || rotation.y != 0f || rotation.z != 0f) {
matrices.translate(origin.x / UNITS_PER_BLOCK, origin.y / UNITS_PER_BLOCK, origin.z / UNITS_PER_BLOCK);
matrices.mulPose(Axis.XP.rotationDegrees(rotation.x));
matrices.mulPose(Axis.YP.rotationDegrees(rotation.y));
matrices.mulPose(Axis.ZP.rotationDegrees(rotation.z));
matrices.translate(-origin.x / UNITS_PER_BLOCK, -origin.y / UNITS_PER_BLOCK, -origin.z / UNITS_PER_BLOCK);
}
float inflate = cube.getInflate();
float x1 = (cube.getFrom().x - inflate) / UNITS_PER_BLOCK;
float y1 = (cube.getFrom().y - inflate) / UNITS_PER_BLOCK;
float z1 = (cube.getFrom().z - inflate) / UNITS_PER_BLOCK;
float x2 = (cube.getTo().x + inflate) / UNITS_PER_BLOCK;
float y2 = (cube.getTo().y + inflate) / UNITS_PER_BLOCK;
float z2 = (cube.getTo().z + inflate) / UNITS_PER_BLOCK;
Matrix4f m = new Matrix4f(matrices.last().pose());
CosmeticCube.CubeFace[] faces = cube.getFaces();
int tw = cube.getTextureWidth();
int th = cube.getTextureHeight();
for (int side = 0; side < 6; side++) {
CosmeticCube.CubeFace face = faces[side];
if (face == null) continue;
addFace(m, face, tw, th, x1, y1, z1, x2, y2, z2, side, out);
}
matrices.popPose();
}
private static void addFace(Matrix4f m, CosmeticCube.CubeFace face, float tw, float th,
float x1, float y1, float z1, float x2, float y2, float z2,
int side, Map<Integer, List<PendingFace>> out) {
PendingFace quad = switch (side) {
case 0 -> quad(m,
x2, y2, z1, x1, y2, z1, x1, y1, z1, x2, y1, z1);
case 1 -> quad(m,
x2, y2, z2, x2, y2, z1, x2, y1, z1, x2, y1, z2);
case 2 -> quad(m,
x1, y2, z2, x2, y2, z2, x2, y1, z2, x1, y1, z2);
case 3 -> quad(m,
x1, y2, z1, x1, y2, z2, x1, y1, z2, x1, y1, z1);
case 4 -> quad(m,
x1, y2, z2, x2, y2, z2, x2, y2, z1, x1, y2, z1);
case 5 -> quad(m,
x1, y1, z1, x2, y1, z1, x2, y1, z2, x1, y1, z2);
default -> null;
};
if (quad == null) return;
float u1 = face.getU1() / tw;
float v1 = face.getV1() / th;
float u2 = face.getU2() / tw;
float v2 = face.getV2() / th;
quad.us[0] = u1;
quad.vs[0] = v1;
quad.us[1] = u2;
quad.vs[1] = v1;
quad.us[2] = u2;
quad.vs[2] = v2;
quad.us[3] = u1;
quad.vs[3] = v2;
out.computeIfAbsent(face.getTextureIndex(), k -> new ArrayList<>()).add(quad);
}
private static PendingFace quad(Matrix4f m,
float x1, float y1, float z1,
float x2, float y2, float z2,
float x3, float y3, float z3,
float x4, float y4, float z4) {
PendingFace face = new PendingFace();
Vector3f p1 = m.transformPosition(new Vector3f(x1, y1, z1));
Vector3f p2 = m.transformPosition(new Vector3f(x2, y2, z2));
Vector3f p3 = m.transformPosition(new Vector3f(x3, y3, z3));
Vector3f p4 = m.transformPosition(new Vector3f(x4, y4, z4));
face.xs = new float[]{p1.x, p2.x, p3.x, p4.x};
face.ys = new float[]{p1.y, p2.y, p3.y, p4.y};
face.zs = new float[]{p1.z, p2.z, p3.z, p4.z};
return face;
}
private static final class PendingFace {
float[] xs;
float[] ys;
float[] zs;
final float[] us = new float[4];
final float[] vs = new float[4];
}
}

View file

@ -1,104 +0,0 @@
package dev.loki.lovisual.features.cosmetic.manager;
import dev.loki.lovisual.config.io.ConfigPaths;
import java.io.File;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import dev.loki.lovisual.features.cosmetic.animation.CosmeticType;
import dev.loki.lovisual.features.cosmetic.model.CosmeticModel;
import dev.loki.lovisual.features.cosmetic.model.CosmeticSlot;
/**
* Scans {@code config/lovisual/cosmetics/&lt;type&gt;/&lt;name&gt;} (and the flat root as a
* fallback) for user-provided cosmetic folders (avatar.json + .bbmodel) and keeps the
* loaded catalog in memory.
*/
public final class CosmeticRepository {
private static final CosmeticRepository INSTANCE = new CosmeticRepository();
private final Map<String, CosmeticModel> catalog = new LinkedHashMap<>();
public static CosmeticRepository get() {
return INSTANCE;
}
public Path root() {
return ConfigPaths.root().resolve("cosmetics");
}
public void reload() {
catalog.clear();
discoverUserFolder(root());
}
public List<CosmeticModel> all() {
return new ArrayList<>(catalog.values());
}
public List<CosmeticModel> byType(CosmeticType type) {
return catalog.values().stream().filter(m -> m.getType() == type).toList();
}
public List<CosmeticModel> bySlot(CosmeticSlot slot) {
return catalog.values().stream().filter(m -> m.getSlot() == slot).toList();
}
public CosmeticModel get(String id) {
return catalog.get(id);
}
private void register(CosmeticModel model) {
if (model != null && !catalog.containsKey(model.getId())) {
catalog.put(model.getId(), model);
}
}
private void discoverUserFolder(Path root) {
if (!Files.isDirectory(root)) {
try {
Files.createDirectories(root);
for (CosmeticType type : CosmeticType.values()) {
Files.createDirectories(root.resolve(type.getId()));
}
} catch (Exception ignored) {
}
return;
}
for (CosmeticType type : CosmeticType.values()) {
Path typeDir = root.resolve(type.getId());
scanDirectory(typeDir, type);
}
scanDirectory(root, null);
}
private void scanDirectory(Path dir, CosmeticType forcedType) {
if (!Files.isDirectory(dir)) return;
try (Stream<Path> stream = Files.list(dir)) {
stream.filter(Files::isDirectory).forEach(sub -> {
File folder = sub.toFile();
if (new File(folder, "avatar.json").exists() || hasBbmodel(folder)) {
CosmeticModel model = CosmeticModel.loadExternal(folder, "user");
if (model != null) {
if (forcedType != null && model.getType() == CosmeticType.MODEL) {
model = model.withType(forcedType);
}
register(model);
}
}
});
} catch (Exception ignored) {
}
}
private static boolean hasBbmodel(File folder) {
return new File(folder, "model.bbmodel").exists()
|| new File(folder, folder.getName() + ".bbmodel").exists();
}
}

View file

@ -1,67 +0,0 @@
package dev.loki.lovisual.features.cosmetic.model;
import lombok.Getter;
import lombok.Setter;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.List;
import dev.loki.lovisual.features.cosmetic.model.CosmeticCube;
@Getter
public class CosmeticBone {
private final String name;
private final String uuid;
private final Vector3f pivot;
private final Vector3f defaultRotation;
private final List<CosmeticCube> cubes = new ArrayList<>();
private final List<CosmeticBone> children = new ArrayList<>();
@Setter private Vector3f animRotation = new Vector3f(0, 0, 0);
@Setter private Vector3f animPosition = new Vector3f(0, 0, 0);
public CosmeticBone(String name, String uuid, Vector3f pivot, Vector3f defaultRotation) {
this.name = name;
this.uuid = uuid;
this.pivot = pivot;
this.defaultRotation = defaultRotation;
}
public Matrix4f buildMatrix() {
Matrix4f mat = new Matrix4f();
mat.translate(pivot.x / 16f, pivot.y / 16f, pivot.z / 16f);
if (defaultRotation.x != 0 || defaultRotation.y != 0 || defaultRotation.z != 0
|| animRotation.x != 0 || animRotation.y != 0 || animRotation.z != 0) {
float rx = (float) Math.toRadians(defaultRotation.x + animRotation.x);
float ry = (float) Math.toRadians(defaultRotation.y + animRotation.y);
float rz = (float) Math.toRadians(defaultRotation.z + animRotation.z);
mat.rotateZYX(rz, ry, rx);
}
mat.translate(-pivot.x / 16f, -pivot.y / 16f, -pivot.z / 16f);
if (animPosition.x != 0 || animPosition.y != 0 || animPosition.z != 0) {
mat.translate(animPosition.x / 16f, animPosition.y / 16f, animPosition.z / 16f);
}
return mat;
}
public void resetAnim() {
animRotation = new Vector3f(0, 0, 0);
animPosition = new Vector3f(0, 0, 0);
for (CosmeticBone child : children) child.resetAnim();
}
public CosmeticBone deepClone() {
CosmeticBone copy = new CosmeticBone(name, uuid, new Vector3f(pivot), new Vector3f(defaultRotation));
copy.getCubes().addAll(cubes);
for (CosmeticBone child : children) {
copy.getChildren().add(child.deepClone());
}
return copy;
}
}

View file

@ -1,42 +0,0 @@
package dev.loki.lovisual.features.cosmetic.model;
import lombok.Getter;
import org.joml.Vector3f;
@Getter
public class CosmeticCube {
private final Vector3f from;
private final Vector3f to;
private final Vector3f rotation;
private final Vector3f origin;
private final float inflate;
private final CubeFace[] faces;
private final int textureWidth;
private final int textureHeight;
public CosmeticCube(Vector3f from, Vector3f to, Vector3f rotation, Vector3f origin,
float inflate, CubeFace[] faces, int textureWidth, int textureHeight) {
this.from = from;
this.to = to;
this.rotation = rotation;
this.origin = origin;
this.inflate = inflate;
this.faces = faces;
this.textureWidth = textureWidth;
this.textureHeight = textureHeight;
}
@Getter
public static class CubeFace {
private final float u1, v1, u2, v2;
private final int textureIndex;
public CubeFace(float u1, float v1, float u2, float v2, int textureIndex) {
this.u1 = u1;
this.v1 = v1;
this.u2 = u2;
this.v2 = v2;
this.textureIndex = textureIndex;
}
}
}

View file

@ -1,677 +0,0 @@
package dev.loki.lovisual.features.cosmetic.model;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.mojang.blaze3d.platform.NativeImage;
import lombok.Getter;
import lombok.Setter;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.texture.DynamicTexture;
import net.minecraft.resources.Identifier;
import org.joml.Vector3f;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import dev.loki.lovisual.features.cosmetic.animation.CosmeticBbAnimation;
import dev.loki.lovisual.features.cosmetic.animation.CosmeticTextureSlot;
import dev.loki.lovisual.features.cosmetic.animation.CosmeticType;
import dev.loki.lovisual.features.cosmetic.model.CosmeticBone;
import dev.loki.lovisual.features.cosmetic.model.CosmeticCube;
import dev.loki.lovisual.features.cosmetic.model.CosmeticSlot;
/**
* A single cosmetic asset: a Blockbench (.bbmodel) bone/cube tree plus its texture(s),
* loaded from a user-provided folder (avatar.json + model.bbmodel + textures).
*/
@Getter
public class CosmeticModel {
private final String id;
private final String name;
private final String description;
private final String accentColor;
private final String folderName;
private final CosmeticType type;
private final CosmeticSlot slot;
private final File externalDirectory;
@Setter private String source = "Своя";
private final List<CosmeticBone> rootBones = new ArrayList<>();
private final List<CosmeticBone> renderRoots = new ArrayList<>();
private boolean customLegsPresent;
private boolean bowReplacementAccessory;
private boolean swordReplacementAccessory;
private boolean hatOnlyAccessory;
private boolean wingsOnlyAccessory;
private Set<String> visibleBoneUuids;
@Setter private Set<String> animationBoneFilter;
private final Map<String, CosmeticBone> boneMap = new HashMap<>();
private final Map<String, CosmeticBone> boneMapByUuid = new HashMap<>();
private final Map<String, CosmeticBbAnimation> bbAnimations = new LinkedHashMap<>();
private final List<CosmeticTextureSlot> textureSlots = new ArrayList<>();
private int textureWidth = 64;
private int textureHeight = 64;
private Identifier textureId;
private boolean textureLoaded = false;
private CosmeticTextureSlot fallbackSlot;
private CosmeticModel(String id, String name, String description, String accentColor,
String folderName, CosmeticType type, CosmeticSlot slot, File externalDirectory) {
this.id = id;
this.name = name;
this.description = description;
this.accentColor = accentColor;
this.folderName = folderName;
this.type = type;
this.slot = slot != null ? slot : defaultSlotFor(type, folderName);
this.externalDirectory = externalDirectory;
}
private static CosmeticSlot defaultSlotFor(CosmeticType type, String folderName) {
return switch (type) {
case MODEL -> CosmeticSlot.MODEL;
case PET -> CosmeticSlot.PET;
case ACCESSORY -> CosmeticSlot.guessFromFolderName(folderName);
};
}
public CosmeticModel withType(CosmeticType newType) {
CosmeticModel copy = new CosmeticModel(id, name, description, accentColor, folderName, newType, null, externalDirectory);
copy.rootBones.addAll(rootBones);
copy.renderRoots.addAll(renderRoots);
copy.boneMap.putAll(boneMap);
copy.boneMapByUuid.putAll(boneMapByUuid);
copy.bbAnimations.putAll(bbAnimations);
copy.textureSlots.addAll(textureSlots);
copy.textureWidth = textureWidth;
copy.textureHeight = textureHeight;
copy.textureId = textureId;
copy.textureLoaded = textureLoaded;
copy.source = source;
copy.postProcess();
return copy;
}
public void resetTexture() {
textureLoaded = false;
textureId = null;
for (CosmeticTextureSlot slot : textureSlots) {
slot.reset();
}
}
public CosmeticTextureSlot getTextureSlot(int index) {
if (!textureSlots.isEmpty()) {
if (index >= 0 && index < textureSlots.size()) {
return textureSlots.get(index);
}
return textureSlots.get(0);
}
if (textureId == null) return null;
if (fallbackSlot == null) {
fallbackSlot = new CosmeticTextureSlot(0, textureId, textureWidth, textureHeight, "fallback");
if (textureLoaded) fallbackSlot.markLoaded();
}
return fallbackSlot;
}
/**
* Loads a cosmetic from a user folder: {@code avatar.json} (metadata) plus a
* {@code .bbmodel} (geometry/animation) and its texture(s).
*/
public static CosmeticModel loadExternal(File folder, String sourceTag) {
if (folder == null || !folder.isDirectory()) return null;
String key = sourceTag + ":" + folder.getName().toLowerCase();
return load(folder.getName(), folder, key, null);
}
private static CosmeticModel load(String folderName, File externalDir, String catalogId, CosmeticType forcedType) {
try {
JsonObject avatar = new JsonObject();
File avatarFile = new File(externalDir, "avatar.json");
if (avatarFile.exists()) {
try (InputStreamReader reader = new InputStreamReader(new FileInputStream(avatarFile), StandardCharsets.UTF_8)) {
avatar = JsonParser.parseReader(reader).getAsJsonObject();
}
}
String name = avatar.has("name") ? avatar.get("name").getAsString() : folderName;
String desc = avatar.has("description") ? avatar.get("description").getAsString() : "";
String color = avatar.has("color") ? avatar.get("color").getAsString() : "#ffffff";
CosmeticType type = forcedType != null ? forcedType : CosmeticType.fromJson(
avatar.has("category") ? avatar.get("category").getAsString()
: avatar.has("type") ? avatar.get("type").getAsString() : null
);
CosmeticSlot slot = avatar.has("slot") ? CosmeticSlot.fromJson(avatar.get("slot").getAsString()) : null;
CosmeticModel model = new CosmeticModel(catalogId, name, desc, color, folderName, type, slot, externalDir);
InputStream modelStream = openModelStream(externalDir, folderName);
if (modelStream == null) {
return model;
}
JsonObject bbmodel;
try (InputStreamReader reader = new InputStreamReader(modelStream, StandardCharsets.UTF_8)) {
bbmodel = JsonParser.parseReader(reader).getAsJsonObject();
}
if (bbmodel.has("resolution")) {
JsonObject res = bbmodel.getAsJsonObject("resolution");
model.textureWidth = res.get("width").getAsInt();
model.textureHeight = res.get("height").getAsInt();
}
model.textureId = Identifier.fromNamespaceAndPath(
"lovisual",
"dynamic/cosmetic/" + catalogId.replace(':', '_').replace(' ', '_')
);
model.loadTextureSlots(bbmodel);
Map<String, JsonObject> elementMap = new HashMap<>();
if (bbmodel.has("elements")) {
for (JsonElement el : bbmodel.getAsJsonArray("elements")) {
JsonObject obj = el.getAsJsonObject();
if (obj.has("uuid")) {
elementMap.put(obj.get("uuid").getAsString(), obj);
}
}
}
Map<String, JsonObject> groupMap = buildGroupMap(bbmodel);
if (bbmodel.has("outliner")) {
for (JsonElement el : bbmodel.getAsJsonArray("outliner")) {
CosmeticBone bone = parseOutliner(el, elementMap, groupMap, model.textureWidth, model.textureHeight);
if (bone != null) {
model.rootBones.add(bone);
model.indexBone(bone);
}
}
}
model.bbAnimations.putAll(CosmeticBbAnimation.parseAll(bbmodel));
model.postProcess();
return model;
} catch (Exception e) {
return null;
}
}
private static InputStream openModelStream(File externalDir, String folderName) throws IOException {
File model = new File(externalDir, "model.bbmodel");
if (!model.exists()) {
model = new File(externalDir, folderName + ".bbmodel");
}
if (model.exists()) return new FileInputStream(model);
File[] bbmodels = externalDir.listFiles((dir, name) -> name.endsWith(".bbmodel"));
if (bbmodels != null && bbmodels.length > 0) {
return new FileInputStream(bbmodels[0]);
}
return null;
}
private void loadTextureSlots(JsonObject bbmodel) {
textureSlots.clear();
if (!bbmodel.has("textures")) return;
String modelKey = id.replace(':', '_');
JsonArray textures = bbmodel.getAsJsonArray("textures");
for (int i = 0; i < textures.size(); i++) {
if (!textures.get(i).isJsonObject()) continue;
JsonObject tex = textures.get(i).getAsJsonObject();
textureSlots.add(CosmeticTextureSlot.fromJson(i, modelKey, tex));
}
}
public void ensureTextureSlotLoaded(CosmeticTextureSlot slot) {
if (slot == null || slot.isLoaded()) return;
Minecraft mc = Minecraft.getInstance();
if (mc == null) return;
slot.load(mc, externalDirectory, folderName);
}
/**
* Loads whichever textures this model needs (per-slot bbmodel textures, or a single
* fallback {@code texture.png}/{@code avatar.png} in the cosmetic's folder).
*/
public void loadTexture() {
if (textureLoaded) return;
try {
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.getTextureManager() == null) return;
boolean anyLoaded = false;
for (CosmeticTextureSlot slot : textureSlots) {
slot.load(mc, externalDirectory, folderName);
if (slot.isLoaded()) anyLoaded = true;
}
if (!anyLoaded && externalDirectory != null) {
String[] names = {"outfits/texture.png", "texture.png", folderName + ".png", "avatar.png"};
for (String rel : names) {
File tex = new File(externalDirectory, rel);
if (!tex.exists()) continue;
try (InputStream is = new FileInputStream(tex)) {
registerTexture(mc, textureId, is);
anyLoaded = true;
break;
} catch (Exception ignored) {
}
}
} else if (!textureSlots.isEmpty() && textureSlots.get(0).isLoaded()) {
textureId = textureSlots.get(0).getId();
textureWidth = textureSlots.get(0).getWidth();
textureHeight = textureSlots.get(0).getHeight();
}
textureLoaded = anyLoaded;
if (!anyLoaded) {
textureId = null;
}
} catch (Exception ignored) {
}
}
private void registerTexture(Minecraft mc, Identifier rawId, InputStream is) throws IOException {
NativeImage img = NativeImage.read(is);
DynamicTexture tex = new DynamicTexture(() -> "lovisual_cosmetic", img);
mc.getTextureManager().register(rawId, tex);
textureId = rawId;
textureLoaded = true;
}
private void indexBone(CosmeticBone bone) {
boneMap.put(bone.getName().toLowerCase(), bone);
boneMapByUuid.put(bone.getUuid(), bone);
for (CosmeticBone child : bone.getChildren()) indexBone(child);
}
public CosmeticBone getBone(String name) {
return boneMap.get(name.toLowerCase());
}
public CosmeticBone getBoneByUuid(String uuid) {
return boneMapByUuid.get(uuid);
}
public boolean hasBbAnimations() {
return !bbAnimations.isEmpty();
}
public Collection<CosmeticBbAnimation> getBbAnimations() {
return bbAnimations.values();
}
public CosmeticBbAnimation findBbAnimation(String... candidates) {
for (String candidate : candidates) {
if (candidate == null) continue;
CosmeticBbAnimation anim = bbAnimations.get(candidate.toLowerCase());
if (anim != null) return anim;
}
for (String candidate : candidates) {
if (candidate == null) continue;
String lower = candidate.toLowerCase();
for (Map.Entry<String, CosmeticBbAnimation> entry : bbAnimations.entrySet()) {
if (entry.getKey().contains(lower) || lower.contains(entry.getKey())) {
return entry.getValue();
}
}
}
return null;
}
public CosmeticBbAnimation getFirstBbAnimation() {
for (String preferred : List.of(
"idling", "idle", "idle_arms", "idlemonsbob", "defaultstate",
"walking", "yap", "painting", "pose", "pose2"
)) {
CosmeticBbAnimation anim = findBbAnimation(preferred);
if (anim != null) return anim;
}
if (bbAnimations.isEmpty()) return null;
return bbAnimations.values().iterator().next();
}
public List<CosmeticBone> getRenderRoots() {
return renderRoots.isEmpty() ? rootBones : renderRoots;
}
public boolean hasCustomLegs() {
return customLegsPresent;
}
public boolean isBowReplacementAccessory() {
return bowReplacementAccessory;
}
public boolean isSwordReplacementAccessory() {
return swordReplacementAccessory;
}
public boolean isBodyOnlyAccessory() {
return type == CosmeticType.ACCESSORY && !bowReplacementAccessory && !swordReplacementAccessory;
}
public boolean hasRestPoseAnimation() {
return findBbAnimation(
"idling", "idle", "idle_arms", "idlemonsbob", "defaultstate",
"walking", "walkingback", "flywalk"
) != null;
}
public boolean shouldSkipBone(CosmeticBone bone) {
if (visibleBoneUuids != null) {
return !visibleBoneUuids.contains(bone.getUuid());
}
String lower = bone.getName().toLowerCase();
if (customLegsPresent && lower.contains("vanilla") && (lower.contains("leg") || lower.contains("foot"))) {
return true;
}
if (lower.contains("rightarm_fp") || lower.contains("leftarm_fp")) {
return true;
}
if (hatOnlyAccessory) {
return lower.equals("body") || lower.equals("leftarm") || lower.equals("rightarm")
|| lower.equals("leftleg") || lower.equals("rightleg") || lower.equals("root");
}
return false;
}
/**
* Derives a standalone pet cosmetic from a single named bone subtree of a parent model
* (e.g. turning a model's "head" bone into its own pet cosmetic).
*/
public static CosmeticModel derivePet(CosmeticModel parent, String boneName, String petFolder, String petName) {
if (parent == null) return null;
CosmeticBone source = parent.findBoneDeep(boneName);
if (source == null) return null;
CosmeticModel pet = new CosmeticModel(
"derived:" + petFolder.replace('/', '_'),
petName,
"",
parent.getAccentColor(),
petFolder,
CosmeticType.PET,
null,
null
);
pet.source = parent.source;
pet.textureWidth = parent.textureWidth;
pet.textureHeight = parent.textureHeight;
pet.textureId = parent.textureId;
pet.textureSlots.addAll(parent.textureSlots);
pet.textureLoaded = parent.textureLoaded;
CosmeticBone root = source.deepClone();
pet.rootBones.add(root);
pet.renderRoots.add(root);
pet.indexBone(root);
Set<String> uuids = new HashSet<>();
pet.collectUuids(root, uuids);
pet.animationBoneFilter = uuids;
for (Map.Entry<String, CosmeticBbAnimation> entry : parent.bbAnimations.entrySet()) {
String key = entry.getKey().toLowerCase();
if (key.contains("mons") || key.contains("mini")) {
pet.bbAnimations.put(entry.getKey(), entry.getValue());
}
}
if (pet.bbAnimations.isEmpty()) {
pet.bbAnimations.putAll(parent.bbAnimations);
}
pet.postProcess();
return pet;
}
private void collectUuids(CosmeticBone bone, Set<String> uuids) {
uuids.add(bone.getUuid());
for (CosmeticBone child : bone.getChildren()) collectUuids(child, uuids);
}
private CosmeticBone findBoneDeep(String name) {
String lower = name.toLowerCase();
for (CosmeticBone root : rootBones) {
CosmeticBone found = findBoneDeep(root, lower);
if (found != null) return found;
}
return null;
}
private static CosmeticBone findBoneDeep(CosmeticBone bone, String lower) {
if (bone.getName().toLowerCase().equals(lower) || bone.getName().toLowerCase().contains(lower)) {
return bone;
}
for (CosmeticBone child : bone.getChildren()) {
CosmeticBone found = findBoneDeep(child, lower);
if (found != null) return found;
}
return null;
}
private void postProcess() {
customLegsPresent = boneMap.keySet().stream().anyMatch(n ->
n.contains("leftleg") || n.contains("rightleg") || n.contains("leftpaw") || n.contains("rightpaw"))
&& boneMap.keySet().stream().noneMatch(n -> n.equals("leftlegvanilla") || n.equals("rightlegvanilla")
|| (n.contains("vanilla") && n.contains("leg")));
String folder = folderName.toLowerCase();
bowReplacementAccessory = type == CosmeticType.ACCESSORY && folder.contains("bow");
swordReplacementAccessory = false;
hatOnlyAccessory = type == CosmeticType.ACCESSORY
&& (folder.contains("witch") || folder.contains("hat")) && !bowReplacementAccessory;
wingsOnlyAccessory = type == CosmeticType.ACCESSORY && folder.contains("wing");
visibleBoneUuids = null;
if (wingsOnlyAccessory) {
visibleBoneUuids = buildWingsBoneChainUuids();
}
renderRoots.clear();
for (CosmeticBone root : rootBones) {
String n = root.getName().toLowerCase();
if (type == CosmeticType.MODEL) {
if (n.equals("monst3r") || n.equals("skull") || n.endsWith("_fp")) continue;
}
if (type == CosmeticType.PET) {
if (n.equals("arrow") || n.equals("skull")) continue;
}
renderRoots.add(root);
}
if (renderRoots.isEmpty()) {
renderRoots.addAll(rootBones);
}
applyAccessorySubtreeFilter();
}
private void applyAccessorySubtreeFilter() {
if (hatOnlyAccessory) {
CosmeticBone head = findBoneDeep("head");
CosmeticBone hatBone = findBoneDeep("bone");
if (head != null && hatBone != null) {
CosmeticBone mount = new CosmeticBone(
head.getName(), head.getUuid(),
new Vector3f(head.getPivot()), new Vector3f(head.getDefaultRotation()));
mount.getChildren().add(hatBone.deepClone());
renderRoots.clear();
renderRoots.add(mount);
}
}
}
private Set<String> buildWingsBoneChainUuids() {
Set<String> keep = new HashSet<>();
CosmeticBone wings = findBoneDeep("wings");
if (wings == null) return keep;
collectBoneTreeUuids(wings, keep);
for (CosmeticBone root : rootBones) {
markBonePathToUuid(root, wings.getUuid(), keep);
}
return keep;
}
private static void collectBoneTreeUuids(CosmeticBone bone, Set<String> uuids) {
uuids.add(bone.getUuid());
for (CosmeticBone child : bone.getChildren()) {
collectBoneTreeUuids(child, uuids);
}
}
private static boolean markBonePathToUuid(CosmeticBone bone, String targetUuid, Set<String> keep) {
keep.add(bone.getUuid());
if (bone.getUuid().equals(targetUuid)) {
return true;
}
for (CosmeticBone child : bone.getChildren()) {
if (markBonePathToUuid(child, targetUuid, keep)) {
return true;
}
}
keep.remove(bone.getUuid());
return false;
}
private static Map<String, JsonObject> buildGroupMap(JsonObject bbmodel) {
Map<String, JsonObject> map = new HashMap<>();
if (!bbmodel.has("groups")) return map;
for (JsonElement el : bbmodel.getAsJsonArray("groups")) {
if (!el.isJsonObject()) continue;
JsonObject group = el.getAsJsonObject();
if (group.has("uuid")) {
map.put(group.get("uuid").getAsString(), group);
}
}
return map;
}
private static Vector3f readVec3(JsonObject obj, String key, Vector3f fallback) {
if (obj == null || !obj.has(key)) return new Vector3f(fallback);
JsonArray arr = obj.getAsJsonArray(key);
return new Vector3f(arr.get(0).getAsFloat(), arr.get(1).getAsFloat(), arr.get(2).getAsFloat());
}
private static CosmeticBone parseOutliner(JsonElement element, Map<String, JsonObject> elementMap,
Map<String, JsonObject> groupMap, int texW, int texH) {
if (element.isJsonPrimitive() || !element.isJsonObject()) return null;
JsonObject obj = element.getAsJsonObject();
String uuid = obj.has("uuid") ? obj.get("uuid").getAsString() : UUID.randomUUID().toString();
JsonObject group = groupMap.get(uuid);
String name = "bone";
if (obj.has("name")) {
name = obj.get("name").getAsString();
} else if (group != null && group.has("name")) {
name = group.get("name").getAsString();
}
Vector3f pivot = obj.has("origin")
? readVec3(obj, "origin", new Vector3f())
: readVec3(group, "origin", new Vector3f());
Vector3f rotation = obj.has("rotation")
? readVec3(obj, "rotation", new Vector3f())
: readVec3(group, "rotation", new Vector3f());
CosmeticBone bone = new CosmeticBone(name, uuid, pivot, rotation);
if (obj.has("children")) {
for (JsonElement child : obj.getAsJsonArray("children")) {
if (child.isJsonPrimitive()) {
String childUuid = child.getAsString();
JsonObject cubeObj = elementMap.get(childUuid);
if (cubeObj != null) {
CosmeticCube cube = parseCube(cubeObj, texW, texH);
if (cube != null) bone.getCubes().add(cube);
}
} else {
CosmeticBone childBone = parseOutliner(child, elementMap, groupMap, texW, texH);
if (childBone != null) bone.getChildren().add(childBone);
}
}
}
return bone;
}
private static CosmeticCube parseCube(JsonObject obj, int texW, int texH) {
try {
JsonArray fromArr = obj.getAsJsonArray("from");
JsonArray toArr = obj.getAsJsonArray("to");
Vector3f from = new Vector3f(fromArr.get(0).getAsFloat(), fromArr.get(1).getAsFloat(), fromArr.get(2).getAsFloat());
Vector3f to = new Vector3f(toArr.get(0).getAsFloat(), toArr.get(1).getAsFloat(), toArr.get(2).getAsFloat());
Vector3f rot = new Vector3f(0, 0, 0);
if (obj.has("rotation")) {
JsonArray r = obj.getAsJsonArray("rotation");
rot.set(r.get(0).getAsFloat(), r.get(1).getAsFloat(), r.get(2).getAsFloat());
}
Vector3f origin = new Vector3f(0, 0, 0);
if (obj.has("origin")) {
JsonArray o = obj.getAsJsonArray("origin");
origin.set(o.get(0).getAsFloat(), o.get(1).getAsFloat(), o.get(2).getAsFloat());
}
float inflate = obj.has("inflate") ? obj.get("inflate").getAsFloat() : 0f;
CosmeticCube.CubeFace[] faces = new CosmeticCube.CubeFace[6];
String[] faceNames = {"north", "east", "south", "west", "up", "down"};
if (obj.has("faces")) {
JsonObject facesObj = obj.getAsJsonObject("faces");
for (int i = 0; i < faceNames.length; i++) {
String fn = faceNames[i];
if (!facesObj.has(fn)) continue;
JsonObject face = facesObj.getAsJsonObject(fn);
if (!face.has("uv") || !face.get("uv").isJsonArray()) continue;
JsonArray uv = face.getAsJsonArray("uv");
if (uv.size() < 4) continue;
int tex = 0;
if (face.has("texture") && !face.get("texture").isJsonNull()) {
JsonElement texEl = face.get("texture");
if (texEl.isJsonPrimitive() && texEl.getAsJsonPrimitive().isNumber()) {
tex = texEl.getAsInt();
}
}
float u1 = uv.get(0).getAsFloat();
float v1 = uv.get(1).getAsFloat();
float u2 = uv.get(2).getAsFloat();
float v2 = uv.get(3).getAsFloat();
if (u2 < u1) {
float t = u1;
u1 = u2;
u2 = t;
}
if (v2 < v1) {
float t = v1;
v1 = v2;
v2 = t;
}
faces[i] = new CosmeticCube.CubeFace(u1, v1, u2, v2, tex);
}
}
return new CosmeticCube(from, to, rot, origin, inflate, faces, texW, texH);
} catch (Exception e) {
return null;
}
}
}

View file

@ -1,62 +0,0 @@
package dev.loki.lovisual.features.cosmetic.model;
/**
* Where a cosmetic is worn. MODEL/PET are whole-body/companion slots (one active
* item each); the remaining six are independent accessory slots that can all be
* active simultaneously (e.g. a hat + wings + a belt at once).
*/
public enum CosmeticSlot {
MODEL("model", "Модель"),
PET("pet", "Питомец"),
HEAD("head", "Голова"),
SHOULDERS("shoulders", "Плечи"),
BACK("back", "Спина"),
WAIST("waist", "Пояс"),
LEGS("legs", "Ноги"),
HELD("held", "В руке");
private final String id;
private final String displayName;
CosmeticSlot(String id, String displayName) {
this.id = id;
this.displayName = displayName;
}
public String getId() {
return id;
}
public String getDisplayName() {
return displayName;
}
public boolean isAccessory() {
return this != MODEL && this != PET;
}
public static CosmeticSlot fromJson(String raw) {
if (raw == null || raw.isBlank()) return null;
return switch (raw.trim().toLowerCase()) {
case "model", "модель" -> MODEL;
case "pet", "pets", "питомец", "питомцы" -> PET;
case "head", "hat", "голова", "шляпа" -> HEAD;
case "shoulders", "shoulder", "плечи" -> SHOULDERS;
case "back", "спина" -> BACK;
case "waist", "belt", "пояс" -> WAIST;
case "legs", "leg", "ноги" -> LEGS;
case "held", "hand", "рука" -> HELD;
default -> null;
};
}
/** Best-effort slot guess from the model's folder name (legacy accessories without a "slot" key). */
public static CosmeticSlot guessFromFolderName(String folderName) {
if (folderName == null) return HELD;
String folder = folderName.toLowerCase();
if (folder.contains("witch") || folder.contains("hat")) return HEAD;
if (folder.contains("wing")) return BACK;
if (folder.contains("wheel")) return WAIST;
return HELD;
}
}

View file

@ -20,6 +20,7 @@ import dev.loki.lovisual.features.gui.clickgui.settings.render.SettingRenderCont
import dev.loki.lovisual.features.gui.clickgui.settings.render.SettingRenderSurface;
import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiHintOverlay;
import dev.loki.lovisual.features.gui.clickgui.util.ClickGuiI18n;
import dev.loki.lovisual.features.gui.clickgui.util.SettingTooltip;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElement;
import dev.loki.lovisual.features.gui.hud.draggable.DraggableHudElementRegistry;
import dev.loki.lovisual.render.engine.animation.AnimationUtility;
@ -282,10 +283,21 @@ public final class SettingsPanelComponent {
renderScrollbar(panelScale, palette);
}
renderSettingTooltip(mx, my, panelScale, palette);
renderHudEditorHints(menuX, menuY, menuW, menuH, panelScale, fadeProgress);
renderSettingsPanelHints(menuX, menuY, menuW, menuH, panelScale, fadeProgress);
}
/** Пузырь под курсором с {@code <setting key>.desc}, когда у наведенной строки есть описание. */
private void renderSettingTooltip(float mx, float my, float panelScale, SettingsGuiPalette tooltipPalette) {
for (SettingHit hit : hits) {
if (inside(mx, my, hit.x(), hit.y(), hit.w(), hit.h())) {
SettingTooltip.render(hit.setting(), mx, my, hit.y() + hit.h(), panelScale, tooltipPalette);
return;
}
}
}
public boolean mouseClicked(float mx, float my, int button, float scale) {
if (!isActive()) return false;
if (openAnim <= 0.01f) return false;

View file

@ -34,6 +34,8 @@ public abstract class Setting {
private long lastAnimTime = System.nanoTime();
private String cachedDisplayName;
private String cachedTranslationKey;
private String cachedDescription;
private String cachedDescriptionBase;
private SettingOwner cachedTranslationOwner;
private final List<String> commonI18nKeys = new ArrayList<>();
private boolean i18nNameEnabled = true;
@ -167,6 +169,25 @@ public abstract class Setting {
return resolved;
}
/**
* Hover description from {@code <translationKey>.desc} (e.g.
* setting.cosmeticdisplay.next_cosmetic.desc); null when no key exists —
* never falls back to the raw key, unlike names.
*/
public String getDescription() {
String local = getTranslationKey();
if (local == null || local.isBlank()) return null;
if (LANG_READY && cachedDescription != null && local.equals(cachedDescriptionBase)) {
return cachedDescription;
}
String translated = resolveFirstTranslation(java.util.List.of(local + ".desc"), false);
if (LANG_READY && translated != null) {
cachedDescription = translated;
cachedDescriptionBase = local;
}
return translated;
}
public SettingOwner getParent() {
return parent;
}
@ -471,6 +492,8 @@ public abstract class Setting {
private void invalidateNameCaches() {
cachedDisplayName = null;
cachedTranslationKey = null;
cachedDescription = null;
cachedDescriptionBase = null;
cachedTranslationOwner = null;
cachedOptionDisplayNames.clear();
}

View file

@ -0,0 +1,100 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.gui.clickgui.util;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D;
import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer;
import dev.loki.lovisual.features.gui.clickgui.settings.core.Setting;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import net.minecraft.client.Minecraft;
/**
* Hover tooltip for a settings row: renders {@link Setting#getDescription()}
* (the {@code <key>.desc} i18n entry) as a wrapped bubble below the cursor.
* Silent when the setting has no description key.
*/
public final class SettingTooltip {
private static final float MAX_TEXT_W = 170f;
private static final float TEXT_SIZE = 7f;
private static final float PAD = 4f;
private static final float OFFSET = 12f;
private SettingTooltip() {
}
/** @param scale ClickGUI ui scale; @param anchorBottom bottom of the hovered row (tooltip flips up when out of screen). */
public static void render(Setting setting, float mx, float my, float anchorBottom, float scale, SettingsGuiPalette palette) {
if (setting == null || scale <= 0f) return;
String description = setting.getDescription();
if (description == null || description.isBlank()) return;
TextRenderer font = ClickGuiRenderer.getInterRegular();
float size = TEXT_SIZE * scale;
float maxWidth = MAX_TEXT_W * scale;
java.util.List<String> lines = wrap(font, description, maxWidth, size);
if (lines.isEmpty()) return;
float lineH = ClickGuiRenderer.textHeight(font, size);
float boxW = maxWidth + PAD * 2f * scale;
for (String line : lines) {
boxW = Math.max(boxW, ClickGuiRenderer.textWidth(font, line, size) + PAD * 2f * scale);
}
float boxH = lines.size() * lineH + PAD * 2f * scale;
float x = clampX(mx + OFFSET * scale, boxW);
float y = anchorBottom + 3f * scale;
float screenH = screenHeight();
if (y + boxH > screenH - 4f) {
y = Math.max(4f, my - boxH - OFFSET * scale);
}
int bg = LayoutRender2D.alpha(palette.menuShadow(), 0.92f);
int stroke = LayoutRender2D.alpha(palette.panelStroke(), 0.9f);
float radius = 3.5f * scale;
LayoutRender2D.roundedQuad(x, y, boxW, boxH, radius, bg, bg, bg, bg);
LayoutRender2D.roundedStrokeQuad(x, y, boxW, boxH, radius, 0.5f * scale,
stroke, stroke, stroke, stroke);
int textColor = palette.moduleDescriptionText();
float ty = y + PAD * scale;
for (String line : lines) {
ClickGuiRenderer.drawText(font, line, x + PAD * scale, ty, size, textColor, false);
ty += lineH;
}
}
private static java.util.List<String> wrap(TextRenderer font, String text, float maxWidth, float size) {
java.util.List<String> lines = new java.util.ArrayList<>();
StringBuilder line = new StringBuilder();
for (String word : text.split(" ")) {
if (word.isEmpty()) continue;
String candidate = line.isEmpty() ? word : line + " " + word;
if (!line.isEmpty() && ClickGuiRenderer.textWidth(font, candidate, size) > maxWidth) {
lines.add(line.toString());
line = new StringBuilder(word);
} else {
line = new StringBuilder(candidate);
}
}
if (!line.isEmpty()) lines.add(line.toString());
return lines;
}
private static float screenHeight() {
Minecraft mc = Minecraft.getInstance();
return mc != null ? mc.getWindow().getGuiScaledHeight() : 240f;
}
private static float clampX(float x, float boxW) {
Minecraft mc = Minecraft.getInstance();
if (mc == null) return x;
float screenW = mc.getWindow().getGuiScaledWidth();
return Math.min(Math.max(4f, x), Math.max(4f, screenW - boxW - 4f));
}
}

View file

@ -223,8 +223,8 @@ public enum ModuleManager {
for (Module m : phaseModules) {
if (m.isEnabled()) {
try (ProfilerPhase.Scope _ = ProfilerPhase.scope("2d:module:" + m.name());
RenderProfiler2D.Section ignored = RenderProfiler2D.section("module:" + m.name())) {
try (ProfilerPhase.Scope _ = ProfilerPhase.scope("2d:module:", m.name());
RenderProfiler2D.Section ignored = RenderProfiler2D.section("module:", m.name())) {
if (ModuleExtensionManager.beforeHudRender(m, renderer, textRenderer, ctx, tickDelta)) {
m.onRenderHudEngine(renderer, textRenderer, ctx, tickDelta);
ModuleExtensionManager.afterHudRender(m, renderer, textRenderer, ctx, tickDelta);
@ -246,8 +246,8 @@ public enum ModuleManager {
for (Module m : phaseModules) {
if (m.isEnabled()) {
try (ProfilerPhase.Scope phaseScope = ProfilerPhase.scope("2d:module_fg:" + m.name());
RenderProfiler2D.Section ignored = RenderProfiler2D.section("module_fg:" + m.name())) {
try (ProfilerPhase.Scope phaseScope = ProfilerPhase.scope("2d:module_fg:", m.name());
RenderProfiler2D.Section ignored = RenderProfiler2D.section("module_fg:", m.name())) {
m.onRenderHudEngineForeground(renderer, textRenderer, ctx, tickDelta);
}
}
@ -295,8 +295,8 @@ public enum ModuleManager {
for (Module m : phaseModules) {
if (m.isEnabled()) {
try (ProfilerPhase.Scope phaseScope = ProfilerPhase.scope("3d:module:" + m.name());
RenderProfiler3D.Section ignored = RenderProfiler3D.section("module:" + m.name())) {
try (ProfilerPhase.Scope phaseScope = ProfilerPhase.scope("3d:module:", m.name());
RenderProfiler3D.Section ignored = RenderProfiler3D.section("module:", m.name())) {
if (ModuleExtensionManager.beforeWorldRender(m, renderer, depthRenderer, tickDelta)) {
m.onRenderWorldEngine(renderer, depthRenderer, tickDelta);
ModuleExtensionManager.afterWorldRender(m, renderer, depthRenderer, tickDelta);

View file

@ -15,6 +15,7 @@ import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.modules.combat.prediction.Predictions;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsAim;
import dev.loki.lovisual.features.module.modules.combat.predictions.PredictionsSimulation;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.phase.HudPhase;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.features.module.core.Module;
@ -543,6 +544,7 @@ public class Predictions extends Module {
? LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE
: LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE_LIQUID_IGNORE;
Renderer3D.DepthMode depthMode = walls ? Renderer3D.DepthMode.MAIN : Renderer3D.DepthMode.PRE_DEPTH;
if (FpsOptimizer.bloomDisabled()) return;
MeshBuilder mesh = renderer.batchTextured(pipeline, TextureStorage.BLOOM, depthMode);
if (mesh == null) return;
@ -645,6 +647,7 @@ public class Predictions extends Module {
? LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE
: LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE_LIQUID_IGNORE;
Renderer3D.DepthMode depthMode = walls ? Renderer3D.DepthMode.MAIN : Renderer3D.DepthMode.PRE_DEPTH;
if (FpsOptimizer.bloomDisabled()) return;
MeshBuilder mesh = renderer.batchTextured(pipeline, TextureStorage.BLOOM, depthMode);
if (mesh == null) return;

View file

@ -23,6 +23,7 @@ import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspCrystals;
import dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspEffects;
import dev.loki.lovisual.features.module.modules.combat.targetesp.TargetEspModes;
@ -302,12 +303,14 @@ public class TargetESP extends Module {
LoVisualRenderPipelines.WORLD_COLORED_LINES_DEPTH,
depthMode
);
MeshBuilder bloomMesh = renderer.batchTextured(
LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE_DEPTH,
CRYSTAL_BLOOM,
depthMode
);
if (additiveMesh == null || fillMesh == null || lineMesh == null || bloomMesh == null) {
MeshBuilder bloomMesh = FpsOptimizer.bloomDisabled()
? null
: renderer.batchTextured(
LoVisualRenderPipelines.WORLD_TEXTURED_ADDITIVE_DEPTH,
CRYSTAL_BLOOM,
depthMode
);
if (additiveMesh == null || fillMesh == null || lineMesh == null) {
return;
}

View file

@ -57,7 +57,9 @@ public final class TargetEspCrystals {
appendCrystalGeometry(fillMesh, targetPos, this, orbitYawRad, 1.0f, applyOpacity(baseColor, 0.3f), true);
appendCrystalGeometry(lineMesh, targetPos, this, orbitYawRad, 1.0f, applyOpacity(baseColor, 0.8f), false);
appendCrystalGeometry(additiveMesh, targetPos, this, orbitYawRad, 1.2f, applyOpacity(baseColor, 0.3f), true);
appendBloomSphere(bloomMesh, targetPos, this, orbitYawRad, baseColor);
if (bloomMesh != null) {
appendBloomSphere(bloomMesh, targetPos, this, orbitYawRad, baseColor);
}
}
}

View file

@ -1,139 +0,0 @@
package dev.loki.lovisual.features.module.modules.visuals.cosmetics;
import dev.loki.lovisual.config.values.mode.EnumValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.cosmetic.manager.CosmeticManager;
import dev.loki.lovisual.features.cosmetic.model.CosmeticModel;
import dev.loki.lovisual.features.cosmetic.manager.CosmeticRenderer;
import dev.loki.lovisual.features.cosmetic.manager.CosmeticRepository;
import dev.loki.lovisual.features.cosmetic.model.CosmeticSlot;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.util.screen.ClientScreen;
import net.minecraft.client.Minecraft;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import dev.loki.lovisual.features.module.modules.visuals.cosmetics.CosmeticsScreen;
/**
* Renders the locally equipped cosmetics (per body-zone slot) attached to the local
* player. Right-click the module card in ClickGUI to open {@link CosmeticsScreen}
* (3D preview + per-slot pickers). Offset/rotation/scale overrides are per accessory
* slot (not per model) — see {@link #offsetsFor(CosmeticSlot)}.
*/
@ModuleInfo(
id = "cosmeticdisplay",
displayName = "Cosmetics",
aliases = {"cosmetics"},
category = ModuleCategory.VISUALS,
description = "Отображает надетую косметику по слотам (модель/питомец/аксессуары)"
)
public final class CosmeticDisplay extends Module {
/** Per-slot offset/rotation/scale user overrides, applied on top of the base attachment transform. */
public record SlotOffsetSettings(NumberValue<Float> offsetX, NumberValue<Float> offsetY,
NumberValue<Float> offsetZ, NumberValue<Float> rotation,
NumberValue<Float> scale) {
}
private static final CosmeticSlot[] ACCESSORY_SLOTS = {
CosmeticSlot.HEAD, CosmeticSlot.SHOULDERS, CosmeticSlot.BACK,
CosmeticSlot.WAIST, CosmeticSlot.LEGS, CosmeticSlot.HELD
};
private final EnumValue<CosmeticSlot> activeSlot =
enumSetting("cosmeticsActiveSlot", "active_slot", CosmeticSlot.HEAD, ACCESSORY_SLOTS);
private final Map<CosmeticSlot, SlotOffsetSettings> slotSettings = buildSlotSettings();
{
action("Next Cosmetic", "NONE");
action("Prev Cosmetic", "NONE");
action("Open Cosmetics", "NONE");
}
private Map<CosmeticSlot, SlotOffsetSettings> buildSlotSettings() {
Map<CosmeticSlot, SlotOffsetSettings> map = new EnumMap<>(CosmeticSlot.class);
for (CosmeticSlot slot : ACCESSORY_SLOTS) {
String prefix = "cosmetics" + capitalize(slot.getId());
SlotOffsetSettings settings = new SlotOffsetSettings(
visibleWhen(num(prefix + "OffsetX", "offset_x", 0.0f, -1.0f, 1.0f), () -> activeSlot.get() == slot),
visibleWhen(num(prefix + "OffsetY", "offset_y", 0.0f, -1.0f, 1.0f), () -> activeSlot.get() == slot),
visibleWhen(num(prefix + "OffsetZ", "offset_z", 0.0f, -1.0f, 1.0f), () -> activeSlot.get() == slot),
visibleWhen(num(prefix + "Rotation", "rotation", 0.0f, -180.0f, 180.0f), () -> activeSlot.get() == slot),
visibleWhen(num(prefix + "Scale", "scale", 1.0f, 0.1f, 3.0f), () -> activeSlot.get() == slot)
);
map.put(slot, settings);
}
return map;
}
private static String capitalize(String s) {
return s.isEmpty() ? s : Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
public SlotOffsetSettings offsetsFor(CosmeticSlot slot) {
return slotSettings.get(slot);
}
public CosmeticSlot getActiveSlot() {
return activeSlot.get();
}
public void setActiveSlot(CosmeticSlot slot) {
activeSlot.set(slot);
}
@Override
public void onEnable() {
CosmeticRepository.get().reload();
}
@Override
public void onTick() {
if (!isEnabled()) return;
if (isActionPressedOnce("Next Cosmetic")) {
cycle(1);
} else if (isActionPressedOnce("Prev Cosmetic")) {
cycle(-1);
} else if (isActionPressedOnce("Open Cosmetics")) {
openScreen();
}
}
public void openScreen() {
ClientScreen.show(new CosmeticsScreen());
}
private void cycle(int direction) {
List<CosmeticModel> models = CosmeticRepository.get().bySlot(CosmeticSlot.MODEL);
if (models.isEmpty()) return;
CosmeticModel current = CosmeticManager.get().getActive(CosmeticSlot.MODEL);
int index = current == null ? -1 : models.indexOf(current);
int next = Math.floorMod(index + direction, models.size());
CosmeticManager.get().setActive(CosmeticSlot.MODEL, models.get(next));
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.AFTER_POST_PROCESS;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.level == null || mc.player == null) return;
for (CosmeticSlot slot : CosmeticSlot.values()) {
CosmeticRenderer.renderOnPlayer(renderer, mc.player, slot, tickDelta);
}
}
}

View file

@ -1,81 +0,0 @@
package dev.loki.lovisual.features.module.modules.visuals.cosmetics;
import dev.loki.lovisual.features.cosmetic.model.CosmeticSlot;
/**
* Pure geometry for the clickable body-zone rectangles overlaid on the 3D preview.
* Zones are fixed fractions of the preview region height (not bone-projected: the
* inventory-style entity preview renders through its own camera space, so world-camera
* bone projection doesn't apply here).
*/
public final class CosmeticsHotspot {
private final CosmeticSlot slot;
private final float x;
private final float y;
private final float width;
private final float height;
public CosmeticsHotspot(CosmeticSlot slot, float x, float y, float width, float height) {
this.slot = slot;
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
public CosmeticSlot slot() {
return slot;
}
public float x() {
return x;
}
public float y() {
return y;
}
public float width() {
return width;
}
public float height() {
return height;
}
public boolean contains(double mouseX, double mouseY) {
return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + height;
}
/**
* Builds the four body-zone hotspots for a preview region. BACK reuses the
* SHOULDERS rectangle (selected via a separate front/back view toggle in the screen).
*/
public static CosmeticsHotspot[] forRegion(float regionX, float regionY, float regionW, float regionH, boolean backView) {
float headH = regionH * 0.20f;
float shouldersH = regionH * 0.15f;
float waistH = regionH * 0.15f;
float legsH = regionH - headH - shouldersH - waistH;
float y0 = regionY;
float y1 = y0 + headH;
float y2 = y1 + shouldersH;
float y3 = y2 + waistH;
CosmeticSlot shouldersOrBack = backView ? CosmeticSlot.BACK : CosmeticSlot.SHOULDERS;
return new CosmeticsHotspot[]{
new CosmeticsHotspot(CosmeticSlot.HEAD, regionX, y0, regionW, headH),
new CosmeticsHotspot(shouldersOrBack, regionX, y1, regionW, shouldersH),
new CosmeticsHotspot(CosmeticSlot.WAIST, regionX, y2, regionW, waistH),
new CosmeticsHotspot(CosmeticSlot.LEGS, regionX, y3, regionW, legsH)
};
}
public static CosmeticSlot hitTest(CosmeticsHotspot[] hotspots, double mouseX, double mouseY) {
for (CosmeticsHotspot hotspot : hotspots) {
if (hotspot.contains(mouseX, mouseY)) return hotspot.slot();
}
return null;
}
}

View file

@ -1,197 +0,0 @@
package dev.loki.lovisual.features.module.modules.visuals.cosmetics;
import dev.loki.lovisual.features.cosmetic.model.CosmeticSlot;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import net.minecraft.client.Minecraft;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.screens.Screen;
import net.minecraft.client.gui.screens.inventory.InventoryScreen;
import net.minecraft.client.input.MouseButtonEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.world.entity.player.Player;
import dev.loki.lovisual.features.module.modules.visuals.cosmetics.CosmeticDisplay;
import dev.loki.lovisual.features.module.modules.visuals.cosmetics.CosmeticsHotspot;
import dev.loki.lovisual.features.module.modules.visuals.cosmetics.CosmeticsSlotPanel;
/**
* Cosmetics editor: a live 3D player preview on the left (drag to rotate, scroll to
* zoom) with clickable body-zone hotspots, and a per-slot picker/offset panel on the
* right. Opened via the "Open Cosmetics" action / right-click on the module card.
*
* The preview uses {@link InventoryScreen#extractEntityInInventoryFollowsMouse}, which
* derives the entity's yaw/pitch from a "mouse position" argument — we don't feed it the
* real cursor, we feed it a synthetic position offset by {@link #previewYawPx}/
* {@link #previewPitchPx} so the pose only changes while the user is dragging the preview.
*/
public final class CosmeticsScreen extends Screen {
private static final float DEFAULT_YAW_PX = -40f;
private static final float DEFAULT_PITCH_PX = 15f;
private static final float MAX_PITCH_PX = 60f;
private static final float PANEL_WIDTH = 160f;
private static final float MARGIN = 12f;
private final CosmeticsSlotPanel slotPanel;
private float previewYawPx = DEFAULT_YAW_PX;
private float previewPitchPx = DEFAULT_PITCH_PX;
private float previewScale = 1.0f;
private boolean backView = false;
private CosmeticSlot selectedSlot = CosmeticSlot.HEAD;
private boolean draggingPreview = false;
private double pressX;
private double pressY;
public CosmeticsScreen() {
super(Component.literal("Cosmetics"));
CosmeticDisplay module = Modules.get(CosmeticDisplay.class);
this.slotPanel = new CosmeticsSlotPanel(module);
}
@Override
public void extractRenderState(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
int w = context.guiWidth();
int h = context.guiHeight();
Renderer2D.COLOR.begin();
try {
Renderer2D.COLOR.roundedRect(0, 0, w, h, 0f, Theme.theme().windowBg());
float previewW = w - PANEL_WIDTH - MARGIN * 3f;
float previewH = h - MARGIN * 2f;
renderPreview(context, MARGIN, MARGIN, previewW, previewH, delta);
renderHotspots(MARGIN, MARGIN, previewW, previewH, mouseX, mouseY);
float panelX = MARGIN * 2f + previewW;
slotPanel.render(panelX, MARGIN, PANEL_WIDTH, previewH, selectedSlot, mouseX, mouseY);
} finally {
Renderer2D.COLOR.render();
}
}
@Override
public void extractBackground(GuiGraphicsExtractor context, int mouseX, int mouseY, float delta) {
}
private void renderPreview(GuiGraphicsExtractor context, float x, float y, float width, float height, float tickDelta) {
Player player = Minecraft.getInstance().player;
if (player == null) return;
int x1 = (int) x;
int y1 = (int) y;
int x2 = (int) (x + width);
int y2 = (int) (y + height);
int size = (int) (Math.min(width, height) * 0.45f * previewScale);
float centerX = x + width / 2f;
float centerY = y + height / 2f;
InventoryScreen.extractEntityInInventoryFollowsMouse(
context, x1, y1, x2, y2, size,
centerX + previewYawPx, centerY + previewPitchPx, tickDelta, player
);
}
private void renderHotspots(float x, float y, float width, float height, double mouseX, double mouseY) {
CosmeticsHotspot[] zones = CosmeticsHotspot.forRegion(x, y, width, height, backView);
int accent = Theme.theme().accent();
for (CosmeticsHotspot zone : zones) {
boolean hovered = zone.contains(mouseX, mouseY);
boolean active = zone.slot() == selectedSlot;
if (hovered || active) {
int alpha = active ? 70 : 35;
Renderer2D.COLOR.roundedRect(zone.x(), zone.y(), zone.width(), zone.height(), 3f,
(accent & 0x00FFFFFF) | (alpha << 24));
}
}
}
@Override
public boolean mouseClicked(MouseButtonEvent click, boolean doubled) {
pressX = click.x();
pressY = click.y();
if (handlePanelClick(click.x(), click.y())) return true;
if (click.button() == 0 && isInsidePreview((float) click.x(), (float) click.y())) {
draggingPreview = true;
return true;
}
return true;
}
@Override
public boolean mouseDragged(MouseButtonEvent click, double dragX, double dragY) {
if (!draggingPreview) return false;
previewYawPx += (float) dragX;
previewPitchPx = Math.clamp(previewPitchPx + (float) dragY, -MAX_PITCH_PX, MAX_PITCH_PX);
return true;
}
@Override
public boolean mouseReleased(MouseButtonEvent click) {
boolean wasDragging = draggingPreview;
draggingPreview = false;
double moved = Math.hypot(click.x() - pressX, click.y() - pressY);
if (!wasDragging || moved > 4.0) return true;
if (moved <= 4.0) {
handlePreviewClick((float) click.x(), (float) click.y());
}
return true;
}
@Override
public boolean mouseScrolled(double mouseX, double mouseY, double horizontalAmount, double verticalAmount) {
previewScale = Math.clamp(previewScale + (float) verticalAmount * 0.1f, 0.5f, 2.0f);
return true;
}
private void handlePreviewClick(float mouseX, float mouseY) {
int w = Minecraft.getInstance().getWindow().getGuiScaledWidth();
int h = Minecraft.getInstance().getWindow().getGuiScaledHeight();
float previewW = w - PANEL_WIDTH - MARGIN * 3f;
float previewH = h - MARGIN * 2f;
CosmeticsHotspot[] zones = CosmeticsHotspot.forRegion(MARGIN, MARGIN, previewW, previewH, backView);
CosmeticSlot hit = CosmeticsHotspot.hitTest(zones, mouseX, mouseY);
if (hit == CosmeticSlot.BACK || hit == CosmeticSlot.SHOULDERS) {
backView = !backView;
}
if (hit != null) {
selectSlot(hit);
}
}
private boolean handlePanelClick(double mouseX, double mouseY) {
int w = Minecraft.getInstance().getWindow().getGuiScaledWidth();
int h = Minecraft.getInstance().getWindow().getGuiScaledHeight();
float previewW = w - PANEL_WIDTH - MARGIN * 3f;
float previewH = h - MARGIN * 2f;
float panelX = MARGIN * 2f + previewW;
return slotPanel.handleClick(panelX, MARGIN, PANEL_WIDTH, selectedSlot, mouseX, mouseY);
}
private void selectSlot(CosmeticSlot slot) {
selectedSlot = slot;
CosmeticDisplay module = Modules.get(CosmeticDisplay.class);
if (module != null && slot.isAccessory()) {
module.setActiveSlot(slot);
}
}
private boolean isInsidePreview(float mouseX, float mouseY) {
int w = Minecraft.getInstance().getWindow().getGuiScaledWidth();
int h = Minecraft.getInstance().getWindow().getGuiScaledHeight();
float previewW = w - PANEL_WIDTH - MARGIN * 3f;
float previewH = h - MARGIN * 2f;
return mouseX >= MARGIN && mouseX <= MARGIN + previewW && mouseY >= MARGIN && mouseY <= MARGIN + previewH;
}
@Override
public boolean isPauseScreen() {
return false;
}
}

View file

@ -1,154 +0,0 @@
package dev.loki.lovisual.features.module.modules.visuals.cosmetics;
import dev.loki.lovisual.features.cosmetic.manager.CosmeticManager;
import dev.loki.lovisual.features.cosmetic.model.CosmeticModel;
import dev.loki.lovisual.features.cosmetic.manager.CosmeticRepository;
import dev.loki.lovisual.features.cosmetic.model.CosmeticSlot;
import dev.loki.lovisual.features.theme.Theme;
import dev.loki.lovisual.render.engine.color.RenderColor;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.text.render.TextRenderer;
import dev.loki.lovisual.render.engine.text.font.Fonts;
import dev.loki.lovisual.render.engine.text.font.info.FontInfo;
import net.minecraft.client.resources.language.I18n;
import java.util.List;
import dev.loki.lovisual.features.module.modules.visuals.cosmetics.CosmeticDisplay;
import dev.loki.lovisual.features.module.modules.visuals.cosmetics.CosmeticsScreen;
/**
* Right panel of {@link CosmeticsScreen}: model picker for the currently selected
* slot (live-apply on click, "None" always first) plus, for accessory slots, the
* five per-slot offset/rotation/scale sliders declared on {@link CosmeticDisplay}.
*/
public final class CosmeticsSlotPanel {
private static final float ROW_HEIGHT = 20f;
private static final float SLIDER_HEIGHT = 14f;
private final CosmeticDisplay module;
CosmeticsSlotPanel(CosmeticDisplay module) {
this.module = module;
}
void render(float x, float y, float width, float height, CosmeticSlot slot, double mouseX, double mouseY) {
TextRenderer text = Fonts.renderer("Onest", FontInfo.Type.Regular, TextRenderer.get());
int accent = Theme.theme().accent();
int muted = Theme.theme().textMuted();
text.begin(1.0, true, false);
text.render(slot.getDisplayName(), x, y, new RenderColor(Theme.theme().textPrimary()), false);
text.end();
float rowY = y + 16f;
rowY = renderRow(x, rowY, width, "None", isNoneActive(slot), mouseX, mouseY, text, accent, muted);
for (CosmeticModel model : CosmeticRepository.get().bySlot(slot)) {
boolean active = CosmeticManager.get().getActive(slot) == model;
rowY = renderRow(x, rowY, width, model.getName(), active, mouseX, mouseY, text, accent, muted);
}
if (slot.isAccessory()) {
renderOffsetSliders(x, rowY + 10f, width, slot, mouseX, mouseY, text, accent, muted);
}
}
/** @return the y position immediately below the rendered row. */
private float renderRow(float x, float y, float width, String label, boolean active,
double mouseX, double mouseY, TextRenderer text, int accent, int muted) {
boolean hovered = mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + ROW_HEIGHT;
int bg = active ? Theme.theme().accentSoft() : hovered ? Theme.theme().surfaceHover() : Theme.theme().surface();
Renderer2D.COLOR.roundedRect(x, y, width, ROW_HEIGHT - 2f, 3f, bg);
text.begin(1.0, true, false);
text.render(label, x + 6f, y + 6f, new RenderColor(active ? accent : muted), false);
text.end();
return y + ROW_HEIGHT;
}
private void renderOffsetSliders(float x, float y, float width, CosmeticSlot slot,
double mouseX, double mouseY, TextRenderer text, int accent, int muted) {
CosmeticDisplay.SlotOffsetSettings offsets = module.offsetsFor(slot);
if (offsets == null) return;
float rowY = y;
rowY = slider(x, rowY, width, I18n.get("setting.cosmetics.offset_x"), offsets.offsetX(), text, accent, muted);
rowY = slider(x, rowY, width, I18n.get("setting.cosmetics.offset_y"), offsets.offsetY(), text, accent, muted);
rowY = slider(x, rowY, width, I18n.get("setting.cosmetics.offset_z"), offsets.offsetZ(), text, accent, muted);
rowY = slider(x, rowY, width, I18n.get("setting.cosmetics.rotation"), offsets.rotation(), text, accent, muted);
slider(x, rowY, width, I18n.get("setting.cosmetics.scale"), offsets.scale(), text, accent, muted);
}
private float slider(float x, float y, float width, String label,
dev.loki.lovisual.config.values.primitive.NumberValue<Float> value,
TextRenderer text, int accent, int muted) {
text.begin(0.85, true, false);
text.render(label + ": " + String.format("%.2f", value.get()), x, y, new RenderColor(muted), false);
text.end();
float barY = y + 10f;
float min = value.getMin();
float max = value.getMax();
float fraction = max > min ? (value.get() - min) / (max - min) : 0f;
Renderer2D.COLOR.roundedRect(x, barY, width, SLIDER_HEIGHT - 4f, 2f, Theme.theme().surface());
Renderer2D.COLOR.roundedRect(x, barY, width * fraction, SLIDER_HEIGHT - 4f, 2f, accent);
return barY + SLIDER_HEIGHT;
}
/**
* Drag/click on a slider bar; call from the screen's mouse handlers with the same
* rect origin used in {@link #render}. Returns true if the point hit a slider for this slot.
*/
boolean handleSliderDrag(float x, float listBottomY, float width, CosmeticSlot slot, double mouseX, double mouseY) {
if (!slot.isAccessory()) return false;
CosmeticDisplay.SlotOffsetSettings offsets = module.offsetsFor(slot);
if (offsets == null) return false;
List<dev.loki.lovisual.config.values.primitive.NumberValue<Float>> sliders = List.of(
offsets.offsetX(), offsets.offsetY(), offsets.offsetZ(), offsets.rotation(), offsets.scale());
float rowY = listBottomY + 10f;
for (dev.loki.lovisual.config.values.primitive.NumberValue<Float> value : sliders) {
float barY = rowY + 10f;
if (mouseY >= barY && mouseY <= barY + SLIDER_HEIGHT - 4f && mouseX >= x && mouseX <= x + width) {
float fraction = (float) Math.clamp((mouseX - x) / width, 0.0, 1.0);
value.set(value.getMin() + fraction * (value.getMax() - value.getMin()));
return true;
}
rowY += SLIDER_HEIGHT;
}
return false;
}
boolean handleClick(float x, float y, float width, CosmeticSlot slot, double mouseX, double mouseY) {
List<CosmeticModel> models = CosmeticRepository.get().bySlot(slot);
float rowY = y + 16f;
if (rowHit(x, rowY, width, mouseX, mouseY)) {
CosmeticManager.get().setActive(slot, null);
return true;
}
rowY += ROW_HEIGHT;
for (CosmeticModel model : models) {
if (rowHit(x, rowY, width, mouseX, mouseY)) {
CosmeticManager.get().setActive(slot, model);
return true;
}
rowY += ROW_HEIGHT;
}
return handleSliderDrag(x, rowY, width, slot, mouseX, mouseY);
}
private boolean rowHit(float x, float y, float width, double mouseX, double mouseY) {
return mouseX >= x && mouseX <= x + width && mouseY >= y && mouseY <= y + ROW_HEIGHT;
}
private boolean isNoneActive(CosmeticSlot slot) {
return CosmeticManager.get().getActive(slot) == null;
}
}

View file

@ -33,6 +33,7 @@ import org.joml.Vector3f;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.mixininterface.entity.ILocalPlayer;
import dev.loki.lovisual.mixininterface.entity.IPlayerAttackCooldown;
@ -500,6 +501,7 @@ public class HitEffect extends Module {
Quaternionf camRot = RenderState.cameraRotation;
if (hitParticleTexture.get() == ParticleTextureMode.BLOOM) {
if (FpsOptimizer.bloomDisabled()) return;
MeshBuilder mesh = renderer.batchTextured(pipeline, TextureStorage.FIRE_FLY, depthMode);
if (mesh == null) return;
for (Particle3D p : hitParticles) {

View file

@ -24,6 +24,7 @@ import org.joml.Vector3f;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.mixininterface.entity.IEntity;
import dev.loki.lovisual.mixins.accessors.projectile.PersistentProjectileEntityAccessor;
@ -562,6 +563,7 @@ public class Trails extends Module {
Quaternionf camRot = RenderState.cameraRotation;
if (particleTexture.get() == ParticleTextureMode.BLOOM) {
if (FpsOptimizer.bloomDisabled()) return;
MeshBuilder mesh = renderer.batchTextured(pipeline, TextureStorage.FIRE_FLY, depthMode);
if (mesh == null) return;
for (Particle3D p : extraParticles) {

View file

@ -26,6 +26,7 @@ import dev.loki.lovisual.config.common.CommonSettingSchemas;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.render.engine.texture.TextureStorage;
@ -494,7 +495,9 @@ public class WorldParticles extends Module {
MeshBuilder distortionMesh = tintedTexturedBatch(renderer, useDepth, depthMode, TextureStorage.FUNNEL_DISTORTION);
MeshBuilder eyeMesh = tintedTexturedBatch(renderer, useDepth, depthMode, TextureStorage.FUNNEL_EYE);
MeshBuilder bloomMesh = tintedTexturedBatch(renderer, useDepth, depthMode, TextureStorage.BLOOM);
MeshBuilder bloomMesh = FpsOptimizer.bloomDisabled()
? null
: tintedTexturedBatch(renderer, useDepth, depthMode, TextureStorage.BLOOM);
if (distortionMesh == null && eyeMesh == null && bloomMesh == null) {
return;
}

View file

@ -13,8 +13,6 @@ import dev.loki.lovisual.render.engine.animation.AnimationUtility;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.player.Player;
import dev.loki.lovisual.features.module.modules.visuals.hats.ChinaHat;
import dev.loki.lovisual.features.module.modules.visuals.hats.TazikHat;
/**
* Color resolution helpers for ChinaHat. Shared color-math and per-vertex shading,

View file

@ -10,7 +10,6 @@ package dev.loki.lovisual.features.module.modules.visuals.hats;
import dev.loki.lovisual.util.time.GameClock;
import net.minecraft.client.Minecraft;
import net.minecraft.core.BlockPos;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.player.Player;
@ -34,11 +33,6 @@ import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import dev.loki.lovisual.render.helpers.player.HeadAnchor;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Random;
//todo Description
@ModuleInfo(
id = "tazikhat",
@ -48,14 +42,12 @@ import java.util.Random;
public final class TazikHat extends Module {
private static final int SEGMENTS = 56;
private static final long DEATH_LIFE_MS = 3600L;
private static final double RAINBOW_SPATIAL_SCALE = 12.0;
private static final float BOWL_RIM_RADIUS = 0.42f;
private static final float BOWL_TOP_RADIUS = 0.245f;
private static final float BOWL_HEIGHT = 0.18f;
private static final float BOWL_RIM_WIDTH = 0.052f;
private final Random random = new Random();
private final EnumValue<RenderTarget> renderTarget =
enumMode(
"render_target",
@ -158,21 +150,6 @@ public final class TazikHat extends Module {
0.0f,
2.5f
);
private final BooleanValue deathFlight =
bool(
"death_flight",
true
);
private final NumberValue<Float> deathFlightPower =
visibleWhen(num(
"death_flight_power",
1.0f,
0.25f,
3.0f
), deathFlight::get);
private final Map<Integer, Long> handledDeaths = new HashMap<>();
private final Map<Integer, FlyingTazik> flying = new HashMap<>();
private static void quadGradient(MeshBuilder mesh,
Vec3 p1, int c1,
Vec3 p2, int c2,
@ -298,49 +275,6 @@ public final class TazikHat extends Module {
return WorldPhase.BEFORE_TRANSLUCENT;
}
@Override
public void onTick() {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.level == null || mc.player == null) {
return;
}
long now = GameClock.millis();
for (Player player : mc.level.players()) {
if (player == null || player.isRemoved()) continue;
int id = player.getId();
if (player.isAlive() && player.getHealth() > 0.0f) {
handledDeaths.remove(id);
continue;
}
if (!deathFlight.get()) continue;
if (handledDeaths.containsKey(id)) continue;
handledDeaths.put(id, now);
spawnFlyingTazik(player);
}
handledDeaths.entrySet().removeIf(entry -> now - entry.getValue() > 8000L);
Iterator<Map.Entry<Integer, FlyingTazik>> iterator = flying.entrySet().iterator();
while (iterator.hasNext()) {
FlyingTazik tazik = iterator.next().getValue();
if (tazik.update()) {
iterator.remove();
}
}
}
@Override
public void onDisable() {
handledDeaths.clear();
flying.clear();
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
@ -380,8 +314,6 @@ public final class TazikHat extends Module {
renderAttached(buffers, player, tickDelta);
}
}
renderFlying(buffers, tickDelta);
}
private boolean shouldRenderSelf() {
@ -425,33 +357,6 @@ public final class TazikHat extends Module {
);
}
private void renderFlying(RenderBuffers buffers, float tickDelta) {
long now = GameClock.millis();
for (FlyingTazik tazik : flying.values()) {
float life = (now - tazik.createdAtMs) / (float) DEATH_LIFE_MS;
float fade = 1.0f - AnimationUtility.smoothstep(Math.max(0.0f, life - 0.65f) / 0.35f);
Vec3 pos = new Vec3(
AnimationUtility.lerp(tazik.prevPos.x, tazik.pos.x, tickDelta),
AnimationUtility.lerp(tazik.prevPos.y, tazik.pos.y, tickDelta),
AnimationUtility.lerp(tazik.prevPos.z, tazik.pos.z, tickDelta)
);
buildFlyingTazik(
buffers,
pos,
tazik.scale,
tazik.yaw,
tazik.pitch,
tazik.roll,
tazik.palette,
fade,
tazik.seed
);
}
}
private Wobble resolveWobble(Player player, float tickDelta) {
float hurt = Math.max(0.0f, player.hurtTime - tickDelta);
float hit = AnimationUtility.easeOutBack(AnimationUtility.clamp01(hurt / 10.0f), 1.25f);
@ -528,28 +433,6 @@ public final class TazikHat extends Module {
drawTazikMesh(buffers, rimOuter, rimInner, topOuter, topInner, topCenter, palette, alphaMul);
}
private void buildFlyingTazik(RenderBuffers buffers,
Vec3 center,
float size,
float yawDeg,
float pitchDeg,
float rollDeg,
Palette palette,
float alphaMul,
int seed) {
buildTazik(
buffers,
HeadAnchor.world(center),
size,
yawDeg,
pitchDeg,
rollDeg,
palette,
alphaMul,
seed
);
}
private void drawTazikMesh(RenderBuffers buffers,
Vec3[] rimOuter,
Vec3[] rimInner,
@ -589,49 +472,6 @@ public final class TazikHat extends Module {
}
}
private void spawnFlyingTazik(Player player) {
Minecraft mc = Minecraft.getInstance();
if (player == null) return;
boolean helmet = hasHelmet(player);
float size = scale.get() * (helmet ? helmetScale.get() : 1.0f);
HeadAnchor anchor = HeadAnchor.of(
player,
1.0f,
headTopOffset.get(),
headForwardOffset.get(),
headRightOffset.get()
);
Vec3 base = anchor.origin();
Vec3 away = mc.player != null
? base.subtract(mc.player.getEyePosition())
: new Vec3(randomRange(-1.0f, 1.0f), 0.0, randomRange(-1.0f, 1.0f));
if (away.lengthSqr() < 1.0e-5) {
away = new Vec3(randomRange(-1.0f, 1.0f), 0.0, randomRange(-1.0f, 1.0f));
}
away = away.normalize();
float power = deathFlightPower.get();
Vec3 velocity = new Vec3(
away.x * randomRange(0.18f, 0.34f) * power + randomRange(-0.08f, 0.08f),
randomRange(0.34f, 0.58f) * power,
away.z * randomRange(0.18f, 0.34f) * power + randomRange(-0.08f, 0.08f)
);
flying.put(player.getId(), new FlyingTazik(
base,
velocity,
size,
resolvePalette(player),
random.nextInt()
));
}
private Palette resolvePalette(Player player) {
if (useRelations.get()) {
int relation = CategoryService.getColor(player);
@ -713,10 +553,6 @@ public final class TazikHat extends Module {
);
}
private float randomRange(float min, float max) {
return min + random.nextFloat() * (max - min);
}
public enum RenderTarget implements EnumValue.IdProvider {
SELF("self"),
PLAYERS("players"),
@ -764,96 +600,4 @@ public final class TazikHat extends Module {
private record Wobble(float pitchDeg, float rollDeg) {
}
private final class FlyingTazik {
private final float scale;
private final Palette palette;
private final int seed;
private final long createdAtMs;
private Vec3 pos;
private Vec3 prevPos;
private Vec3 velocity;
private float yaw;
private float pitch;
private float roll;
private float yawVelocity;
private float pitchVelocity;
private float rollVelocity;
private FlyingTazik(Vec3 pos, Vec3 velocity, float scale, Palette palette, int seed) {
this.pos = pos;
this.prevPos = pos;
this.velocity = velocity;
this.scale = scale;
this.palette = palette;
this.seed = seed;
this.createdAtMs = GameClock.millis();
float power = deathFlightPower.get();
this.yaw = randomRange(0.0f, 360.0f);
this.pitch = randomRange(-20.0f, 20.0f);
this.roll = randomRange(-20.0f, 20.0f);
this.yawVelocity = randomRange(-18.0f, 18.0f) * power;
this.pitchVelocity = randomRange(-22.0f, 22.0f) * power;
this.rollVelocity = randomRange(-24.0f, 24.0f) * power;
}
private boolean update() {
Minecraft mc = Minecraft.getInstance();
long age = GameClock.millis() - createdAtMs;
if (age > DEATH_LIFE_MS) {
return true;
}
prevPos = pos;
velocity = velocity.add(0.0, -0.034, 0.0);
velocity = new Vec3(
velocity.x * 0.985,
velocity.y * 0.965,
velocity.z * 0.985
);
pos = pos.add(velocity);
if (mc.level != null) {
BlockPos ground = BlockPos.containing(pos.x, pos.y - 0.035, pos.z);
if (!mc.level.getBlockState(ground).isAir() && velocity.y < 0.0) {
pos = new Vec3(pos.x, ground.getY() + 1.035, pos.z);
if (Math.abs(velocity.y) > 0.045) {
velocity = new Vec3(
velocity.x * 0.72,
-velocity.y * 0.34,
velocity.z * 0.72
);
pitchVelocity *= 0.72f;
rollVelocity *= 0.72f;
} else {
velocity = new Vec3(
velocity.x * 0.62,
0.0,
velocity.z * 0.62
);
pitchVelocity *= 0.58f;
rollVelocity *= 0.58f;
}
}
}
yaw += yawVelocity;
pitch += pitchVelocity;
roll += rollVelocity;
yawVelocity *= 0.985f;
pitchVelocity *= 0.975f;
rollVelocity *= 0.975f;
return false;
}
}
}

View file

@ -39,6 +39,11 @@ import dev.loki.lovisual.features.module.modules.visuals.lyrics.LyricParticle3D;
)
public final class KineticLyrics extends Module {
/** Прыжок вперёд больше этого — seek: переиндексировать очередь, не спавнить пропущенные строки залпом. */
private static final long RESYNC_FORWARD_MS = 1500L;
/** Регрессия больше этого — пауза/откат: строки после индекса уже не догнать, нужен resync. */
private static final long RESYNC_BACKWARD_MS = 750L;
private final EnumValue<AnimationMode> animation =
enumMode("animation", AnimationMode.LYRIC_FLOW, AnimationMode.values());
private final EnumValue<ColorMode> colorMode =
@ -135,9 +140,13 @@ public final class KineticLyrics extends Module {
// нескольких секунд через Bluetooth), а LRC-таймкоды привязаны к
// слышимому звуку. Дефолтный сдвиг назад компенсирует это (решение
// совпадает с lrcsnc: у автора ~0.75с задержки для mpd).
long audioTimeMs = Math.max(0L, snapshot.predictedPositionSeconds() * 1000L + timeOffsetMs.get());
long audioTimeMs = Math.max(0L, snapshot.predictedPositionMs() + timeOffsetMs.get());
if (lastAudioTimeMs >= 0 && Math.abs(audioTimeMs - lastAudioTimeMs) > 1500L) {
// Любая ощутимая регрессия (pause через truncate-в-секунды MPRIS, короткий seek) —
// иначе индекс остаётся впереди звука и строки молча скипаются до догонки.
if (lastAudioTimeMs >= 0 && audioTimeMs < lastAudioTimeMs - RESYNC_BACKWARD_MS) {
resyncQueue(audioTimeMs);
} else if (lastAudioTimeMs >= 0 && audioTimeMs - lastAudioTimeMs > RESYNC_FORWARD_MS) {
resyncQueue(audioTimeMs);
}
lastAudioTimeMs = audioTimeMs;
@ -157,17 +166,19 @@ public final class KineticLyrics extends Module {
lastAudioTimeMs = -1L;
isPlaying = false;
LrcLibClient.fetchLyricsAsync(title, artist, debugMode.get()).thenAccept(lines -> {
if (!this.lastTrackKey.equalsIgnoreCase(key)) return;
LrcLibClient.fetchLyricsAsync(title, artist, debugMode.get()).thenAccept(lines ->
Minecraft.getInstance().execute(() -> {
if (!this.lastTrackKey.equalsIgnoreCase(key)) return;
activeParticles.clear();
rawLyrics.clear();
lyricsQueue.clear();
rawLyrics.addAll(lines);
rebuildLyricsQueue();
currentLyricIndex = 0;
isPlaying = !lyricsQueue.isEmpty();
});
activeParticles.clear();
rawLyrics.clear();
lyricsQueue.clear();
rawLyrics.addAll(lines);
rebuildLyricsQueue();
currentLyricIndex = 0;
isPlaying = !lyricsQueue.isEmpty();
})
);
}
private void rebuildLyricsQueue() {
@ -226,8 +237,29 @@ public final class KineticLyrics extends Module {
activeParticles.removeIf(particle -> particle.isDead(now));
}
/**
* arcSpread is a user-facing "how wide" preference, but it must never exceed what the
* player's actual camera FOV can show, or particles spawn off-screen. Clamp it to the
* real horizontal FOV (derived from vertical FOV + window aspect), with a safety margin
* so text doesn't pop in right at the frustum edge.
*/
private double resolveSpawnSpreadDeg() {
double configured = arcSpread.get();
Minecraft mc = Minecraft.getInstance();
if (mc == null || mc.gameRenderer == null || mc.gameRenderer.mainCamera() == null || mc.getWindow() == null) {
return configured;
}
float verticalFovDeg = mc.gameRenderer.mainCamera().getFov();
double aspect = (double) mc.getWindow().getWidth() / Math.max(1, mc.getWindow().getHeight());
double halfVerticalRad = Math.toRadians(verticalFovDeg) / 2.0;
double halfHorizontalDeg = Math.toDegrees(Math.atan(Math.tan(halfVerticalRad) * aspect));
return Math.min(configured, halfHorizontalDeg * 0.85);
}
private Vec3 findNonOverlappingSpawnPos(Vec3 headPos, float cameraYaw) {
double spread = arcSpread.get();
double spread = resolveSpawnSpreadDeg();
double minD = minDistance.get();
double maxD = Math.max(minD + 0.5, maxDistance.get());
@ -376,6 +408,7 @@ public final class KineticLyrics extends Module {
TYPEWRITER("typewriter"),
POP_SCALE("pop_scale"),
KINETIC_SLIDE("kinetic_slide"),
LETTER_FALL("letter_fall"),
FADE("fade");
private final String id;

View file

@ -9,6 +9,10 @@ import net.minecraft.world.phys.Vec3;
public class LyricParticle3D {
private static final double BASE_WORLD_SCALE = 0.0016;
private static final float LETTER_FALL_EXIT_START = 0.62f;
private static final float LETTER_FALL_STAGGER = 0.55f;
private static final float LETTER_FALL_DURATION = 0.55f;
private static final double LETTER_FALL_DISTANCE = 0.16;
private final String text;
private final Vec3 basePosition;
@ -91,6 +95,15 @@ public class LyricParticle3D {
animScale = 1.0f - 0.04f * t;
}
}
case "LETTER_FALL" -> {
if (progress < 0.15f) {
float t = progress / 0.15f;
alpha = easeOutCubic(t);
offsetY = -0.06 * (1.0f - easeOutCubic(t));
animScale = 0.93f + 0.07f * easeOutBack(t);
}
// exit is handled per-letter below; keep the whole-line alpha/offset at rest here
}
case "FADE" -> {
if (progress < 0.15f) {
alpha = progress / 0.15f;
@ -116,6 +129,11 @@ public class LyricParticle3D {
alpha = Mth.clamp(alpha, 0.0f, 1.0f);
if (alpha <= 0.001f || displayText == null || displayText.isBlank()) return;
if ("LETTER_FALL".equals(animMode) && progress >= LETTER_FALL_EXIT_START) {
renderLetterFall(renderer, textRenderer, displayText, colorRgb, alpha, offsetY, animScale, userScale, progress);
return;
}
int alphaInt = (int) (alpha * 255.0f);
RenderColor color = new RenderColor((alphaInt << 24) | (colorRgb & 0x00FFFFFF));
@ -131,6 +149,74 @@ public class LyricParticle3D {
WorldTextRenderer.drawBillboard(renderer, textRenderer, displayText, anchor, options);
}
/**
* Instead of fading the whole line out at once, each letter peels off and tumbles down on
* its own staggered timer (index-based delay), like it's losing grip and falling one by one.
*/
private void renderLetterFall(Renderer3D renderer,
TextRenderer textRenderer,
String text,
int colorRgb,
float baseAlpha,
double baseOffsetY,
float baseAnimScale,
float userScale,
float progress) {
int length = text.length();
if (length == 0) return;
double worldScale = BASE_WORLD_SCALE * userScale * baseAnimScale;
WorldTextRenderer.Options measureOptions = WorldTextRenderer.Options.defaults().withWorldScale(worldScale);
double totalWidth = WorldTextRenderer.measureWidth(textRenderer, text, measureOptions);
double cursorX = -totalWidth * 0.5;
Vec3 lineAnchor = basePosition.add(0.0, baseOffsetY, 0.0);
int alphaBase = Math.round(Mth.clamp(baseAlpha, 0.0f, 1.0f) * 255.0f);
float exitSpan = Math.max(0.001f, 1.0f - LETTER_FALL_EXIT_START);
for (int i = 0; i < length; i++) {
String letter = text.substring(i, i + 1);
double letterWidth = WorldTextRenderer.measureWidth(textRenderer, letter, measureOptions);
float letterStart = LETTER_FALL_EXIT_START + (i / (float) length) * LETTER_FALL_STAGGER * exitSpan;
float letterProgress = Mth.clamp((progress - letterStart) / LETTER_FALL_DURATION, 0.0f, 1.0f);
if (letterProgress > 0.0f) {
float letterAlpha = 1.0f - easeInCubic(letterProgress);
if (letterAlpha > 0.001f) {
double fallY = -LETTER_FALL_DISTANCE * (letterProgress * letterProgress);
double drift = 0.02 * Math.sin(i * 12.9898) * letterProgress;
int alphaInt = Math.round(letterAlpha * alphaBase);
RenderColor color = new RenderColor((alphaInt << 24) | (colorRgb & 0x00FFFFFF));
WorldTextRenderer.Options options = WorldTextRenderer.Options.defaults()
.withColor(color)
.withCentered(false)
.withShadow(true)
.withWorldScale(worldScale)
.withOffset(cursorX + drift, fallY)
.withDepthMode(Renderer3D.DepthMode.MAIN);
WorldTextRenderer.drawBillboard(renderer, textRenderer, letter, lineAnchor, options);
}
} else {
RenderColor color = new RenderColor((alphaBase << 24) | (colorRgb & 0x00FFFFFF));
WorldTextRenderer.Options options = WorldTextRenderer.Options.defaults()
.withColor(color)
.withCentered(false)
.withShadow(true)
.withWorldScale(worldScale)
.withOffset(cursorX, 0.0)
.withDepthMode(Renderer3D.DepthMode.MAIN);
WorldTextRenderer.drawBillboard(renderer, textRenderer, letter, lineAnchor, options);
}
cursorX += letterWidth;
}
}
private static float easeOutCubic(float t) {
return 1.0f - (float) Math.pow(1.0f - t, 3);
}

View file

@ -0,0 +1,70 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals.optimization;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.events.impl.render.ParticleSpawnEvent;
import dev.loki.lovisual.events.meta.EventHandler;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.lifecycle.Modules;
@ModuleInfo(
id = "fpsoptimizer",
displayName = "FPS Optimizer",
category = ModuleCategory.VISUALS
)
public final class FpsOptimizer extends Module {
private final BooleanValue disableParticles =
bool("disable_particles", false);
private final BooleanValue disableMenuBackground =
bool("disable_menu_background", false);
private final BooleanValue disableGlass =
bool("disable_glass", false);
private final BooleanValue disableBloom =
bool("disable_bloom", false);
private final BooleanValue entityCulling =
bool("entity_culling", false);
private final NumberValue<Integer> entityCullDistance =
visibleWhen(num("entity_cull_distance", 64, 8, 256), entityCulling::get);
public boolean isMenuBackgroundDisabled() {
return isEnabled() && disableMenuBackground.get();
}
public boolean isGlassDisabled() {
return isEnabled() && disableGlass.get();
}
public boolean isBloomDisabled() {
return isEnabled() && disableBloom.get();
}
public boolean isEntityCullingEnabled() {
return isEnabled() && entityCulling.get();
}
public int entityCullDistance() {
return entityCullDistance.get();
}
/** Cheap call-site guard for renderers that build bloom/glow geometry every frame. */
public static boolean bloomDisabled() {
FpsOptimizer module = Modules.get(FpsOptimizer.class);
return module != null && module.isBloomDisabled();
}
@EventHandler
private void onParticleSpawn(ParticleSpawnEvent event) {
if (!isEnabled() || !disableParticles.get()) return;
event.cancel();
}
}

View file

@ -1,6 +1,7 @@
package dev.loki.lovisual.features.module.modules.visuals.weather;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.features.module.core.Module;
@ -8,6 +9,7 @@ import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.render.engine.postprocess.render.SkyShaderRenderer;
import dev.loki.lovisual.render.engine.postprocess.render.SkyShaderStarryRenderer;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import net.minecraft.client.Minecraft;
@ -27,12 +29,18 @@ public class ShaderSky extends Module {
private static final String SETTING_NEBULA_COLOR1 = "nebula_color1";
private static final String SETTING_NEBULA_COLOR2 = "nebula_color2";
private static final String SETTING_NEBULA_STAR_COLOR = "nebula_star_color";
private static final String SETTING_STARRY_INTENSITY = "starry_intensity";
private static final String SETTING_STARRY_DENSITY = "starry_density";
private static final String SETTING_STARRY_MILKYWAY = "starry_milkyway";
private static final String SETTING_STARRY_MOON_SIZE = "starry_moon_size";
private static final String SETTING_STARRY_MOON_COLOR = "starry_moon_color";
private static final String SETTING_STARRY_STAR_COLOR = "starry_star_color";
private final ModeValue mode = modeSetting(
"shaderSkyMode",
SETTING_MODE,
"Thunder",
"Thunder", "Pulsar", "Sky", "Water", "Caustic", "Nebula"
"Thunder", "Pulsar", "Sky", "Water", "Caustic", "Nebula", "Starry"
);
private final NumberValue<Float> nebulaIntensity =
visibleWhen(num("shaderSkyNebulaIntensity", SETTING_NEBULA_INTENSITY, 1.2f, 0.2f, 3.0f), this::isNebulaMode);
@ -56,6 +64,20 @@ public class ShaderSky extends Module {
visibleWhen(num("shaderSkyThunderChance", SETTING_THUNDER_CHANCE, 0.65f, 0.0f, 1.0f), this::isThunderMode);
private final NumberValue<Float> thunderGlow =
visibleWhen(num("shaderSkyThunderGlow", SETTING_THUNDER_GLOW, 1.0f, 0.1f, 3.0f), this::isThunderMode);
private final NumberValue<Float> starryIntensity =
visibleWhen(num("shaderSkyStarryIntensity", SETTING_STARRY_INTENSITY, 1.0f, 0.1f, 5.0f), this::isStarryMode);
private final NumberValue<Float> starryDensity =
visibleWhen(num("shaderSkyStarryDensity", SETTING_STARRY_DENSITY, 64.0f, 8.0f, 256.0f), this::isStarryMode);
private final NumberValue<Float> starryMilkyWay =
visibleWhen(num("shaderSkyStarryMilkyWay", SETTING_STARRY_MILKYWAY, 0.6f, 0.0f, 1.5f), this::isStarryMode);
private final NumberValue<Float> starryMoonSize =
visibleWhen(num("shaderSkyStarryMoonSize", SETTING_STARRY_MOON_SIZE, 0.055f, 0.01f, 0.15f), this::isStarryMode);
private final BooleanValue starryMoon =
visibleWhen(bool("shaderSkyStarryMoon", "starry_moon", true), this::isStarryMode);
private final RGBAColorValue starryMoonColor =
visibleWhen(color("shaderSkyStarryMoonColor", SETTING_STARRY_MOON_COLOR, "#FFF5F0DC"), this::isStarryMode);
private final RGBAColorValue starryStarColor =
visibleWhen(color("shaderSkyStarryStarColor", SETTING_STARRY_STAR_COLOR, "#FFFFFFFF"), this::isStarryMode);
public String getMode() {
return mode.get();
@ -102,6 +124,10 @@ public class ShaderSky extends Module {
return "Nebula".equals(mode.get());
}
private boolean isStarryMode() {
return "Starry".equals(mode.get());
}
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.AFTER_POST_PROCESS;
@ -110,6 +136,7 @@ public class ShaderSky extends Module {
@Override
public void onEnable() {
SkyShaderRenderer.reset();
SkyShaderStarryRenderer.reset();
}
@Override
@ -142,6 +169,19 @@ public class ShaderSky extends Module {
nebulaColor2.getArgb(),
nebulaStarColor.getArgb()
);
} else if (isStarryMode()) {
SkyShaderStarryRenderer.render(
mc,
speed.get(),
starryIntensity.get(),
alpha.get(),
starryDensity.get(),
starryMilkyWay.get(),
starryMoonSize.get(),
starryMoon.get(),
starryMoonColor.getArgb(),
starryStarColor.getArgb()
);
}
}
}

View file

@ -0,0 +1,133 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals.wings;
/**
* Static wing geometry for the Wings module: 8 silhouette shapes (fan of triangles from
* the wing root, each point carries an edge-alpha multiplier) and the pose presets
* (standing / sneaking / elytra-gliding). Ported from the WiNGS reference.
*/
public final class WingShapes {
private WingShapes() {
}
/** Silhouette vertex: local x (outwards), y (up/down), alphaMul — edge fade of the fan triangle. */
public record Point(float x, float y, float alphaMul) {
}
/** Pose-dependent anchoring, spread and flap parameters (all in wing-local units). */
public record Pose(float preTranslateY, float preTranslateZ,
float anchorY, float anchorZ,
float pitchRotation, float rollRotation,
float openMultiplier, float scaleMultiplier,
float motionSpreadBoost, float flapAmplitude,
float sideOffset, float sideYOffset, float sideZOffset,
float sideRoll, float sidePitch, float flapSpeed) {
}
public static final Pose POSE_IDLE =
new Pose(0f, 0f, 1.38f, 0.10f, 0f, 0f, 1f, 1f, 0.18f, 4.5f, 0.06f, 0f, 0.02f, -11f, -4f, 0.12f);
public static final Pose POSE_SNEAK =
new Pose(0f, 0f, 0.96f, 0.10f, 18f, 0f, 1f, 1f, 0.18f, 4.5f, 0.06f, 0f, 0.02f, -11f, -4f, 0.12f);
/** Gliding pose: swept back with progress-scaled pitch toward the flight direction. */
public static Pose glidePose(float pitch, float flightProgress) {
return new Pose(0.34f, 0.46f, 0f, 0f, flightProgress * (-90f - pitch), 0f,
0.76f, 0.92f, 0.10f, 0.58f, 0.05f, 0f, 0.06f, -5f, -2f, 0.13f);
}
public static final String[] TYPES = {
"Angelic", "Dragon", "Butterfly", "Phoenix", "Crystal", "Mechanical", "Fairy", "Demon"
};
public static Point[] shape(String type) {
return switch (type) {
case "Dragon" -> DRAGON;
case "Butterfly" -> BUTTERFLY;
case "Phoenix" -> PHOENIX;
case "Crystal" -> CRYSTAL;
case "Mechanical" -> MECHANICAL;
case "Fairy" -> FAIRY;
case "Demon" -> DEMON;
default -> ANGELIC;
};
}
public static int[] ribs(Point[] shape) {
int[] template = shape.length <= 10 ? new int[]{2, 4, 6, 8} : new int[]{2, 4, 7, 9, 11};
int count = 0;
for (int idx : template) {
if (idx < shape.length) count++;
}
int[] out = new int[count];
int i = 0;
for (int idx : template) {
if (idx < shape.length) out[i++] = idx;
}
return out;
}
private static final Point[] ANGELIC = {
new Point(0.08f, 0.10f, 0.88f), new Point(0.28f, 0.34f, 0.78f), new Point(0.56f, 0.82f, 0.62f),
new Point(0.86f, 0.30f, 0.52f), new Point(1.14f, 0.46f, 0.40f), new Point(1.24f, 0.04f, 0.30f),
new Point(1.02f, -0.18f, 0.28f), new Point(1.18f, -0.64f, 0.22f), new Point(0.86f, -0.46f, 0.20f),
new Point(0.80f, -0.98f, 0.14f), new Point(0.54f, -0.74f, 0.16f), new Point(0.30f, -1.16f, 0.12f),
new Point(0.10f, -0.54f, 0.18f)
};
private static final Point[] DRAGON = {
new Point(0.10f, 0.12f, 0.90f), new Point(0.22f, 0.40f, 0.80f), new Point(0.48f, 0.72f, 0.65f),
new Point(0.80f, 0.60f, 0.55f), new Point(1.10f, 0.70f, 0.42f), new Point(1.30f, 0.30f, 0.35f),
new Point(1.20f, -0.10f, 0.30f), new Point(1.05f, -0.50f, 0.25f), new Point(0.70f, -0.35f, 0.22f),
new Point(0.50f, -0.70f, 0.18f), new Point(0.20f, -0.50f, 0.15f), new Point(0.05f, -0.30f, 0.20f)
};
private static final Point[] BUTTERFLY = {
new Point(0.12f, 0.15f, 0.92f), new Point(0.30f, 0.50f, 0.85f), new Point(0.50f, 0.90f, 0.70f),
new Point(0.70f, 0.80f, 0.60f), new Point(0.85f, 0.55f, 0.50f), new Point(0.75f, 0.20f, 0.45f),
new Point(0.55f, -0.15f, 0.40f), new Point(0.40f, -0.60f, 0.30f), new Point(0.25f, -0.85f, 0.20f),
new Point(0.12f, -0.65f, 0.25f), new Point(0.06f, -0.35f, 0.30f)
};
private static final Point[] PHOENIX = {
new Point(0.10f, 0.14f, 0.90f), new Point(0.25f, 0.45f, 0.82f), new Point(0.52f, 0.78f, 0.68f),
new Point(0.82f, 0.50f, 0.55f), new Point(1.15f, 0.55f, 0.42f), new Point(1.28f, 0.15f, 0.32f),
new Point(1.20f, -0.25f, 0.28f), new Point(1.10f, -0.55f, 0.24f), new Point(1.25f, -0.85f, 0.18f),
new Point(0.90f, -0.65f, 0.16f), new Point(0.60f, -0.90f, 0.14f), new Point(0.30f, -0.70f, 0.12f),
new Point(0.08f, -0.40f, 0.16f)
};
private static final Point[] CRYSTAL = {
new Point(0.15f, 0.10f, 0.85f), new Point(0.40f, 0.35f, 0.75f), new Point(0.70f, 0.60f, 0.60f),
new Point(1.00f, 0.40f, 0.50f), new Point(0.85f, 0.10f, 0.45f), new Point(1.10f, -0.15f, 0.35f),
new Point(0.90f, -0.45f, 0.30f), new Point(0.65f, -0.30f, 0.25f), new Point(0.45f, -0.60f, 0.20f),
new Point(0.20f, -0.40f, 0.22f), new Point(0.08f, -0.15f, 0.30f)
};
private static final Point[] MECHANICAL = {
new Point(0.08f, 0.08f, 0.90f), new Point(0.20f, 0.25f, 0.82f), new Point(0.45f, 0.40f, 0.70f),
new Point(0.70f, 0.35f, 0.58f), new Point(0.95f, 0.25f, 0.48f), new Point(0.90f, 0.00f, 0.40f),
new Point(1.10f, -0.15f, 0.32f), new Point(0.80f, -0.30f, 0.28f), new Point(0.55f, -0.20f, 0.25f),
new Point(0.40f, -0.45f, 0.20f), new Point(0.15f, -0.30f, 0.22f), new Point(0.05f, -0.10f, 0.28f)
};
private static final Point[] FAIRY = {
new Point(0.10f, 0.12f, 0.90f), new Point(0.25f, 0.38f, 0.82f), new Point(0.42f, 0.65f, 0.68f),
new Point(0.55f, 0.70f, 0.58f), new Point(0.60f, 0.45f, 0.50f), new Point(0.50f, 0.15f, 0.42f),
new Point(0.38f, -0.10f, 0.35f), new Point(0.30f, -0.35f, 0.28f), new Point(0.18f, -0.45f, 0.22f),
new Point(0.08f, -0.25f, 0.26f)
};
private static final Point[] DEMON = {
new Point(0.10f, 0.12f, 0.88f), new Point(0.25f, 0.38f, 0.80f), new Point(0.55f, 0.65f, 0.65f),
new Point(0.85f, 0.50f, 0.52f), new Point(1.15f, 0.55f, 0.40f), new Point(1.25f, 0.20f, 0.32f),
new Point(1.10f, -0.10f, 0.28f), new Point(1.30f, -0.45f, 0.22f), new Point(1.15f, -0.70f, 0.18f),
new Point(0.85f, -0.55f, 0.16f), new Point(0.55f, -0.85f, 0.14f), new Point(0.25f, -0.65f, 0.12f),
new Point(0.08f, -0.35f, 0.18f)
};
}

View file

@ -0,0 +1,133 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals.wings;
import dev.loki.lovisual.config.values.color.RGBAColorValue;
import dev.loki.lovisual.config.values.mode.ModeValue;
import dev.loki.lovisual.config.values.primitive.BooleanValue;
import dev.loki.lovisual.config.values.primitive.NumberValue;
import dev.loki.lovisual.features.module.core.Module;
import dev.loki.lovisual.features.module.core.ModuleCategory;
import dev.loki.lovisual.features.module.core.ModuleInfo;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.features.relations.PlayerRelations;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import net.minecraft.client.Minecraft;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.Items;
/**
* Cosmetic wings on player backs (ported from the WiNGS reference): 8 silhouette types,
* pose-driven flapping (idle/sneak/glide), friends/all-players visibility. Geometry data
* lives in {@link WingShapes}, mesh building in {@link WingsRenderer}.
*/
@ModuleInfo(
id = "wings",
displayName = "Wings",
category = ModuleCategory.VISUALS,
description = "Крылья на спине игрока: 8 типов формы, анимация взмахов"
)
public final class Wings extends Module {
private final ModeValue wingType = modeSetting(
"wingsType", "type", "Angelic", WingShapes.TYPES);
private final ModeValue target = modeSetting(
"wingsTarget", "target", "Self", "Self", "Friends", "All Players");
private final NumberValue<Float> scale =
num("wingsScale", "scale", 1.0f, 0.3f, 3.0f);
private final BooleanValue flapping =
bool("wingsFlapping", "flapping", true);
private final NumberValue<Float> flapStrength =
visibleWhen(num("wingsFlapStrength", "flap_strength", 30.0f, 5.0f, 60.0f), flapping::get);
private final NumberValue<Float> flapSpeed =
visibleWhen(num("wingsFlapSpeed", "flap_speed", 3.0f, 0.5f, 8.0f), flapping::get);
private final BooleanValue throughWalls =
bool("wingsThroughWalls", "through_walls", false);
private final BooleanValue glow = bool("wingsGlow", "glow", true);
private final BooleanValue outline = bool("wingsOutline", "outline", true);
private final BooleanValue ribs = bool("wingsRibs", "ribs", true);
private final RGBAColorValue color =
color("wingsColor", "color", "#FFFFFFDC");
private float selfYaw;
private boolean selfYawReady;
@Override
public WorldPhase getWorldPhase() {
return WorldPhase.END_MAIN;
}
@Override
public void onDisable() {
selfYawReady = false;
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
if (!isEnabled() || mc.level == null || mc.player == null || renderer == null) return;
WingsRenderer.Style style = new WingsRenderer.Style(
wingType.get(), scale.get(), flapping.get(), flapStrength.get(), flapSpeed.get(),
color.getArgb(), glow.get(), outline.get(), ribs.get(), !throughWalls.get());
if (showsSelf() && !mc.options.getCameraType().isFirstPerson()
&& mc.player.isAlive() && !hasElytra(mc.player)) {
renderOne(renderer, mc.player, tickDelta, style, true);
}
if (showsOthers()) {
for (Player player : mc.level.players()) {
if (player == mc.player || !player.isAlive() || hasElytra(player)) continue;
if (!showsOthers(player)) continue;
renderOne(renderer, player, tickDelta, style, false);
}
}
}
private void renderOne(Renderer3D renderer, Player player, float tickDelta,
WingsRenderer.Style style, boolean isSelf) {
float targetYaw = Mth.rotLerp(tickDelta, player.yBodyRotO, player.yBodyRot);
float bodyYaw;
if (isSelf) {
if (!selfYawReady || player.tickCount < 2) {
selfYaw = targetYaw;
selfYawReady = true;
} else {
selfYaw += Mth.clamp(Mth.wrapDegrees(targetYaw - selfYaw), -14f, 14f);
}
bodyYaw = selfYaw;
} else {
bodyYaw = targetYaw;
}
WingsRenderer.render(renderer, player, tickDelta, bodyYaw, style);
}
private boolean showsSelf() {
String t = target.get();
return "Self".equals(t) || "All Players".equals(t);
}
private boolean showsOthers() {
return "Friends".equals(target.get()) || "All Players".equals(target.get());
}
private boolean showsOthers(Player player) {
String t = target.get();
if ("All Players".equals(t)) return true;
if ("Friends".equals(t)) {
return PlayerRelations.get().isFriend(player.getGameProfile().name());
}
return false;
}
private static boolean hasElytra(Player player) {
return player.getItemBySlot(EquipmentSlot.CHEST).is(Items.ELYTRA);
}
}

View file

@ -0,0 +1,197 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals.wings;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
import net.minecraft.util.Mth;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
/**
* Draws a pair of wings on a player's back: anchor transform (position/body yaw/pose,
* mirroring the reference PoseStack chain via joml matrices) then the fan of glow/core/
* fill triangles + outline + ribs per side. Ported from the WiNGS reference onto
* Renderer3D batches (world-space vertices, pipelines supply depth behavior).
*/
public final class WingsRenderer {
private static final float DEFAULT_SPREAD = 8.0f;
private static final int DEFAULT_ALPHA = 220;
private WingsRenderer() {
}
/** Per-frame style knobs resolved by the module. */
public record Style(String type, float scale, boolean flapping, float flapStrength, float flapSpeed,
int baseColorArgb, boolean glow, boolean outline, boolean ribs, boolean depthTest) {
}
public static void render(Renderer3D renderer, Player player, float tickDelta, float bodyYaw, Style s) {
if (renderer == null || player == null) return;
WingShapes.Pose pose = resolvePose(player, tickDelta);
if (pose == null) return;
WingShapes.Point[] shape = WingShapes.shape(s.type());
int[] ribIndices = WingShapes.ribs(shape);
Vec3 pos = player.getPosition(tickDelta);
float move = Mth.clamp((float) player.getDeltaMovement().horizontalDistance() * 10f, 0f, 1f);
float flap = 0f;
if (s.flapping()) {
float effectiveSpeed = pose.flapSpeed() * (s.flapSpeed() / 3.0f);
float effectiveAmp = pose.flapAmplitude() * (s.flapStrength() / 30.0f);
flap = Mth.sin((player.tickCount + tickDelta) * effectiveSpeed) * effectiveAmp;
}
float open = (DEFAULT_SPREAD + flap + move * pose.motionSpreadBoost()) * pose.openMultiplier();
float ws = s.scale() * pose.scaleMultiplier();
int glowColor = interpolate(s.baseColorArgb(), 0xFFFFFF, 0.28f);
int coreColor = interpolate(s.baseColorArgb(), 0xFFFFFF, 0.55f);
Meshes meshes = new Meshes(renderer, s.depthTest());
for (float side = -1f; side <= 1f; side += 2f) {
drawWingSide(meshes, pos, bodyYaw, pose, shape, ribIndices, side, open, ws, s, glowColor, coreColor);
}
}
private static final class Meshes {
final MeshBuilder glow;
final MeshBuilder fill;
final MeshBuilder outline;
final Renderer3D.DepthMode depthMode;
Meshes(Renderer3D renderer, boolean depthTest) {
this.depthMode = depthTest ? Renderer3D.DepthMode.MAIN : Renderer3D.DepthMode.NONE;
this.glow = renderer.batch(depthTest
? LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE_DEPTH
: LoVisualRenderPipelines.WORLD_COLORED_ADDITIVE, depthMode);
this.fill = renderer.batch(depthTest
? LoVisualRenderPipelines.WORLD_COLORED_DEPTH
: LoVisualRenderPipelines.WORLD_COLORED, depthMode);
this.outline = renderer.batch(depthTest
? LoVisualRenderPipelines.WORLD_COLORED_LINES_DEPTH
: LoVisualRenderPipelines.WORLD_COLORED_LINES, depthMode);
}
}
/**
* Builds the matrix chain of the reference: translate(player) · Y(180-yaw) · preTranslate ·
* X(pitch) · Z(roll) · translate(anchor) · scale · [per side] translate(side) · Y(side·open) ·
* Z(side·sideRoll) · X(sidePitch). Wing-local verts are (side·x, y, 0).
*/
private static void drawWingSide(Meshes m, Vec3 pos, float bodyYaw, WingShapes.Pose pose,
WingShapes.Point[] shape, int[] ribIndices, float side,
float open, float ws, Style s, int glowColor, int coreColor) {
Matrix4f mat = new Matrix4f()
.translate((float) pos.x, (float) pos.y, (float) pos.z)
.rotateY((float) Math.toRadians(180f - bodyYaw))
.translate(0f, pose.preTranslateY(), pose.preTranslateZ())
.rotateX((float) Math.toRadians(pose.pitchRotation()))
.rotateZ((float) Math.toRadians(pose.rollRotation()))
.translate(0f, pose.anchorY(), pose.anchorZ())
.scale(ws)
.translate(side * pose.sideOffset(), pose.sideYOffset(), pose.sideZOffset())
.rotateY((float) Math.toRadians(side * open))
.rotateZ((float) Math.toRadians(side * pose.sideRoll()))
.rotateX((float) Math.toRadians(pose.sidePitch()));
int baseAlpha = DEFAULT_ALPHA;
int rootGlow = setAlpha(glowColor, (int) (baseAlpha * 0.22f));
int rootCore = setAlpha(coreColor, (int) (baseAlpha * 0.26f));
int rootFill = setAlpha(s.baseColorArgb(), baseAlpha);
int edgeFill = setAlpha(s.baseColorArgb(), 10);
int rootRib = setAlpha(glowColor, Math.max(8, (int) (alphaOf(glowColor) * 0.75f)));
fanLayer(m.glow, mat, shape, 1.22f, side, rootGlow, setAlpha(glowColor, 0));
fanLayer(m.glow, mat, shape, 0.84f, side, rootCore, setAlpha(coreColor, 0));
fanLayer(m.fill, mat, shape, 1.0f, side, rootFill, edgeFill);
if (s.outline()) outline(m.outline, mat, shape, side, 1.0f, setAlpha(s.baseColorArgb(), (int) (baseAlpha * 0.62f)));
if (s.ribs()) ribs(m.glow, mat, shape, ribIndices, side, rootRib);
}
private static void fanLayer(MeshBuilder mesh, Matrix4f mat, WingShapes.Point[] shape,
float layerScale, float side, int rootColor, int edgeColor) {
if (mesh == null) return;
int rootIdx = put(mesh, mat, 0f, 0f, 0f, rootColor);
for (int i = 0; i < shape.length; i++) {
WingShapes.Point cur = shape[i];
WingShapes.Point next = shape[(i + 1) % shape.length];
int curAlpha = setAlpha(edgeColor, Math.round(alphaOf(edgeColor) * cur.alphaMul()));
int nextAlpha = setAlpha(edgeColor, Math.round(alphaOf(edgeColor) * next.alphaMul()));
int i1 = put(mesh, mat, side * cur.x() * layerScale, cur.y() * layerScale, 0f, curAlpha);
int i2 = put(mesh, mat, side * next.x() * layerScale, next.y() * layerScale, 0f, nextAlpha);
mesh.triangle(rootIdx, i1, i2);
}
}
private static void outline(MeshBuilder mesh, Matrix4f mat, WingShapes.Point[] shape,
float side, float scale, int color) {
if (mesh == null) return;
int first = put(mesh, mat, side * shape[0].x() * scale, shape[0].y() * scale, 0f, color);
int prev = first;
for (int i = 1; i < shape.length; i++) {
WingShapes.Point p = shape[i];
int idx = put(mesh, mat, side * p.x() * scale, p.y() * scale, 0f, color);
mesh.line(prev, idx);
prev = idx;
}
mesh.line(prev, first);
}
private static void ribs(MeshBuilder mesh, Matrix4f mat, WingShapes.Point[] shape, int[] ribIndices,
float side, int color) {
if (mesh == null) return;
int rootIdx = put(mesh, mat, 0f, 0f, 0f, color);
for (int idx : ribIndices) {
if (idx >= shape.length) continue;
WingShapes.Point p = shape[idx];
int tip = put(mesh, mat, side * p.x() * 0.96f, p.y() * 0.96f, 0f,
setAlpha(color, Math.max(0, Math.round(alphaOf(color) * p.alphaMul()))));
mesh.line(rootIdx, tip);
}
}
private static int put(MeshBuilder mesh, Matrix4f mat, float x, float y, float z, int argb) {
float wx = mat.m00() * x + mat.m01() * y + mat.m02() * z + mat.m03();
float wy = mat.m10() * x + mat.m11() * y + mat.m12() * z + mat.m13();
float wz = mat.m20() * x + mat.m21() * y + mat.m22() * z + mat.m23();
mesh.vec3(wx, wy, wz).color((argb >>> 16) & 0xFF, (argb >>> 8) & 0xFF, argb & 0xFF, (argb >>> 24) & 0xFF);
return mesh.next();
}
private static WingShapes.Pose resolvePose(Player player, float tickDelta) {
if (player.isFallFlying()) {
float flightTicks = (float) player.getFallFlyingTicks() + tickDelta;
float progress = Mth.clamp(flightTicks * flightTicks / 100f, 0f, 1f);
float pitch = Mth.lerp(tickDelta, player.xRotO, player.getXRot());
return WingShapes.glidePose(pitch, progress);
}
if (player.isInWater()) return null;
if (player.isShiftKeyDown()) return WingShapes.POSE_SNEAK;
return WingShapes.POSE_IDLE;
}
static int setAlpha(int color, int a) {
return (Mth.clamp(a, 0, 255) << 24) | (color & 0x00FFFFFF);
}
static int alphaOf(int color) {
return (color >>> 24) & 0xFF;
}
/** Linear lerp of rgb toward a white-ish target rgb (alpha preserved from source). */
static int interpolate(int fromArgb, int targetRgb, float t) {
int r = Math.round(((fromArgb >>> 16) & 0xFF) + ((((targetRgb >>> 16) & 0xFF)) - ((fromArgb >>> 16) & 0xFF)) * t);
int g = Math.round(((fromArgb >>> 8) & 0xFF) + ((((targetRgb >>> 8) & 0xFF)) - ((fromArgb >>> 8) & 0xFF)) * t);
int b = Math.round((fromArgb & 0xFF) + ((targetRgb & 0xFF) - (fromArgb & 0xFF)) * t);
return setAlpha((r << 16) | (g << 8) | b, alphaOf(fromArgb));
}
}

View file

@ -62,15 +62,30 @@ public abstract class CommandEncoderMixin {
return depthAttachment == null ? null : depthAttachment.textureView();
}
@Unique
private static Boolean lovisual$vulkanBackendCached;
@Unique
private static boolean lovisual$isVulkanBackend() {
try {
if (RenderSystem.getDevice() == null || RenderSystem.getDevice().getDeviceInfo() == null) return false;
String backendName = RenderSystem.getDevice().getDeviceInfo().backendName();
return backendName != null && backendName.toLowerCase(java.util.Locale.ROOT).contains("vulkan");
} catch (Throwable ignored) {
return false;
// submitRenderPass runs every frame; device info is static per session — resolve once.
Boolean cached = lovisual$vulkanBackendCached;
if (cached != null) {
return cached;
}
boolean vulkan = false;
try {
if (RenderSystem.getDevice() != null && RenderSystem.getDevice().getDeviceInfo() != null) {
String backendName = RenderSystem.getDevice().getDeviceInfo().backendName();
vulkan = backendName != null && backendName.toLowerCase(java.util.Locale.ROOT).contains("vulkan");
}
} catch (Throwable ignored) {
vulkan = false;
}
// Only cache once the device exists; before init, retry next call.
if (RenderSystem.getDevice() != null) {
lovisual$vulkanBackendCached = vulkan;
}
return vulkan;
}
@Inject(method = "createRenderPass(Lcom/mojang/blaze3d/systems/RenderPassDescriptor;)Lcom/mojang/blaze3d/systems/RenderPass;", at = @At("RETURN"))

View file

@ -34,7 +34,11 @@ public abstract class IrisRenderingPipelineFinalizeMixin {
@Inject(method = "beginTranslucents", at = @At("HEAD"), remap = false)
private void lovisual$capturePreTranslucentDepth(CallbackInfo ci) {
PreTranslucentDepth.capture();
if (PreTranslucentDepth.takeRequested()) {
PreTranslucentDepth.capture();
} else {
PreTranslucentDepth.reset();
}
}
@Inject(method = "finalizeLevelRendering", at = @At("HEAD"), remap = false)

View file

@ -0,0 +1,42 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.mixins.render.entity;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.culling.Frustum;
import net.minecraft.client.renderer.entity.EntityRenderDispatcher;
import net.minecraft.world.entity.Entity;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
@Mixin(EntityRenderDispatcher.class)
public abstract class EntityRenderDispatcherMixin {
@Inject(method = "shouldRender(Lnet/minecraft/world/entity/Entity;Lnet/minecraft/client/renderer/culling/Frustum;DDD)Z", at = @At("HEAD"), cancellable = true)
private <E extends Entity> void lovisual$cullEntity(E entity, Frustum frustum, double camX, double camY, double camZ, CallbackInfoReturnable<Boolean> cir) {
FpsOptimizer fpsOptimizer = Modules.get(FpsOptimizer.class);
if (fpsOptimizer == null || !fpsOptimizer.isEntityCullingEnabled()) return;
Minecraft mc = Minecraft.getInstance();
if (entity == mc.player) return;
double maxDistance = fpsOptimizer.entityCullDistance();
if (entity.distanceToSqr(camX, camY, camZ) > maxDistance * maxDistance) {
cir.setReturnValue(false);
return;
}
if (!frustum.isVisible(entity.getBoundingBox())) {
cir.setReturnValue(false);
}
}
}

View file

@ -275,6 +275,10 @@ public abstract class LevelRendererMixin {
if (IrisRuntime.isShaderpackRendererActive()) {
return;
}
if (!PreTranslucentDepth.takeRequested()) {
PreTranslucentDepth.reset();
return;
}
RenderTarget mainFramebuffer = lovisual$getFramebuffer(lovisual$asRenderTargetHandle(mainFramebufferHandle));
if (mainFramebuffer == null) {
Minecraft mc = Minecraft.getInstance();

View file

@ -7,21 +7,30 @@
package dev.loki.lovisual.mixins.render.level;
import net.minecraft.client.particle.Particle;
import net.minecraft.client.particle.ParticleEngine;
import net.minecraft.core.particles.ParticleOptions;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
import dev.loki.lovisual.events.Events;
import dev.loki.lovisual.events.impl.render.ParticleSpawnEvent;
import dev.loki.lovisual.util.player.effect.heuristics.StatusEffectHeuristics;
@Mixin(ParticleEngine.class)
public class ParticleManagerMixin {
@Inject(method = "createParticle(Lnet/minecraft/core/particles/ParticleOptions;DDDDDD)Lnet/minecraft/client/particle/Particle;", at = @At("HEAD"))
private void lovisual$heuristicParticle(ParticleOptions parameters, double x, double y, double z, double velocityX, double velocityY, double velocityZ, CallbackInfoReturnable<?> cir) {
@Inject(method = "createParticle(Lnet/minecraft/core/particles/ParticleOptions;DDDDDD)Lnet/minecraft/client/particle/Particle;", at = @At("HEAD"), cancellable = true)
private void lovisual$heuristicParticle(ParticleOptions parameters, double x, double y, double z, double velocityX, double velocityY, double velocityZ, CallbackInfoReturnable<Particle> cir) {
// dev.loki.lovisual.util.logging.DebugLog.info("ParticleManager add type=%s pos=%.2f %.2f %.2f", parameters.getType().toString(), x, y, z);
StatusEffectHeuristics.handle(parameters, x, y, z);
ParticleSpawnEvent event = new ParticleSpawnEvent(parameters, x, y, z);
Events.BUS.post(event);
if (event.isCancelled()) {
cir.setReturnValue(null);
}
}
}

View file

@ -98,6 +98,20 @@ public abstract class GameRendererMixin implements IrisFinalizedSceneRenderer {
private final PoseStack lovisual$matrices = new PoseStack();
@Unique
private final PoseStack lovisual$postMatrices = new PoseStack();
// Per-frame scratch (GameRenderer is a singleton; consumers copy by value):
// avoids ~7 Matrix4f/PoseStack/Quaternionf allocations per frame in the hot render path.
@Unique
private final PoseStack lovisual$effectMatrices = new PoseStack();
@Unique
private final Matrix4f lovisual$screenProjectionScratch = new Matrix4f();
@Unique
private final Matrix4f lovisual$modelViewScratch = new Matrix4f();
@Unique
private final Matrix4f lovisual$inverseBobScratch = new Matrix4f();
@Unique
private final Matrix4f lovisual$cameraPositionScratch = new Matrix4f();
@Unique
private final org.joml.Quaternionf lovisual$conjugateScratch = new org.joml.Quaternionf();
@Shadow
@Final
private Minecraft minecraft;
@ -232,10 +246,10 @@ public abstract class GameRendererMixin implements IrisFinalizedSceneRenderer {
CameraRenderState cameraRenderState = gameRenderState.levelRenderState.cameraRenderState;
if (cameraRenderState == null || cameraRenderState.viewRotationMatrix == null || cameraRenderState.projectionMatrix == null) return;
PoseStack effectMatrices = new PoseStack();
Matrix4f renderProjection = lovisual$buildWorldProjection(fov, effectMatrices, tickDelta);
lovisual$effectMatrices.setIdentity();
Matrix4f renderProjection = lovisual$buildWorldProjection(fov, lovisual$effectMatrices, tickDelta);
AspectRatio.applyToProjection(renderProjection);
Matrix4f screenProjection = new Matrix4f(cameraRenderState.projectionMatrix);
Matrix4f screenProjection = lovisual$screenProjectionScratch.set(cameraRenderState.projectionMatrix);
LoVisualWorldMatrices.capture(
cameraRenderState.viewRotationMatrix,
renderProjection,
@ -263,7 +277,8 @@ public abstract class GameRendererMixin implements IrisFinalizedSceneRenderer {
boolean useCapturedMatrices = capturedPosition != null && capturedProjection != null;
Matrix4f position = useCapturedMatrices
? capturedPosition
: new Matrix4f().rotation(mainCamera.rotation().conjugate(new org.joml.Quaternionf()));
: lovisual$cameraPositionScratch.identity().rotation(
mainCamera.rotation().conjugate(lovisual$conjugateScratch));
RenderState.rendering3D = true;
RenderState.tickDelta = tickDelta;
Vec3 capturedCameraPos = LoVisualWorldMatrices.cameraPosition();
@ -343,7 +358,7 @@ public abstract class GameRendererMixin implements IrisFinalizedSceneRenderer {
tickCounter.getGameTimeDeltaTicks(),
tickCounter.getRealtimeDeltaTicks(),
projection,
new Matrix4f(mv)
lovisual$modelViewScratch.set(mv)
);
try (RenderPhaseScope lovisual$beforeTranslucentPhase = LoVisualRenderSystem.phase(RenderPhase.WORLD_BEFORE_TRANSLUCENT)) {
RenderState.worldProjection.set(projection);
@ -351,8 +366,7 @@ public abstract class GameRendererMixin implements IrisFinalizedSceneRenderer {
RenderState.frustum.prepare(RenderState.cameraPos.x, RenderState.cameraPos.y, RenderState.cameraPos.z);
if (!useCapturedMatrices) {
Matrix4f inverseBob = new Matrix4f(lovisual$matrices.last().pose()).invert();
mv.mul(inverseBob);
mv.mul(lovisual$inverseBobScratch.set(lovisual$matrices.last().pose()).invert());
}
lovisual$renderer.begin();
@ -572,7 +586,8 @@ public abstract class GameRendererMixin implements IrisFinalizedSceneRenderer {
boolean useCapturedMatrices = capturedPosition != null && capturedProjection != null;
Matrix4f position = useCapturedMatrices
? capturedPosition
: new Matrix4f().rotation(mainCamera.rotation().conjugate(new org.joml.Quaternionf()));
: lovisual$cameraPositionScratch.identity().rotation(
mainCamera.rotation().conjugate(lovisual$conjugateScratch));
RenderState.rendering3D = true;
RenderState.tickDelta = tickDelta;
@ -612,7 +627,7 @@ public abstract class GameRendererMixin implements IrisFinalizedSceneRenderer {
tickCounter.getGameTimeDeltaTicks(),
tickCounter.getRealtimeDeltaTicks(),
projection,
new Matrix4f(mv)
lovisual$modelViewScratch.set(mv)
);
try (RenderPhaseScope lovisual$postWorldPhase = LoVisualRenderSystem.phase(RenderPhase.WORLD_POST_PRE_HAND)) {
RenderState.worldProjection.set(projection);
@ -620,8 +635,7 @@ public abstract class GameRendererMixin implements IrisFinalizedSceneRenderer {
RenderState.frustum.prepare(RenderState.cameraPos.x, RenderState.cameraPos.y, RenderState.cameraPos.z);
if (!useCapturedMatrices) {
Matrix4f inverseBob = new Matrix4f(lovisual$postMatrices.last().pose()).invert();
mv.mul(inverseBob);
mv.mul(lovisual$inverseBobScratch.set(lovisual$postMatrices.last().pose()).invert());
}
lovisual$postRenderer.begin();

View file

@ -35,6 +35,26 @@ public enum PreTranslucentDepth {
private static int msaaDepthCopyH = -1;
private static boolean captured;
private static boolean capturedMsaa;
private static boolean requestedNextFrame;
/**
* The full-screen depth copy in {@link #captureFrom} is expensive, and its capture point
* (before the translucent groups in the main pass) runs BEFORE any PRE_DEPTH consumer renders
* in the frame. Consumers ({@link #getDepthView()}, {@link #getDepthViewFor(GpuTextureView)})
* therefore declare demand here; the next frame's capture consults it via {@link #takeRequested()}.
* One-frame latency is invisible: until the copy exists, getters fall back to the live
* framebuffer/WorldSceneDepth views.
*/
public static void requestForNextFrame() {
requestedNextFrame = true;
}
/** @return and clears the demand flag set by {@link #requestForNextFrame()}. */
public static boolean takeRequested() {
boolean requested = requestedNextFrame;
requestedNextFrame = false;
return requested;
}
public static void captureFrom(RenderTarget source) {
if (source == null) return;
@ -97,6 +117,7 @@ public enum PreTranslucentDepth {
}
public static GpuTextureView getDepthView() {
requestForNextFrame();
if (MsaaWorldTarget.isActive() && capturedMsaa && msaaDepthView != null) {
return msaaDepthView;
}
@ -115,6 +136,7 @@ public enum PreTranslucentDepth {
}
public static GpuTextureView getDepthViewFor(GpuTextureView colorAttachment) {
requestForNextFrame();
int requiredSamples = samples(colorAttachment);
if (requiredSamples > 1
&& capturedMsaa

View file

@ -152,6 +152,8 @@ public enum LoVisualRenderPipelines {
// SkyShader nebula
public static final Identifier SHADER_SKY_SHADER_NEBULA_FRAG = shader("sky_shader_nebula.frag");
public static final Identifier SHADER_SKY_SHADER_CAUSTIC_FRAG = shader("sky_shader_caustic.frag");
// SkyShader starry (procedural stars + milky way + round moon)
public static final Identifier SHADER_SKY_SHADER_STARRY_FRAG = shader("sky_shader_starry.frag");
private static final List<RenderPipeline> PIPELINES = new ArrayList<>();
@ -174,6 +176,8 @@ public enum LoVisualRenderPipelines {
public static final RenderPipeline WORLD_TEXTURED_DEPTH = add(WorldPipelines.textured("world_textured_depth", true, BlendFunction.TRANSLUCENT));
/** Depth test (GEQUAL); additive; colored triangles. */
public static final RenderPipeline WORLD_COLORED_ADDITIVE_DEPTH = add(WorldPipelines.colored("world_colored_additive_depth", true, PipelineTemplates.ADDITIVE));
/** No depth test; additive; colored triangles. */
public static final RenderPipeline WORLD_COLORED_ADDITIVE = add(WorldPipelines.colored("world_colored_additive", false, PipelineTemplates.ADDITIVE));
/** No depth test; translucent; world decal quads with SDF fill/stroke. */
public static final RenderPipeline WORLD_DECAL_SDF = add(WorldPipelines.decalSdf("world_decal_sdf", false));
/** Depth test (GEQUAL); translucent; world decal quads with SDF fill/stroke. */
@ -246,6 +250,7 @@ public enum LoVisualRenderPipelines {
public static final RenderPipeline SKY_SHADER_WATER = add(ScreenPipelines.screen("sky_shader_water", SHADER_SKY_SHADER_WATER_FRAG, "SkyShader"));
public static final RenderPipeline SKY_SHADER_CAUSTIC = add(ScreenPipelines.screen("sky_shader_caustic", SHADER_SKY_SHADER_CAUSTIC_FRAG, "SkyShader"));
public static final RenderPipeline SKY_SHADER_NEBULA = add(ScreenPipelines.screen("sky_shader_nebula", SHADER_SKY_SHADER_NEBULA_FRAG, "SkyShaderData"));
public static final RenderPipeline SKY_SHADER_STARRY = add(ScreenPipelines.screen("sky_shader_starry", SHADER_SKY_SHADER_STARRY_FRAG, "SkyShaderStarry"));
/** Fullscreen main menu background (pos2). */
public static final RenderPipeline MENU_BACKGROUND_AURORA = add(ScreenPipelines.screen("menu_background_aurora", SHADER_MENU_BACKGROUND_AURORA_FRAG, "MenuBackground"));
/** Fullscreen postprocess (pos2). */

View file

@ -60,7 +60,7 @@ public final class PostProcessGraph implements AutoCloseable {
LoVisualRhi rhi,
GraphCopy copy) {
if (!hasActivePass(phase, context)) return false;
try (RenderCostProfiler.Scope ignoredGraph = RenderCostProfiler.postPass("graph:" + phase)) {
try (RenderCostProfiler.Scope ignoredGraph = RenderCostProfiler.postPass("graph:", phase.toString())) {
if (!resources.prepare(phase, tickDelta)) return false;
GpuTextureView mainColor = resources.mainColor();

View file

@ -24,6 +24,8 @@ import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
import dev.loki.lovisual.render.engine.renderer.MeshRenderer;
import dev.loki.lovisual.render.engine.uniform.impl.ui.MenuBackgroundUniforms;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
public enum MenuBackgroundRenderer {
;
@ -41,6 +43,8 @@ public enum MenuBackgroundRenderer {
public static void render(Minecraft mc, boolean aurora) {
if (mc == null) return;
FpsOptimizer fpsOptimizer = Modules.get(FpsOptimizer.class);
if (fpsOptimizer != null && fpsOptimizer.isMenuBackgroundDisabled()) return;
if (Renderer2D.isDeferredExtractRecording()) {
deferredPending = true;
deferredAurora = aurora;

View file

@ -0,0 +1,138 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.render.engine.postprocess.render;
import com.mojang.blaze3d.ProjectionType;
import com.mojang.blaze3d.buffers.GpuBufferSlice;
import com.mojang.blaze3d.pipeline.RenderTarget;
import com.mojang.blaze3d.systems.RenderSystem;
import dev.loki.lovisual.render.engine.RenderState;
import dev.loki.lovisual.features.module.modules.visuals.weather.WorldTweaks;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.sky.CustomSkyboxRenderer;
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
import dev.loki.lovisual.render.engine.renderer.MeshRenderer;
import dev.loki.lovisual.render.engine.uniform.impl.sky.SkyShaderStarryUniforms;
import net.minecraft.client.Camera;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.ProjectionMatrixBuffer;
import net.minecraft.util.Util;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
/**
* Fullscreen "Starry" sky pass: procedural star field + milky way band + a round moon
* placed opposite the sun (driven by the world day cycle). Mirrors the scaffolding of
* {@link SkyShaderRenderer#renderNebula}; split out to keep both files within the size limit.
*/
public enum SkyShaderStarryRenderer {
;
private static final Matrix4f IDENTITY = new Matrix4f();
private static ProjectionMatrixBuffer projection;
private static long startMillis = -1L;
public static void reset() {
startMillis = -1L;
}
public static void render(Minecraft mc, float speed, float intensity, float alpha,
float starDensity, float milkyWayIntensity, float moonSize,
boolean showMoon, int moonColorArgb, int starColorArgb) {
if (mc == null || mc.level == null || mc.player == null) return;
RenderTarget fb = mc.gameRenderer.mainRenderTarget();
if (fb == null) return;
int w = mc.getWindow().getWidth();
int h = mc.getWindow().getHeight();
if (w <= 0 || h <= 0) return;
FullScreenRenderer.ensureInit();
if (startMillis < 0) startMillis = Util.getMillis();
float time = (Util.getMillis() - startMillis) / 1000.0f;
Camera cam = mc.gameRenderer.mainCamera();
Quaternionf rot = cam.rotation();
float fov = (float) mc.options.fov().get().intValue();
float[] moonDir = moonDirection(mc);
if (!showMoon) {
// The shader clamps the angular radius, so hiding the moon means pointing it
// below the horizon where horizonFade discards it.
moonDir = new float[]{0f, -1f, 0f};
}
float moonPhase = ((WorldTweaks.getServerTimeOfDay() / 24000f) % 8f) / 8f;
SkyShaderStarryUniforms.update(w, h, time, speed, intensity, alpha, fov,
rot.x(), rot.y(), rot.z(), rot.w(),
moonDir[0], moonDir[1], moonDir[2], moonSize,
moonPhase, milkyWayIntensity, starDensity,
channel(moonColorArgb, 16), channel(moonColorArgb, 8), channel(moonColorArgb, 0),
channel(starColorArgb, 16), channel(starColorArgb, 8), channel(starColorArgb, 0));
var mv = RenderSystem.getModelViewStack();
boolean pushedModelView = false;
GpuBufferSlice prevProj = RenderSystem.getProjectionMatrixBuffer();
ProjectionType prevProjType = RenderSystem.getProjectionType();
Matrix4f prevMeshProjection = MeshRenderer.projection();
boolean prevRendering3D = RenderState.rendering3D;
try {
mv.pushMatrix();
pushedModelView = true;
mv.identity();
if (projection == null) {
projection = new ProjectionMatrixBuffer("lovisual-sky-shader-starry-projection");
}
Matrix4f proj = IDENTITY.identity();
RenderSystem.setProjectionMatrix(projection.getBuffer(proj), ProjectionType.PERSPECTIVE);
MeshRenderer.setProjection(proj);
RenderState.rendering3D = false;
FullScreenRenderer.begin("LoVisual Fullscreen Pass")
.attachment(fb)
.pipeline(LoVisualRenderPipelines.SKY_SHADER_STARRY)
.uniform("SkyShaderStarry", SkyShaderStarryUniforms.get())
.end();
} finally {
if (pushedModelView) {
mv.popMatrix();
}
MeshRenderer.setProjection(prevMeshProjection);
RenderState.rendering3D = prevRendering3D;
if (prevProj != null && prevProjType != null) {
RenderSystem.setProjectionMatrix(prevProj, prevProjType);
}
}
}
/**
* World-space direction to the moon: opposite the sun along its day arc
* (CustomSkyboxRenderer sunDir = (-sin a, cos a, 0), so moon = (sin a, -cos a, 0)).
* Sun angle comes from the last rendered sky pass; fallback derives it from the day clock.
*
* @return normalized xyz
*/
private static float[] moonDirection(Minecraft mc) {
float sunAngleRad;
if (CustomSkyboxRenderer.hasLastSunAngle()) {
sunAngleRad = CustomSkyboxRenderer.getLastSunAngle();
} else {
float timeOfDay = (WorldTweaks.getServerTimeOfDay() % 24000L) / 24000f;
sunAngleRad = timeOfDay * (float) (Math.PI * 2.0);
}
float x = (float) Math.sin(sunAngleRad);
float y = (float) -Math.cos(sunAngleRad);
return new float[]{x, y, 0f};
}
private static float channel(int argb, int shift) {
return ((argb >>> shift) & 0xFF) / 255f;
}
}

View file

@ -17,6 +17,11 @@ public enum ProfilerPhase {
return new Scope(DevProfilerBridge.closeable("ProfilerPhase", "scope", new Class<?>[]{String.class}, label));
}
/** Two-part label: concatenation happens only when a dev profiler is attached. */
public static Scope scope(String prefix, String suffix) {
return DEV ? scope(prefix + suffix) : Scope.NOOP;
}
public static void begin(String label) {
if (!DEV) return;
DevProfilerBridge.invoke("ProfilerPhase", "begin", new Class<?>[]{String.class}, label);

View file

@ -33,6 +33,15 @@ public enum RenderCostProfiler {
return DevProfilerBridge.bool("RenderCostProfiler", "isEnabled", false, new Class<?>[0]);
}
/**
* Single static-field read for call sites that build concatenated labels
* ("graph:" + phase, "3d:module:" + name) every frame — the string work is
* free when no dev profiler jar is attached.
*/
public static boolean profiling() {
return DEV;
}
public static boolean isConfigured() {
if (!DEV) return false;
return DevProfilerBridge.bool("RenderCostProfiler", "isConfigured", false, new Class<?>[0]);
@ -69,6 +78,11 @@ public enum RenderCostProfiler {
return new Scope(DevProfilerBridge.closeable("RenderCostProfiler", "postPass", new Class<?>[]{String.class}, label));
}
/** Two-part label: concatenation happens only when a dev profiler is attached. */
public static Scope postPass(String prefix, String suffix) {
return DEV ? postPass(prefix + suffix) : Scope.NOOP;
}
public static Scope rhiDraw(String label) {
if (!DEV) return Scope.NOOP;
return new Scope(DevProfilerBridge.closeable("RenderCostProfiler", "rhiDraw", new Class<?>[]{String.class}, label));

View file

@ -39,6 +39,11 @@ public enum RenderProfiler2D {
return new Section(DevProfilerBridge.closeable("RenderProfiler2D", "section", new Class<?>[]{String.class}, name));
}
/** Two-part label: concatenation happens only when a dev profiler is attached. */
public static Section section(String prefix, String suffix) {
return DEV ? section(prefix + suffix) : Section.NOOP;
}
public static List<String> getDebugLines() {
if (!DEV) return List.of();
return DevProfilerBridge.lines("RenderProfiler2D", "getDebugLines", new Class<?>[0]);

View file

@ -39,6 +39,11 @@ public enum RenderProfiler3D {
return new Section(DevProfilerBridge.closeable("RenderProfiler3D", "section", new Class<?>[]{String.class}, name));
}
/** Two-part label: concatenation happens only when a dev profiler is attached. */
public static Section section(String prefix, String suffix) {
return DEV ? section(prefix + suffix) : Section.NOOP;
}
public static List<String> getDebugLines() {
if (!DEV) return List.of();
return DevProfilerBridge.lines("RenderProfiler3D", "getDebugLines", new Class<?>[0]);

View file

@ -10,6 +10,8 @@ package dev.loki.lovisual.render.engine.renderer.renderer2d.fx;
import com.mojang.blaze3d.pipeline.RenderTarget;
import com.mojang.blaze3d.textures.GpuSampler;
import com.mojang.blaze3d.textures.GpuTextureView;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.optimization.FpsOptimizer;
import dev.loki.lovisual.render.engine.postprocess.post.PostProcessManager;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.renderer.renderer2d.Batch2D;
@ -46,6 +48,8 @@ public final class Blur2D {
/** Main framebuffer color texture plus the shared post-process sampler. */
public static BlurSource source() {
FpsOptimizer fpsOptimizer = Modules.get(FpsOptimizer.class);
if (fpsOptimizer != null && fpsOptimizer.isGlassDisabled()) return null;
Minecraft mc = Minecraft.getInstance();
if (mc == null) return null;
RenderTarget fb = mc.gameRenderer.mainRenderTarget();

View file

@ -115,13 +115,22 @@ public final class UiBlurResources {
UiDeferredScheduler.deferAction(UiBlurResources::prepareCapturedWorldBlur);
}
/**
* Full-screen main-color copy — only when liquid glass was actually requested this frame
* (set by getOrCreateBlur/liquid-glass text recording, reset each extract frame). Skipping
* it when no HUD element uses glass removes a per-frame fullscreen copy from the "dead tax".
*/
public static void captureWorldSource() {
if (!liquidGlassBlurRequested) {
worldSourceReady = false;
return;
}
Minecraft minecraft = Minecraft.getInstance();
if (minecraft == null) return;
RenderTarget framebuffer = minecraft.gameRenderer.mainRenderTarget();
TextureTarget source = ensureGlassSource(minecraft);
worldSourceReady = copyMainColor(framebuffer, source);
if (!worldSourceReady || !liquidGlassBlurRequested || source == null) return;
if (!worldSourceReady || source == null) return;
prepareCapturedWorldBlur();
}

View file

@ -96,13 +96,16 @@ public final class GlStencilShapeClipBackend implements ShapeClipBackend {
if (offscreenPassBypassDepth > 0) {
resetNativeStateOnly();
checkGlErrors("beginRenderPass(offscreen-bypass)");
if (DebugLog.isStencilDebugEnabled()) {
checkGlErrors("beginRenderPass(offscreen-bypass)");
}
return;
}
boolean shouldPrepare = renderPassAttachmentRequired || mode != Mode.DISABLED || clearRequested;
if (!shouldPrepare) {
checkGlErrors("beginRenderPass(no-clip)");
// Hot path (every vanilla/Sodium pass with clip idle): glGetError syncs with the
// driver — skip it unless explicitly debugging stencil.
return;
}
@ -127,6 +130,9 @@ public final class GlStencilShapeClipBackend implements ShapeClipBackend {
@Override
public void endRenderPass() {
// Capture before endRenderPass resets renderPassAttachmentRequired below.
boolean errorsOfInterest = mode != Mode.DISABLED || renderPassAttachmentRequired
|| DebugLog.isStencilDebugEnabled();
resetNativeStateOnly();
currentColorView = null;
currentDepthView = null;
@ -138,7 +144,9 @@ public final class GlStencilShapeClipBackend implements ShapeClipBackend {
renderPassAttachmentRequired = false;
attachmentReason = "none";
}
checkGlErrors(offscreenPassBypassDepth > 0 ? "endRenderPass(offscreen-bypass)" : "endRenderPass");
if (errorsOfInterest) {
checkGlErrors(offscreenPassBypassDepth > 0 ? "endRenderPass(offscreen-bypass)" : "endRenderPass");
}
}
@Override
@ -250,12 +258,16 @@ public final class GlStencilShapeClipBackend implements ShapeClipBackend {
public void applyNativeState() {
if (offscreenPassBypassDepth > 0) {
resetNativeStateOnly();
checkGlErrors("applyNativeState(offscreen-bypass)");
if (DebugLog.isStencilDebugEnabled()) {
checkGlErrors("applyNativeState(offscreen-bypass)");
}
return;
}
if (mode == Mode.DISABLED) {
resetNativeStateOnly();
checkGlErrors("applyNativeState(disabled)");
if (DebugLog.isStencilDebugEnabled()) {
checkGlErrors("applyNativeState(disabled)");
}
return;
}

View file

@ -102,6 +102,22 @@ public enum WorldTextRenderer {
return width * worldScale;
}
/** Width the given text would occupy on screen, in the same world units as {@link Options#offsetX()}, without drawing anything. */
public static double measureWidth(TextRenderer textRenderer, String text, Options options) {
if (text == null || text.isEmpty()) return 0.0;
Options resolved = options != null ? options : Options.defaults();
CustomTextRenderer custom = resolve(textRenderer);
if (custom == null) return 0.0;
CustomTextRenderer.GlyphSelection selection = custom.prepareGlyphFont(resolved.scale(), resolved.big());
GlyphFont font = selection.font();
if (font == null) return 0.0;
double glyphScale = selection.glyphScale();
return font.getWidth(text, text.length()) * glyphScale * resolved.worldScale();
}
private static void emitString(MeshBuilder mesh,
GlyphFont font,
String text,

View file

@ -0,0 +1,108 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.render.engine.uniform.impl.sky;
import dev.loki.lovisual.render.engine.core.system.LoVisualRenderSystem;
import com.mojang.blaze3d.buffers.GpuBufferSlice;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.buffers.Std140SizeCalculator;
import dev.loki.lovisual.render.engine.rhi.uniform.LoVisualUniformAllocator;
/** Uniform buffer for the SkyShader "Starry" fullscreen mode (star field + milky way + procedural round moon). */
public enum SkyShaderStarryUniforms {
;
public static final int SIZE = new Std140SizeCalculator()
.putVec4().putVec4().putVec4().putVec4().putVec4()
.putVec4().putVec4()
.get();
private static final Data DATA = new Data();
private static final String UNIFORM_NAME = "LoVisual - SkyShaderStarry UBO";
private static final int EXPECTED_WRITES_PER_FRAME = 4;
public static void update(float width, float height, float time, float speed,
float intensity, float alpha, float fov,
float qx, float qy, float qz, float qw,
float moonDirX, float moonDirY, float moonDirZ, float moonSize,
float moonPhase01, float milkyWayIntensity, float starDensity,
float moonR, float moonG, float moonB,
float starR, float starG, float starB) {
DATA.resolution[0] = width;
DATA.resolution[1] = height;
DATA.resolution[2] = time;
DATA.resolution[3] = speed;
DATA.settings[0] = intensity;
DATA.settings[1] = alpha;
DATA.settings[2] = fov;
DATA.settings[3] = starDensity;
DATA.cameraDir[0] = qx;
DATA.cameraDir[1] = qy;
DATA.cameraDir[2] = qz;
DATA.cameraDir[3] = qw;
DATA.moon[0] = moonDirX;
DATA.moon[1] = moonDirY;
DATA.moon[2] = moonDirZ;
DATA.moon[3] = moonSize;
DATA.moonExtra[0] = moonPhase01;
DATA.moonExtra[1] = milkyWayIntensity;
DATA.moonExtra[2] = 0f;
DATA.moonExtra[3] = 0f;
DATA.moonColor[0] = moonR;
DATA.moonColor[1] = moonG;
DATA.moonColor[2] = moonB;
DATA.moonColor[3] = 1f;
DATA.starColor[0] = starR;
DATA.starColor[1] = starG;
DATA.starColor[2] = starB;
DATA.starColor[3] = 1f;
LoVisualRenderSystem.uniforms().write(UNIFORM_NAME, SIZE, EXPECTED_WRITES_PER_FRAME, DATA);
}
public static GpuBufferSlice get() {
return LoVisualRenderSystem.uniforms().current(UNIFORM_NAME);
}
private static final class Data implements LoVisualUniformAllocator.UniformWriter {
private final float[] resolution = new float[4];
private final float[] settings = new float[4];
private final float[] cameraDir = new float[4];
private final float[] moon = new float[4];
private final float[] moonExtra = new float[4];
private final float[] moonColor = new float[4];
private final float[] starColor = new float[4];
@Override
public void write(java.nio.ByteBuffer buffer) {
Std140Builder builder = Std140Builder.intoBuffer(buffer);
putVec4(builder, resolution);
putVec4(builder, settings);
putVec4(builder, cameraDir);
putVec4(builder, moon);
putVec4(builder, moonExtra);
putVec4(builder, moonColor);
putVec4(builder, starColor);
}
private static void putVec4(Std140Builder builder, float[] v) {
builder.putFloat(v[0]).putFloat(v[1]).putFloat(v[2]).putFloat(v[3]);
}
@Override
public boolean equals(Object o) {
return false;
}
}
}

View file

@ -37,6 +37,7 @@ public final class LinuxMediaSession implements IMediaSession {
private static final ConcurrentHashMap<String, Boolean> PENDING_TRACK_BY_OWNER = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, String> LAST_NUDGED_TRACK_ID_BY_OWNER = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, SeekOverrideState> SEEK_OVERRIDE_BY_OWNER = new ConcurrentHashMap<>();
private static final ConcurrentHashMap<String, byte[]> ARTWORK_CACHE_BY_URL = new ConcurrentHashMap<>();
private final Player dbus;
private final String owner;
@ -379,14 +380,23 @@ public final class LinuxMediaSession implements IMediaSession {
private byte[] readArtworkBytes(Map<String, Object> metadata) {
Object raw = unwrap(metadata.get("mpris:artUrl"));
if (!(raw instanceof String url) || url.isBlank()) return new byte[0];
byte[] cached = ARTWORK_CACHE_BY_URL.get(url);
if (cached != null) return cached;
try {
URL connectionUrl = new URL(url);
var connection = connectionUrl.openConnection();
connection.setConnectTimeout(1500);
connection.setReadTimeout(1500);
connection.setConnectTimeout(3000);
connection.setReadTimeout(3000);
byte[] bytes;
try (InputStream stream = connection.getInputStream()) {
return stream.readAllBytes();
bytes = stream.readAllBytes();
}
if (bytes.length > 0) {
ARTWORK_CACHE_BY_URL.put(url, bytes);
}
return bytes;
} catch (Exception ignored) {
return new byte[0];
}

View file

@ -266,9 +266,9 @@ public final class MediaSessionService {
}
boolean hasSession = session != null && media != null;
long predictedPosition = hasSession ? getPredictedPositionMs(media) / 1000L : 0L;
long predictedPositionMs = hasSession ? getPredictedPositionMs(media) : 0L;
long duration = hasSession ? lastRawDuration : 0L;
snapshot = new Snapshot(mediaAvailable, session, media, artworkTexture, predictedPosition, duration, hasSession);
snapshot = new Snapshot(mediaAvailable, session, media, artworkTexture, predictedPositionMs, duration, hasSession);
}
private IMediaSession resolveSession() {
@ -351,6 +351,14 @@ public final class MediaSessionService {
artworkTexture = ARTWORK_ID;
}
// 300ms instead of 1s: MPRIS/SMTC position only updates when we ask for it (no
// PropertiesChanged push), so the raw sample is always stale by up to one poll
// interval. Polling faster shrinks that worst-case staleness window fourfold without
// adding network cost - these are local D-Bus/WinRT reads, not HTTP (artwork bytes are
// cached by URL, see LinuxMediaSession.readArtworkBytes, so this does not multiply
// network requests).
private static final long POLL_INTERVAL_MS = 300L;
private void pollMediaSessions() {
while (pollingRunning) {
try {
@ -361,7 +369,7 @@ public final class MediaSessionService {
}
try {
Thread.sleep(1000L);
Thread.sleep(POLL_INTERVAL_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
pollingRunning = false;
@ -545,13 +553,23 @@ public final class MediaSessionService {
IMediaSession session,
MediaInfo media,
Identifier artworkTexture,
long predictedPositionSeconds,
long predictedPositionMs,
long durationSeconds,
boolean hasSession) {
public static Snapshot empty(boolean mediaAvailable) {
return new Snapshot(mediaAvailable, null, null, null, 0L, 0L, false);
}
/**
* Whole-second position for UI widgets (mm:ss display, progress bars). Lyric sync must use
* {@link #predictedPositionMs()} directly instead — truncating to seconds here previously fed
* a staircase-quantized timestamp into KineticLyrics, even though the underlying prediction
* already extrapolates smoothly between MPRIS/SMTC polls.
*/
public long predictedPositionSeconds() {
return predictedPositionMs / 1000L;
}
public boolean isPlaying() {
return media != null && media.playing();
}

View file

@ -9,7 +9,6 @@ package dev.loki.lovisual.util.pvp.client;
import net.minecraft.util.Util;
import net.minecraft.world.item.Item;
import dev.loki.lovisual.config.i18n.MainConfig;
import dev.loki.lovisual.config.values.collection.ItemCooldownRulesValue;
import dev.loki.lovisual.util.pvp.cooldown.ItemCooldownSnapshot;
import dev.loki.lovisual.util.pvp.cooldown.ItemUseCooldowns;
@ -38,7 +37,7 @@ public class CooldownManager {
}
public boolean isInPvp() {
return MainConfig.get().isForcePvp() || inPvp;
return inPvp;
}
public boolean isPvpGraceActive(long graceMs) {

View file

@ -166,7 +166,6 @@
"setting.main_config.debug.option.error_and_warnings": "Errors and warnings",
"setting.main_config.debug.option.serverdebug": "Server debug",
"setting.main_config.debug.option.all": "All",
"setting.main_config.forcePvp": "Force PvP",
"setting.main_config.menuBg": "Menu background",
"setting.main_config.menuBg.option.off": "Off",
"setting.main_config.menuBg.option.waves": "Waves",
@ -1141,7 +1140,7 @@
"setting.postfx.vignette_radius": "Vignette radius",
"setting.timecontrol.sync_system_time": "Sync with system time",
"setting.autoleave.respect_pvp": "Respect PVP",
"setting.autoleave.hp_threshold": "HP threshold",
"setting.autoleave.hp_threshold": "Health threshold",
"setting.shitdropper.max_trash_slots": "Max slots",
"setting.shitdropper.delay_ticks": "Delay (ticks)",
"setting.shitdropper.trash_item_ids": "Trash item ids",
@ -1306,18 +1305,6 @@
"setting.esp.entity_color": "Entity Color",
"setting.esp.search_entities": "Search Entities",
"setting.esp.render_self": "Render Self",
"setting.tazikhat.render_target": "Render",
"setting.tazikhat.render_target.option.self": "Self",
"setting.tazikhat.render_target.option.players": "Players",
"setting.tazikhat.render_target.option.both": "Both",
"setting.tazikhat.rim_color": "Rim color",
"setting.tazikhat.helmet_scale": "Helmet scale",
"setting.tazikhat.head_top_offset": "Head height",
"setting.tazikhat.head_forward_offset": "Forward offset",
"setting.tazikhat.head_right_offset": "Right offset",
"setting.tazikhat.wobble_strength": "Wobble strength",
"setting.tazikhat.death_flight": "Death flight",
"setting.tazikhat.death_flight_power": "Death flight power",
"setting.cameraclip.distance_multiplier": "Distance multiplier",
"setting.cameraclip.back_offset": "Back offset",
"setting.cameraclip.right_offset": "Right offset",
@ -3256,18 +3243,6 @@
"command.tps.description": "Shows estimated server ticks per second.",
"command.username.description": "Shows the current Minecraft username and UUID.",
"command.xaero.description": "Adds, lists, or removes Xaero waypoints.",
"module.chinahat.description": "Renders a classic conical Chinese hat on players' heads.",
"setting.chinahat.render_target": "Render",
"setting.chinahat.render_target.option.self": "Self",
"setting.chinahat.render_target.option.players": "Players",
"setting.chinahat.render_target.option.both": "Both",
"setting.chinahat.rim_color": "Rim color",
"setting.chinahat.segments": "Segments",
"setting.chinahat.brim_radius": "Brim radius",
"setting.chinahat.cone_height": "Cone height",
"setting.chinahat.head_top_offset": "Head height",
"setting.chinahat.head_forward_offset": "Forward offset",
"setting.chinahat.head_right_offset": "Right offset",
"module.animations.description": "Animates interface transitions.",
"setting.animations.hotbar_raise": "Hotbar raise",
"setting.animations.inventory": "Inventory open",
@ -3309,7 +3284,6 @@
"setting.holdmyitems.join_appear": "Smooth world entry",
"module.autoleave.description": "Leaves the server when your health drops low or unfriendly players come nearby.",
"setting.autoleave.hp_enabled": "Leave on low health",
"setting.autoleave.hp_threshold": "Health threshold",
"setting.autoleave.players_enabled": "Leave when players nearby",
"setting.autoleave.player_count": "Player count",
"setting.autoleave.radius": "Radius",
@ -3343,5 +3317,76 @@
"setting.streamermode.mask_server_words": "Mask server words",
"setting.streamermode.hide_skins": "Hide skins",
"module.itemscroller.description": "Shift-moves items with the mouse wheel inside any container.",
"setting.itemscroller.hotbar_fallback": "Scroll moves hotbar slot"
"setting.itemscroller.hotbar_fallback": "Scroll moves hotbar slot",
"setting.tazikhat.render_target": "Render",
"setting.tazikhat.render_target.option.self": "Self",
"setting.tazikhat.render_target.option.players": "Players",
"setting.tazikhat.render_target.option.both": "Both",
"setting.tazikhat.rim_color": "Rim color",
"setting.tazikhat.helmet_scale": "Helmet scale",
"setting.tazikhat.head_top_offset": "Head height",
"setting.tazikhat.head_forward_offset": "Forward offset",
"setting.tazikhat.head_right_offset": "Right offset",
"setting.tazikhat.wobble_strength": "Wobble strength",
"module.chinahat.description": "Renders a classic conical Chinese hat on players' heads.",
"setting.chinahat.render_target": "Render",
"setting.chinahat.render_target.option.self": "Self",
"setting.chinahat.render_target.option.players": "Players",
"setting.chinahat.render_target.option.both": "Both",
"setting.chinahat.rim_color": "Rim color",
"setting.chinahat.segments": "Segments",
"setting.chinahat.brim_radius": "Brim radius",
"setting.chinahat.cone_height": "Cone height",
"setting.chinahat.head_top_offset": "Head height",
"setting.chinahat.head_forward_offset": "Forward offset",
"setting.chinahat.head_right_offset": "Right offset",
"module.fpsoptimizer.name": "FPS Optimizer",
"module.fpsoptimizer.description": "Manual toggles to cut render cost: vanilla particles, menu background shader, glass/blur, bloom/glow, off-screen entity culling.",
"setting.fpsoptimizer.disable_particles": "Disable particles",
"setting.fpsoptimizer.disable_menu_background": "Disable menu background",
"setting.fpsoptimizer.disable_glass": "Disable glass/blur effects",
"setting.fpsoptimizer.disable_bloom": "Disable bloom/glow effects",
"setting.fpsoptimizer.entity_culling": "Cull off-screen entities",
"setting.fpsoptimizer.entity_cull_distance": "Entity cull distance",
"setting.cosmeticdisplay.next_cosmetic.desc": "Cycles to the next model in the Model slot.",
"setting.cosmeticdisplay.prev_cosmetic.desc": "Cycles to the previous model in the Model slot.",
"setting.cosmeticdisplay.open_cosmetics.desc": "Opens the cosmetics editor: 3D preview with clickable body zones and per-slot model pickers.",
"setting.zoom.zoom_hold.desc": "Hold to zoom the camera in.",
"setting.thirdperson.look.desc": "Hold to orbit the camera around your player in free third-person view.",
"setting.freecam.toggle_input.desc": "Toggles whether WASD input still moves the freecam.",
"setting.definetarget.toggle_target.desc": "Toggles defining the entity you look at as your target.",
"setting.shadersky.mode.option.starry": "Starry",
"setting.shadersky.starry_intensity": "Star intensity",
"setting.shadersky.starry_density": "Star density",
"setting.shadersky.starry_milkyway": "Milky way",
"setting.shadersky.starry_moon": "Show moon",
"setting.shadersky.starry_moon_size": "Moon size",
"setting.shadersky.starry_moon_color": "Moon color",
"setting.shadersky.starry_star_color": "Star color",
"module.wings.name": "Wings",
"module.wings.description": "Cosmetic wings on players' backs: 8 silhouette types with pose-driven flapping.",
"setting.wings.type": "Wing type",
"setting.wings.type.option.angelic": "Angelic",
"setting.wings.type.option.dragon": "Dragon",
"setting.wings.type.option.butterfly": "Butterfly",
"setting.wings.type.option.phoenix": "Phoenix",
"setting.wings.type.option.crystal": "Crystal",
"setting.wings.type.option.mechanical": "Mechanical",
"setting.wings.type.option.fairy": "Fairy",
"setting.wings.type.option.demon": "Demon",
"setting.wings.target": "Targets",
"setting.wings.target.option.self": "Self",
"setting.wings.target.option.friends": "Friends",
"setting.wings.target.option.all_players": "All players",
"setting.wings.scale": "Scale",
"setting.wings.flapping": "Flapping",
"setting.wings.flap_strength": "Flap strength",
"setting.wings.flap_speed": "Flap speed",
"setting.wings.through_walls": "Through walls",
"setting.wings.glow": "Glow layers",
"setting.wings.outline": "Outline",
"setting.wings.ribs": "Ribs",
"setting.wings.color": "Color",
"setting.kineticlyrics.time_offset_ms.desc": "Shifts lyric timing vs the audible audio (ms). Positive = text later. Player position leads the speakers (Bluetooth: 0.5-1s+), so a negative default compensates. Tune with debug mode if it still drifts.",
"setting.kineticlyrics.arc_spread.desc": "How wide around your view lines may spawn; automatically clamped to your real horizontal FOV."
}

View file

@ -1082,7 +1082,7 @@
"setting.postfx.vignette_radius": "Радиус виньетки",
"setting.timecontrol.sync_system_time": "Синхронизировать с временем системы",
"setting.autoleave.respect_pvp": "Уважать PVP",
"setting.autoleave.hp_threshold": "Порог ХП",
"setting.autoleave.hp_threshold": "Порог здоровья",
"setting.shitdropper.max_trash_slots": "Макс. слотов",
"setting.shitdropper.delay_ticks": "Задержка (тики)",
"setting.shitdropper.trash_item_ids": "Id мусорных предметов",
@ -1104,14 +1104,6 @@
"setting.esp.entity_color": "Цвет сущностей",
"setting.esp.search_entities": "Поиск сущностей",
"setting.esp.render_self": "Отображать себя",
"setting.tazikhat.rim_color": "Цвет обода",
"setting.tazikhat.helmet_scale": "Размер со шлемом",
"setting.tazikhat.head_top_offset": "Высота на голове",
"setting.tazikhat.head_forward_offset": "Смещение вперёд",
"setting.tazikhat.head_right_offset": "Смещение вправо",
"setting.tazikhat.wobble_strength": "Сила колыхания",
"setting.tazikhat.death_flight": "Улёт при смерти",
"setting.tazikhat.death_flight_power": "Сила улёта",
"setting.cameraclip.distance_multiplier": "Множитель дистанции",
"setting.cameraclip.back_offset": "Смещение назад",
"setting.cameraclip.right_offset": "Смещение вправо",
@ -3046,18 +3038,6 @@
"command.tps.description": "Показывает расчётное количество серверных тиков в секунду.",
"command.username.description": "Показывает текущее имя Minecraft и UUID.",
"command.xaero.description": "Добавляет, показывает или удаляет точки Xaero.",
"module.chinahat.description": "Рисует классическую коническую китайскую шляпу на головах игроков.",
"setting.chinahat.render_target": "Рендер",
"setting.chinahat.render_target.option.self": "Себе",
"setting.chinahat.render_target.option.players": "Игрокам",
"setting.chinahat.render_target.option.both": "Всем",
"setting.chinahat.rim_color": "Цвет обода",
"setting.chinahat.segments": "Сегменты",
"setting.chinahat.brim_radius": "Радиус полей",
"setting.chinahat.cone_height": "Высота конуса",
"setting.chinahat.head_top_offset": "Высота на голове",
"setting.chinahat.head_forward_offset": "Смещение вперёд",
"setting.chinahat.head_right_offset": "Смещение вправо",
"module.animations.description": "Плавные анимации интерфейса.",
"setting.animations.hotbar_raise": "Подъём хотбара",
"setting.animations.inventory": "Открытие инвентаря",
@ -3099,7 +3079,6 @@
"setting.holdmyitems.join_appear": "Плавное появление при входе",
"module.autoleave.description": "Покидает сервер при низком здоровье или когда рядом оказываются недруги.",
"setting.autoleave.hp_enabled": "Выход при низком ХП",
"setting.autoleave.hp_threshold": "Порог здоровья",
"setting.autoleave.players_enabled": "Выход при игроках рядом",
"setting.autoleave.player_count": "Количество игроков",
"setting.autoleave.radius": "Радиус",
@ -3133,5 +3112,72 @@
"setting.streamermode.mask_server_words": "Маскировать названия серверов",
"setting.streamermode.hide_skins": "Скрывать скины",
"module.itemscroller.description": "Перекладывает предметы колёсиком мыши (shift-клик) в любом инвентаре.",
"setting.itemscroller.hotbar_fallback": "Скролл двигает слот хотбара"
"setting.itemscroller.hotbar_fallback": "Скролл двигает слот хотбара",
"setting.tazikhat.rim_color": "Цвет обода",
"setting.tazikhat.helmet_scale": "Размер со шлемом",
"setting.tazikhat.head_top_offset": "Высота на голове",
"setting.tazikhat.head_forward_offset": "Смещение вперёд",
"setting.tazikhat.head_right_offset": "Смещение вправо",
"setting.tazikhat.wobble_strength": "Сила колыхания",
"module.chinahat.description": "Рисует классическую коническую китайскую шляпу на головах игроков.",
"setting.chinahat.render_target": "Рендер",
"setting.chinahat.render_target.option.self": "Себе",
"setting.chinahat.render_target.option.players": "Игрокам",
"setting.chinahat.render_target.option.both": "Всем",
"setting.chinahat.rim_color": "Цвет обода",
"setting.chinahat.segments": "Сегменты",
"setting.chinahat.brim_radius": "Радиус полей",
"setting.chinahat.cone_height": "Высота конуса",
"setting.chinahat.head_top_offset": "Высота на голове",
"setting.chinahat.head_forward_offset": "Смещение вперёд",
"setting.chinahat.head_right_offset": "Смещение вправо",
"module.fpsoptimizer.name": "Оптимизация FPS",
"module.fpsoptimizer.description": "Ручные тумблеры для снижения нагрузки рендера: ванильные частицы, фоновый шейдер меню, стекло/блюр, блум/свечение, отсечение сущностей за экраном.",
"setting.fpsoptimizer.disable_particles": "Отключить частицы",
"setting.fpsoptimizer.disable_menu_background": "Отключить фон меню",
"setting.fpsoptimizer.disable_glass": "Отключить стекло/блюр",
"setting.fpsoptimizer.disable_bloom": "Отключить блум/свечение",
"setting.fpsoptimizer.entity_culling": "Скрывать сущности за экраном",
"setting.fpsoptimizer.entity_cull_distance": "Дистанция отсечения сущностей",
"setting.cosmeticdisplay.next_cosmetic.desc": "Следующая модель в слоте «Модель».",
"setting.cosmeticdisplay.prev_cosmetic.desc": "Предыдущая модель в слоте «Модель».",
"setting.cosmeticdisplay.open_cosmetics.desc": "Открывает редактор косметики: 3D-превью с кликабельными зонами тела и выбором моделей по слотам.",
"setting.zoom.zoom_hold.desc": "Удерживай, чтобы приблизить камеру.",
"setting.thirdperson.look.desc": "Удерживай, чтобы вращать камеру вокруг игрока (обзор от третьего лица).",
"setting.freecam.toggle_input.desc": "Вкл/выкл движение WASD в полётном режиме камеры.",
"setting.definetarget.toggle_target.desc": "Отмечает сущность, на которую смотришь, как цель.",
"setting.shadersky.mode.option.starry": "Звёздное",
"setting.shadersky.starry_intensity": "Яркость звёзд",
"setting.shadersky.starry_density": "Плотность звёзд",
"setting.shadersky.starry_milkyway": "Млечный путь",
"setting.shadersky.starry_moon": "Показывать луну",
"setting.shadersky.starry_moon_size": "Размер луны",
"setting.shadersky.starry_moon_color": "Цвет луны",
"setting.shadersky.starry_star_color": "Цвет звёзд",
"module.wings.name": "Крылья",
"module.wings.description": "Косметические крылья на спине игроков: 8 типов формы с анимацией взмахов.",
"setting.wings.type": "Тип крыльев",
"setting.wings.type.option.angelic": "Ангельские",
"setting.wings.type.option.dragon": "Драконьи",
"setting.wings.type.option.butterfly": "Бабочка",
"setting.wings.type.option.phoenix": "Феникс",
"setting.wings.type.option.crystal": "Кристалл",
"setting.wings.type.option.mechanical": "Механические",
"setting.wings.type.option.fairy": "Фея",
"setting.wings.type.option.demon": "Демонические",
"setting.wings.target": "Кому показывать",
"setting.wings.target.option.self": "Только себе",
"setting.wings.target.option.friends": "Друзьям",
"setting.wings.target.option.all_players": "Всем игрокам",
"setting.wings.scale": "Масштаб",
"setting.wings.flapping": "Взмахи",
"setting.wings.flap_strength": "Сила взмаха",
"setting.wings.flap_speed": "Скорость взмаха",
"setting.wings.through_walls": "Сквозь стены",
"setting.wings.glow": "Слои свечения",
"setting.wings.outline": "Обводка",
"setting.wings.ribs": "Перья (рёбра)",
"setting.wings.color": "Цвет",
"setting.kineticlyrics.time_offset_ms.desc": "Сдвиг тайминга текста относительно слышимого звука (мс). Плюс = текст позже. Позиция плеера опережает динамики (Bluetooth: 0.5–1с+), дефолтный минус это компенсирует. Подстрой через debug, если всё ещё плывёт.",
"setting.kineticlyrics.arc_spread.desc": "Насколько широко вокруг обзора могут появляться строки; автоматически ограничивается реальным горизонтальным FOV."
}

View file

@ -0,0 +1,159 @@
#version 330 core
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*/
out vec4 color;
layout (std140) uniform SkyShaderStarry {
vec4 u_Resolution; // xy = size, z = time, w = speed
vec4 u_Settings; // x = intensity, y = alpha, z = FOV, w = starDensity
vec4 u_CameraDir; // orientation quaternion
vec4 u_Moon; // xyz = world-space moon direction, w = angular radius
vec4 u_MoonExtra; // x = moon phase (0..1), y = milky way intensity, zw = spare
vec4 u_MoonColor;
vec4 u_StarColor;
};
float hash13(vec3 p) {
p = fract(p * 0.3183099 + vec3(0.1, 0.2, 0.3));
p *= 17.0;
return fract(p.x * p.y * p.z * (p.x + p.y + p.z));
}
float hash21(vec2 p) {
p = fract(p * vec2(443.8975, 397.2973));
p += dot(p, p + 19.19);
return fract(p.x * p.y);
}
float smoothNoise2D(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash21(i);
float b = hash21(i + vec2(1.0, 0.0));
float c = hash21(i + vec2(0.0, 1.0));
float d = hash21(i + vec2(1.0, 1.0));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(vec2 p) {
float v = 0.0, a = 0.5, freq = 1.0;
for (int k = 0; k < 4; k++) {
v += a * smoothNoise2D(p * freq);
freq *= 2.05;
a *= 0.5;
}
return v;
}
vec3 calcRayDir() {
vec2 uv = gl_FragCoord.xy / u_Resolution.xy;
vec2 sp = uv * 2.0 - 1.0;
float aspect = u_Resolution.x / u_Resolution.y;
float tanV = tan(radians(u_Settings.z) * 0.5);
vec3 ray = normalize(vec3(sp.x * tanV * aspect, sp.y * tanV, -1.0));
vec3 qv = u_CameraDir.xyz;
float qw = u_CameraDir.w;
mat3 rot = mat3(
1.0 - 2.0*(qv.y*qv.y + qv.z*qv.z), 2.0*(qv.x*qv.y + qv.z*qw), 2.0*(qv.x*qv.z - qv.y*qw),
2.0*(qv.x*qv.y - qv.z*qw), 1.0 - 2.0*(qv.x*qv.x + qv.z*qv.z), 2.0*(qv.y*qv.z + qv.x*qw),
2.0*(qv.x*qv.z + qv.y*qw), 2.0*(qv.y*qv.z - qv.x*qw), 1.0 - 2.0*(qv.x*qv.x + qv.y*qv.y)
);
return rot * ray;
}
float starField(vec3 dir, float time, float density) {
vec3 grid = dir * density;
vec3 cell = floor(grid);
float h = hash13(cell);
float exists = step(0.986, h);
float twinkle = 0.6 + 0.4 * sin(time * (2.0 + h * 6.0) + h * 43.0);
float brightness = fract(h * 91.7) * 0.7 + 0.3;
return exists * twinkle * brightness;
}
float milkyWayBand(vec3 dir, float time) {
vec3 axis = normalize(vec3(0.35, 0.12, 0.93));
float t = dot(dir, axis);
float band = exp(-t * t * 7.0);
vec2 texCoord = vec2(dir.x - dir.z, dir.y) * 2.5 + time * 0.005;
float texture = fbm(texCoord) * 0.6 + fbm(texCoord * 2.3 + 5.0) * 0.4;
return band * (0.55 + 0.45 * texture);
}
vec3 basisFromDir(vec3 dir, out vec3 right, out vec3 up) {
vec3 worldUp = abs(dir.y) > 0.99 ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 1.0, 0.0);
right = normalize(cross(worldUp, dir));
up = normalize(cross(dir, right));
return dir;
}
float moonDisc(vec3 dir, out float lit) {
vec3 moonDir = normalize(u_Moon.xyz);
float angularRadius = max(0.002, u_Moon.w);
vec3 right, up;
basisFromDir(moonDir, right, up);
vec3 diff = dir - moonDir;
vec2 p = vec2(dot(diff, right), dot(diff, up)) / angularRadius;
float edge = 0.06;
float distMoon = length(p);
float moonMask = 1.0 - smoothstep(1.0 - edge, 1.0 + edge, distMoon);
if (moonMask <= 0.001) {
lit = 0.0;
return 0.0;
}
float phaseAngle = fract(u_MoonExtra.x) * 6.2831853;
float side = sin(phaseAngle) >= 0.0 ? 1.0 : -1.0;
float shadowX = side * (1.0 - cos(phaseAngle));
float distShadow = length(p - vec2(shadowX, 0.0));
float shadowMask = 1.0 - smoothstep(1.0 - edge, 1.0 + edge, distShadow);
float limbDarkening = 1.0 - 0.25 * distMoon * distMoon;
float litArea = moonMask * (1.0 - shadowMask) * limbDarkening;
float earthshine = moonMask * shadowMask * 0.10;
lit = litArea + earthshine;
return moonMask;
}
void main() {
vec3 dir = normalize(calcRayDir());
float time = u_Resolution.z;
float speed = u_Resolution.w;
float anim = time * speed;
float horizonFade = smoothstep(-0.05, 0.12, dir.y);
if (horizonFade < 0.005) discard;
float intensity = max(0.0001, u_Settings.x);
float alpha = u_Settings.y;
float starDensity = max(1.0, u_Settings.w);
float milkyWayIntensity = u_MoonExtra.y;
vec3 skyBase = vec3(0.015, 0.02, 0.05);
float stars = starField(dir, anim, starDensity);
float milkyWay = milkyWayBand(dir, anim) * milkyWayIntensity;
float moonLit;
float moonMask = moonDisc(dir, moonLit);
vec3 skyColor = skyBase;
skyColor += u_StarColor.rgb * stars * intensity;
skyColor += mix(u_StarColor.rgb, vec3(0.75, 0.8, 1.0), 0.4) * milkyWay * intensity;
skyColor = mix(skyColor, u_MoonColor.rgb * (0.4 + 0.6 * moonLit), moonMask);
skyColor += u_MoonColor.rgb * moonLit * 0.6 * intensity;
float finalAlpha = alpha * horizonFade;
color = vec4(clamp(skyColor, 0.0, 1.0), finalAlpha);
}

View file

@ -140,6 +140,7 @@
"network.game.PlayerInfoMixin",
"network.packet.ClientCommonPacketListenerImplMixin",
"network.packet.ServerboundUseItemPacketMixin",
"render.entity.EntityRenderDispatcherMixin",
"render.entity.ItemEntityRenderStateMixin",
"render.entity.ItemEntityRendererMixin",
"render.entity.LivingEntityRenderStateMixin",

View file

@ -10,7 +10,6 @@ package dev.loki.lovisual.features.module.modules.visuals;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.loki.lovisual.features.module.modules.visuals.hats.ChinaHat;
import dev.loki.lovisual.features.module.modules.visuals.hats.ChinaHatPalette;
public final class ChinaHatPaletteTest {
@ -67,25 +66,4 @@ public final class ChinaHatPaletteTest {
assertEquals(b, ChinaHatPalette.mix(a, b, 2.0f));
assertEquals(0x80808080, ChinaHatPalette.mix(a, b, 0.5f));
}
@Test
void colorModeIdsAreUniqueAndLowercase() {
String[] ids = java.util.Arrays.stream(ChinaHat.ColorMode.values())
.map(ChinaHat.ColorMode::id)
.toArray(String[]::new);
assertEquals(ChinaHat.ColorMode.values().length, java.util.Arrays.stream(ids).distinct().count());
for (String id : ids) {
assertEquals(id, id.toLowerCase());
}
}
@Test
void renderTargetIdsAreUnique() {
String[] ids = java.util.Arrays.stream(ChinaHat.RenderTarget.values())
.map(ChinaHat.RenderTarget::id)
.toArray(String[]::new);
assertEquals(ChinaHat.RenderTarget.values().length, java.util.Arrays.stream(ids).distinct().count());
}
}

View file

@ -1,45 +0,0 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals.cosmetics;
import dev.loki.lovisual.features.cosmetic.model.CosmeticSlot;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import dev.loki.lovisual.features.module.modules.visuals.cosmetics.CosmeticsHotspot;
public final class CosmeticsHotspotTest {
@Test
void headIsTopZone() {
CosmeticsHotspot[] zones = CosmeticsHotspot.forRegion(0f, 0f, 100f, 100f, false);
assertEquals(CosmeticSlot.HEAD, CosmeticsHotspot.hitTest(zones, 50, 5));
}
@Test
void legsIsBottomZone() {
CosmeticsHotspot[] zones = CosmeticsHotspot.forRegion(0f, 0f, 100f, 100f, false);
assertEquals(CosmeticSlot.LEGS, CosmeticsHotspot.hitTest(zones, 50, 95));
}
@Test
void shouldersZoneBecomesBackWhenBackViewToggled() {
CosmeticsHotspot[] front = CosmeticsHotspot.forRegion(0f, 0f, 100f, 100f, false);
CosmeticsHotspot[] back = CosmeticsHotspot.forRegion(0f, 0f, 100f, 100f, true);
assertEquals(CosmeticSlot.SHOULDERS, CosmeticsHotspot.hitTest(front, 50, 25));
assertEquals(CosmeticSlot.BACK, CosmeticsHotspot.hitTest(back, 50, 25));
}
@Test
void outsideRegionMissesAllZones() {
CosmeticsHotspot[] zones = CosmeticsHotspot.forRegion(10f, 10f, 100f, 100f, false);
assertNull(CosmeticsHotspot.hitTest(zones, 5, 5));
}
}

View file

@ -0,0 +1,57 @@
/*
* This file is part of the LoVisual Client distribution.
* Copyright (c) 2026 loki5512344.
*
* Licensed under the GNU General Public License v3.0.
*/
package dev.loki.lovisual.features.module.modules.visuals.wings;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
public final class WingShapesTest {
@Test
void everyTypeResolvesToNonEmptyShape() {
for (String type : WingShapes.TYPES) {
WingShapes.Point[] shape = WingShapes.shape(type);
assertNotNull(shape, type);
assertTrue(shape.length >= 10, type);
}
// unknown type falls back to Angelic
assertSame(WingShapes.shape("Angelic"), WingShapes.shape("Nope"));
}
@Test
void allShapesUseFullPointRanges() {
for (String type : WingShapes.TYPES) {
for (WingShapes.Point p : WingShapes.shape(type)) {
assertTrue(p.alphaMul() > 0f && p.alphaMul() <= 1f, type + " " + p);
assertTrue(p.x() >= 0f, type + " " + p);
}
}
}
@Test
void ribIndicesFitShape() {
for (String type : WingShapes.TYPES) {
WingShapes.Point[] shape = WingShapes.shape(type);
for (int idx : WingShapes.ribs(shape)) {
assertTrue(idx < shape.length, type + " rib " + idx);
}
}
}
@Test
void glidePoseScalesPitchWithProgress() {
WingShapes.Pose start = WingShapes.glidePose(0f, 0f);
WingShapes.Pose end = WingShapes.glidePose(0f, 1f);
assertEquals(0f, start.pitchRotation(), 0.001f);
assertEquals(-90f, end.pitchRotation(), 0.001f);
}
}