feat(mod): GodRays, Knife melee skins, Wings Bedrock models, EnchantGlint animated styles, shader theme backdrop

- GodRays: PostProcessPass PRE_HAND, godrays.frag (radial sky blur to the sun over the scene, depth from mainDepth),
  GodRaysUniforms, GodRaysSkyMixin takes sunAngle/sunset colour from SkyRenderState; off under shaderpacks (Iris)
- Knife (Melee Skins): 33 skins from Bedrock geo/anim in assets/lovisual/knife, animator (static_idle, draw/inspect/melee/run,
  0.2 s crossfade, Catmull-Rom) with background baking, first/third-person render, sounds, mixins ItemInHand/PlayerItemInHand/Minecraft
- Wings: wingSource Procedural/Model with 14 textured Bedrock models (WingModelDef/WingModels/WingModelRenderer, BedrockClip +
  procedural angel/demon/dragon); shared Bedrock parser util/bedrock (GeoParser, GeoFaces, Channel, Clip)
- EnchantGlint: Pulse/Fire/Galaxy/Hologram/Glitch/Sparkle/Aurora styles in glint/style, coverage setting, custom animated
  texture matrix via GlintTextureMixin.styleMotion; lang en/ru for all new settings
- Themes: shader backdrop for ClickGUI (Backdrop record, 6 modes, intensity/speed/scale, ui_theme_backdrop_batch.frag with
  700 ms REVEAL/ERASE wipe, ThemeBackdrop2D in BackgroundComponent), ThemeEntry.backdrop persisted in JSON and profile codec,
  6 presets with backdrops, backdrop settings in the theme editor
- tests: GodRaysSun, GlintStyles, Knife assets/animator, WingModel, Backdrop, bedrock Channel; ./gradlew build green
This commit is contained in:
loki5512344 2026-10-05 19:55:49 +02:00
parent 6c641c4a94
commit 8bc884c297
Signed by: boba
GPG key ID: 253067914055423B
278 changed files with 36225 additions and 56 deletions

View file

@ -1,11 +1,31 @@
package dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.implement.other;
import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer;
import dev.loki.lovisual.features.theme.Themes;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.renderer.renderer2d.fx.backdrop.ThemeBackdrop2D;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.SettingsGuiPalette;
import dev.loki.lovisual.features.gui.clickgui.layout.screen.settings.render.LayoutRender2D;
public final class BackgroundComponent {
/** The theme's shader backdrop, or both sides of the wipe while the theme is changing. */
private static void renderBackdrop(float x, float y, float w, float h, float radius) {
Themes.BackdropView view = Themes.backdropView();
Themes.Theme theme = dev.loki.lovisual.features.theme.Theme.theme();
float opacity = ClickGuiRenderer.getRenderAlphaMultiplier();
Renderer2D r = Renderer2D.COLOR;
if (view.wipe() < 1.0f) {
ThemeBackdrop2D.draw(r, x, y, w, h, radius, view.outgoing(), theme.accent(), theme.textPrimary(), opacity,
ThemeBackdrop2D.Wipe.ERASE, view.wipe());
ThemeBackdrop2D.draw(r, x, y, w, h, radius, view.target(), theme.accent(), theme.textPrimary(), opacity,
ThemeBackdrop2D.Wipe.REVEAL, view.wipe());
} else {
ThemeBackdrop2D.draw(r, x, y, w, h, radius, view.target(), theme.accent(), theme.textPrimary(), opacity,
ThemeBackdrop2D.Wipe.NONE, 1.0f);
}
}
public void render(float x, float y, float w, float h, float scale) {
SettingsGuiPalette palette = SettingsGuiPalette.current();
ClickGuiRenderer.drawBlur(x, y, w, h, 8f * scale, 0xFF000000, 200f / 255f);
@ -16,6 +36,7 @@ public final class BackgroundComponent {
palette.menuWindowBgRight(),
palette.menuWindowBgLeft()
);
renderBackdrop(x, y, w, h, 8f * scale);
LayoutRender2D.roundedStroke(x, y, w, h, 8f * scale, 0.1f * scale, palette.menuWindowStroke());
}
}

View file

@ -0,0 +1,120 @@
package dev.loki.lovisual.features.module.modules.visuals.godrays;
import com.mojang.blaze3d.textures.GpuTextureView;
import dev.loki.lovisual.config.values.color.RGBColorValue;
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.render.engine.RenderState;
import dev.loki.lovisual.render.engine.core.system.LoVisualWorldMatrices;
import dev.loki.lovisual.render.engine.optimize.OptimizeState;
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
import dev.loki.lovisual.render.engine.postprocess.post.PostProcessContext;
import dev.loki.lovisual.render.engine.postprocess.post.PostProcessManager;
import dev.loki.lovisual.render.engine.postprocess.post.PostProcessPass;
import dev.loki.lovisual.render.engine.renderer.FullScreenRenderer;
import dev.loki.lovisual.render.engine.uniform.impl.godrays.GodRaysUniforms;
import dev.loki.lovisual.render.iris.runtime.IrisRuntime;
import dev.loki.lovisual.util.logging.DebugLog;
import net.minecraft.client.Minecraft;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
/**
* Sun shafts: a radial blur of the bright sky towards the sun, added on top of the scene. Skipped while a
* shaderpack renders; the sun position comes from the sky state captured by GodRaysSkyMixin.
*/
@ModuleInfo(
id = "godrays",
displayName = "God Rays",
aliases = {"godrays", "sunshafts"},
category = ModuleCategory.VISUALS
)
public final class GodRays extends Module implements PostProcessPass {
private static final float SAMPLE_WEIGHT = 1.35f;
private static final float MIN_FADE = 0.002f;
private static final int LITE_SAMPLES = 12;
private final NumberValue<Float> strength = num("godRaysStrength", "strength", 110.0f, 10.0f, 400.0f);
private final NumberValue<Float> reach = num("godRaysReach", "reach", 65.0f, 20.0f, 100.0f);
private final NumberValue<Integer> samples = num("godRaysSamples", "samples", 28, 8, 64);
private final NumberValue<Float> falloff = num("godRaysFalloff", "falloff", 96.0f, 80.0f, 100.0f);
private final RGBColorValue rayColor = colorNoAlpha("godRaysColor", "color", "#FFE2A8");
private final BooleanValue sunsetBoost = bool("godRaysSunsetBoost", "sunset_boost", true);
private final Matrix4f viewProjection = new Matrix4f();
private boolean failed;
{
PostProcessManager.register(this);
}
@Override
public void onEnable() {
failed = false;
}
@Override
public boolean isActive() {
Minecraft mc = Minecraft.getInstance();
return isEnabled() && !failed && GodRaysSun.isValid() && mc.player != null && mc.level != null
&& !IrisRuntime.isShaderpackRendererActive();
}
@Override
public int getPriority() {
return 1;
}
@Override
public Phase getPhase() {
return Phase.PRE_HAND;
}
@Override
public boolean render(GpuTextureView src, GpuTextureView dst, float tickDelta) {
return false;
}
@Override
public boolean render(PostProcessContext context, GpuTextureView src, GpuTextureView dst) {
if (!isActive() || context == null || src == null || dst == null || context.mainDepth() == null) return false;
Matrix4f projection = LoVisualWorldMatrices.renderProjectionMatrix();
if (projection == null) projection = RenderState.worldProjection;
viewProjection.set(projection).mul(new Matrix4f().rotation(new Quaternionf(RenderState.cameraRotation).conjugate()));
GodRaysSun.Screen sun = GodRaysSun.project(GodRaysSun.angle(), viewProjection);
if (sun == null) return false;
float fade = GodRaysSun.fade(sun, Minecraft.getInstance().level.getRainLevel(1.0f), sunsetBoost.get());
if (fade <= MIN_FADE) return false;
int glow = GodRaysSun.sunrise();
int ray = rayColor.getArgb();
int glowAlpha = glow >>> 24;
GodRaysUniforms.update(sun.u(), sun.v(), reach.get() * 0.01f,
OptimizeState.isLitePost() ? LITE_SAMPLES : samples.get(), SAMPLE_WEIGHT,
strength.get() * 0.01f, falloff.get() * 0.01f,
GodRaysSun.tintChannel(ray >> 16 & 0xFF, glow >> 16 & 0xFF, glowAlpha),
GodRaysSun.tintChannel(ray >> 8 & 0xFF, glow >> 8 & 0xFF, glowAlpha),
GodRaysSun.tintChannel(ray & 0xFF, glow & 0xFF, glowAlpha),
fade, context.width(), context.height());
try {
FullScreenRenderer.ensureInit();
FullScreenRenderer.begin("LoVisual GodRays Pass")
.attachment(dst)
.pipeline(LoVisualRenderPipelines.GOD_RAYS)
.uniform("GodRays", GodRaysUniforms.get())
.sampler("u_Texture", src, PostProcessManager.getSampler())
.sampler("u_MainDepth", context.mainDepth(), PostProcessManager.getSampler())
.end();
return true;
} catch (Throwable t) {
failed = true;
DebugLog.error("[LoVisual] GodRays pass failed, disabled until re-enabled", t);
return false;
}
}
}

View file

@ -0,0 +1,79 @@
package dev.loki.lovisual.features.module.modules.visuals.godrays;
import org.joml.Matrix4f;
import org.joml.Vector4f;
/** Sky state captured each frame plus the pure maths that turns it into a screen position and a fade. */
public final class GodRaysSun {
/** Sun position on screen in 0..1 uv together with how high it stands (-1..1). */
public record Screen(float u, float v, float elevation) {
}
private static final float HORIZON_FADE_START = 0.03f;
private static final float HORIZON_FADE_END = 0.2f;
private static final float OFF_SCREEN_RANGE = 0.55f;
private static final float RAIN_FADE = 0.85f;
private static final float SUNSET_GAIN = 0.8f;
private static final float SUNSET_REFERENCE = 0.45f;
private static final float WARMTH = 0.85f;
private static final float W_EPSILON = 1.0E-6f;
private static volatile float sunAngle;
private static volatile int sunriseColor;
private static volatile boolean valid;
private GodRaysSun() {
}
public static void capture(float angle, int sunriseAndSunsetColor) {
sunAngle = angle;
sunriseColor = sunriseAndSunsetColor;
valid = true;
}
public static void invalidate() {
valid = false;
}
public static boolean isValid() {
return valid;
}
public static float angle() {
return sunAngle;
}
public static int sunrise() {
return sunriseColor;
}
/** Projects the sun direction; null while it is below the horizon or behind the camera. */
public static Screen project(float angle, Matrix4f viewProjection) {
float elevation = (float) Math.cos(angle);
if (elevation <= HORIZON_FADE_START) return null;
Vector4f clip = new Vector4f(-(float) Math.sin(angle), elevation, 0.0f, 0.0f).mul(viewProjection);
if (clip.w <= W_EPSILON) return null;
return new Screen(clip.x / clip.w * 0.5f + 0.5f, clip.y / clip.w * 0.5f + 0.5f, elevation);
}
public static float fade(Screen sun, float rain, boolean sunsetBoost) {
float horizon = smoothstep(HORIZON_FADE_START, HORIZON_FADE_END, sun.elevation());
float overshoot = Math.max(Math.max(Math.abs(sun.u() - 0.5f) - 0.5f, 0.0f), Math.max(Math.abs(sun.v() - 0.5f) - 0.5f, 0.0f));
float screen = 1.0f - smoothstep(0.0f, OFF_SCREEN_RANGE, overshoot);
float weather = 1.0f - Math.max(0.0f, Math.min(rain, 1.0f)) * RAIN_FADE;
float boost = sunsetBoost ? 1.0f + SUNSET_GAIN * (1.0f - Math.min(sun.elevation() / SUNSET_REFERENCE, 1.0f)) : 1.0f;
return horizon * screen * weather * boost;
}
/** One tint channel: the ray colour pulled towards the sunrise/sunset glow while there is one. */
public static float tintChannel(int rayChannel, int glowChannel, int glowAlpha) {
float warmth = glowAlpha / 255.0f * WARMTH;
float glow = glowChannel / 255.0f;
return rayChannel / 255.0f * (1.0f - warmth + glow * warmth);
}
static float smoothstep(float from, float to, float x) {
float t = Math.max(0.0f, Math.min((x - from) / (to - from), 1.0f));
return t * t * (3.0f - 2.0f * t);
}
}

View file

@ -7,10 +7,15 @@ 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.modules.visuals.items.glint.style.GlintMotion;
import dev.loki.lovisual.features.module.modules.visuals.items.glint.style.GlintStyles;
import net.minecraft.util.Util;
import org.joml.Matrix4f;
/**
* Enchantment glint on items and armor: style (Rainbow, Solid, Gradient, Hidden), colours, strength, scroll
* speed and stripe scale. Speed and scale go through GlintTextureMixin, the look by repainting the textures.
* Enchantment glint on items and armor: eleven styles (Rainbow, Solid, Gradient, Pulse, Fire, Galaxy, Hologram,
* Glitch, Sparkle, Aurora, Hidden), colours, strength, coverage, scroll speed and stripe scale. The look is a
* repaint of the textures; speed, scale and the animated styles' motion go through GlintTextureMixin.
*/
@ModuleInfo(
id = "enchantglint",
@ -20,12 +25,15 @@ import dev.loki.lovisual.features.module.core.ModuleInfo;
)
public class EnchantGlint extends Module {
private final ModeValue style = modeSetting("enchantGlintStyle", "style", GlintTint.RAINBOW,
GlintTint.RAINBOW, GlintTint.SOLID, GlintTint.GRADIENT, GlintTint.HIDDEN);
GlintTint.RAINBOW, GlintTint.SOLID, GlintTint.GRADIENT, GlintStyles.PULSE, GlintStyles.FIRE,
GlintStyles.GALAXY, GlintStyles.HOLOGRAM, GlintStyles.GLITCH, GlintStyles.SPARKLE, GlintStyles.AURORA,
GlintTint.HIDDEN);
private final RGBColorValue color =
visibleWhen(colorNoAlpha("enchantGlintColor", "color", "#FF4FD8"), this::usesPrimary);
private final RGBColorValue secondColor =
visibleWhen(colorNoAlpha("enchantGlintColor2", "color2", "#4F9BFF"), this::usesSecondary);
private final NumberValue<Float> strength = num("enchantGlintStrength", "strength", 1.5f, 0.2f, 3.0f);
private final NumberValue<Float> coverage = num("enchantGlintCoverage", "coverage", 0.35f, 0.0f, 1.0f);
private final NumberValue<Float> speed = num("enchantGlintSpeed", "speed", 1.0f, 0.1f, 4.0f);
private final NumberValue<Float> scale = num("enchantGlintScale", "scale", 1.0f, 0.2f, 4.0f);
private final BooleanValue items = bool("enchantGlintItems", "items", true);
@ -34,17 +42,18 @@ public class EnchantGlint extends Module {
private final GlintTexture texture = new GlintTexture();
private boolean usesPrimary() {
return GlintTint.SOLID.equals(style.get()) || GlintTint.GRADIENT.equals(style.get());
String current = style.get();
return !GlintTint.RAINBOW.equals(current) && !GlintTint.HIDDEN.equals(current);
}
private boolean usesSecondary() {
return GlintTint.GRADIENT.equals(style.get());
return GlintTint.GRADIENT.equals(style.get()) || GlintStyles.usesSecondary(style.get());
}
@Override
public void onTick() {
GlintTexture.Paint paint = new GlintTexture.Paint(style.get(),
color.getArgb() & 0xFFFFFF, secondColor.getArgb() & 0xFFFFFF, strength.get());
color.getArgb() & 0xFFFFFF, secondColor.getArgb() & 0xFFFFFF, strength.get(), coverage.get());
texture.sync(items.get() ? paint : null, armor.get() ? paint : null);
}
@ -58,6 +67,12 @@ public class EnchantGlint extends Module {
return isEnabled() ? speed.get() : 1.0f;
}
/** Animated texture matrix of the current style, or null while the style keeps the vanilla motion. */
public Matrix4f motion(float scale) {
if (!isEnabled()) return null;
return GlintMotion.matrix(style.get(), Util.getMillis() * 0.001f * speed.get(), scale);
}
public float scaleFactor() {
return isEnabled() ? scale.get() : 1.0f;
}

View file

@ -21,7 +21,7 @@ import java.util.Optional;
*/
final class GlintTexture {
/** What to paint onto a glint texture; a null paint means "leave it vanilla". */
record Paint(String style, int primary, int secondary, float strength) {
record Paint(String style, int primary, int secondary, float strength, float coverage) {
}
private final Map<AbstractTexture, Paint> painted = new IdentityHashMap<>();
@ -65,7 +65,7 @@ final class GlintTexture {
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
image.setPixel(x, y, GlintTint.pixel(image.getPixel(x, y), (x + 0.5f) / width, (y + 0.5f) / height,
paint.style(), paint.primary(), paint.secondary(), paint.strength()));
paint.style(), paint.primary(), paint.secondary(), paint.strength(), paint.coverage()));
}
}
}

View file

@ -1,5 +1,6 @@
package dev.loki.lovisual.features.module.modules.visuals.items.glint;
import dev.loki.lovisual.features.module.modules.visuals.items.glint.style.GlintStyles;
import net.minecraft.util.Mth;
/** Pure pixel maths for repainting the enchantment glint texture (ARGB ints, u and v in 0..1). */
@ -15,19 +16,34 @@ public final class GlintTint {
private GlintTint() {
}
/** Colours the glint streaks in a style; the pixel's strongest channel keeps the streak shape. */
public static int pixel(int argb, float u, float v, String style, int primary, int secondary, float strength) {
return pixel(argb, u, v, style, primary, secondary, strength, 0.0f);
}
/**
* Colours the glint streaks in a style; the pixel's strongest channel keeps the streak shape and
* {@code coverage} lifts the glow between the streaks.
*/
public static int pixel(int argb, float u, float v, String style, int primary, int secondary, float strength, float coverage) {
if (HIDDEN.equals(style)) return argb & 0x00FFFFFF;
float level = Math.min(1.0f, (Math.max(argb >> 16 & 0xFF, Math.max(argb >> 8 & 0xFF, argb & 0xFF)) / 255.0f) * strength);
float streak = Math.max(argb >> 16 & 0xFF, Math.max(argb >> 8 & 0xFF, argb & 0xFF)) / 255.0f;
if (GlintStyles.handles(style)) {
return (argb & 0xFF000000) | scaled(GlintStyles.shade(style, u, v, streak, primary, secondary, coverage), strength);
}
float level = Math.min(1.0f, (coverage + (1.0f - coverage) * streak) * strength);
int color = switch (style) {
case RAINBOW -> Mth.hsvToRgb(frac(u * (1.0f - RAINBOW_SKEW) + v * RAINBOW_SKEW), 1.0f, 1.0f);
case GRADIENT -> lerp(primary, secondary, frac(u * (1.0f - GRADIENT_SKEW) + v * GRADIENT_SKEW));
default -> primary;
};
int r = Math.round((color >> 16 & 0xFF) * level);
int g = Math.round((color >> 8 & 0xFF) * level);
int b = Math.round((color & 0xFF) * level);
return (argb & 0xFF000000) | r << 16 | g << 8 | b;
return (argb & 0xFF000000) | scaled(color, level);
}
private static int scaled(int rgb, float factor) {
int r = Math.min(255, Math.round((rgb >> 16 & 0xFF) * factor));
int g = Math.min(255, Math.round((rgb >> 8 & 0xFF) * factor));
int b = Math.min(255, Math.round((rgb & 0xFF) * factor));
return r << 16 | g << 8 | b;
}
static int lerp(int from, int to, float t) {

View file

@ -0,0 +1,37 @@
package dev.loki.lovisual.features.module.modules.visuals.items.glint.style;
import org.joml.Matrix4f;
/** Texture-matrix animation of the procedural glint styles; null for styles that keep vanilla motion. */
public final class GlintMotion {
private static final float DEFAULT_ROTATION = 0.17453292f;
private GlintMotion() {
}
/** {@code seconds} is the speed-scaled clock; {@code scale} the vanilla texture scale. */
public static Matrix4f matrix(String style, float seconds, float scale) {
return switch (style) {
case GlintStyles.PULSE -> build(seconds * 0.073f, seconds * 0.267f, DEFAULT_ROTATION,
scale * (1.0f + 0.22f * (float) Math.sin(seconds * 1.6f)));
case GlintStyles.FIRE -> build(0.05f * (float) Math.sin(seconds * 0.7f), -seconds * 0.45f, 0.0f, scale);
case GlintStyles.GALAXY -> build(seconds * 0.02f, seconds * 0.015f, DEFAULT_ROTATION + seconds * 0.035f, scale);
case GlintStyles.HOLOGRAM -> build(seconds * 0.04f, seconds * 0.7f, 0.0f, scale);
case GlintStyles.GLITCH -> {
int step = (int) Math.floor(seconds * 7.0f);
yield build(GlintNoise.hash(step, 0, 64, 31), GlintNoise.hash(0, step, 64, 47), 0.0f, scale);
}
case GlintStyles.SPARKLE -> build(seconds * 0.11f, seconds * 0.07f, DEFAULT_ROTATION + seconds * 0.14f, scale);
case GlintStyles.AURORA -> build(seconds * 0.05f, seconds * 0.12f, 0.08726646f, scale);
default -> null;
};
}
private static Matrix4f build(float du, float dv, float rotation, float scale) {
return new Matrix4f().translation(-wrap(du), wrap(dv), 0.0f).rotateZ(rotation).scale(scale);
}
static float wrap(float x) {
return x - (float) Math.floor(x);
}
}

View file

@ -0,0 +1,80 @@
package dev.loki.lovisual.features.module.modules.visuals.items.glint.style;
/** Tileable procedural noise for the glint styles; every cell lookup wraps with the grid period. */
public final class GlintNoise {
private GlintNoise() {
}
/** Integer hash of a wrapped grid cell, 0..1. */
public static float hash(int x, int y, int period, int seed) {
int h = Math.floorMod(x, period) * 0x1F3D5B79 + Math.floorMod(y, period) * 0x2545F491 + seed * 0x6C8E9CF5;
h ^= h >>> 16;
h *= 0x7FEB352D;
h ^= h >>> 15;
h *= 0x846CA68B;
h ^= h >>> 16;
return (h >>> 8) * (1.0f / (1 << 24));
}
public static float smooth(float t) {
return t * t * (3.0f - 2.0f * t);
}
public static float wave(float radians) {
return 0.5f + 0.5f * (float) Math.cos(radians);
}
/** Bilinear value noise on a {@code cells} x {@code cells} wrapping grid. */
public static float value(float u, float v, int cells, int seed) {
float x = u * cells;
float y = v * cells;
int ix = (int) Math.floor(x);
int iy = (int) Math.floor(y);
float fx = smooth(x - ix);
float fy = smooth(y - iy);
float top = lerp(fx, hash(ix, iy, cells, seed), hash(ix + 1, iy, cells, seed));
float bottom = lerp(fx, hash(ix, iy + 1, cells, seed), hash(ix + 1, iy + 1, cells, seed));
return lerp(fy, top, bottom);
}
/** Fractal sum of value noise, each octave doubling the grid and halving the weight; result 0..1. */
public static float fbm(float u, float v, int octaves, int baseCells, int seed) {
float sum = 0.0f;
float weight = 1.0f;
float total = 0.0f;
int cells = baseCells;
for (int i = 0; i < octaves; i++) {
sum += value(u, v, cells, seed + i) * weight;
total += weight;
weight *= 0.5f;
cells <<= 1;
}
return sum / total;
}
/** Nearest-point glitter: one jittered point per cell with a soft round falloff; result 0..1. */
public static float sparkles(float u, float v, int cells, float radius, float density, int seed) {
float x = u * cells;
float y = v * cells;
int cx = (int) Math.floor(x);
int cy = (int) Math.floor(y);
float best = 0.0f;
for (int dy = -1; dy <= 1; dy++) {
for (int dx = -1; dx <= 1; dx++) {
int nx = cx + dx;
int ny = cy + dy;
if (hash(nx, ny, cells, seed) > density) continue;
float px = nx + 0.15f + hash(nx, ny, cells, seed + 101) * 0.7f;
float py = ny + 0.15f + hash(nx, ny, cells, seed + 202) * 0.7f;
float distance = (float) Math.hypot((x - px) / radius, (y - py) / radius);
float glow = 1.0f - distance;
if (glow > 0.0f) best = Math.max(best, glow * glow);
}
}
return best;
}
public static float lerp(float t, float a, float b) {
return a + t * (b - a);
}
}

View file

@ -0,0 +1,115 @@
package dev.loki.lovisual.features.module.modules.visuals.items.glint.style;
/**
* Colour of one glint texel for the procedural styles. {@code streak} is the vanilla texture's own brightness
* pattern (0..1), so each style keeps the familiar diagonal streaks and adds its own look on top.
*/
public final class GlintStyles {
public static final String PULSE = "Pulse";
public static final String FIRE = "Fire";
public static final String GALAXY = "Galaxy";
public static final String HOLOGRAM = "Hologram";
public static final String GLITCH = "Glitch";
public static final String SPARKLE = "Sparkle";
public static final String AURORA = "Aurora";
private static final float TAU = (float) (Math.PI * 2.0);
private GlintStyles() {
}
public static boolean handles(String style) {
return switch (style) {
case PULSE, FIRE, GALAXY, HOLOGRAM, GLITCH, SPARKLE, AURORA -> true;
default -> false;
};
}
/** True when the style blends two colours rather than using the primary alone. */
public static boolean usesSecondary(String style) {
return switch (style) {
case FIRE, GALAXY, HOLOGRAM, GLITCH, AURORA -> true;
default -> false;
};
}
/** RGB (0xRRGGBB) before strength is applied; coverage lifts the glow between streaks. */
public static int shade(String style, float u, float v, float streak, int primary, int secondary, float coverage) {
float[] color = new float[3];
float level;
switch (style) {
case PULSE -> {
set(color, primary);
level = streak * (0.15f + 0.85f * (float) Math.pow(GlintNoise.wave(TAU * u), 2.0));
}
case FIRE -> {
float heat = clamp((GlintNoise.fbm(u, v, 5, 3, 7) - 0.3f) * 2.4f);
mix(color, primary, secondary, GlintNoise.smooth(heat));
lift(color, 0.45f + 0.55f * heat, (float) Math.pow(heat, 6.0) * 0.65f);
level = 0.35f + 0.65f * streak;
}
case GALAXY -> {
float cloud = clamp((GlintNoise.fbm(u, v, 5, 3, 21) - 0.28f) * 2.4f);
mix(color, primary, secondary, (float) Math.pow(cloud, 3.0));
lift(color, 0.3f + 0.7f * cloud, GlintNoise.sparkles(u, v, 12, 0.22f, 0.35f, 33));
level = 0.4f + 0.6f * streak;
}
case HOLOGRAM -> {
float scan = (float) Math.pow(GlintNoise.wave(16.0f * TAU * v), 2.0);
mix(color, primary, secondary, GlintNoise.wave(TAU * (v * 0.6f + u * 0.15f)));
level = clamp(streak * (0.35f + 0.8f * scan));
}
case GLITCH -> {
int cx = (int) Math.floor(u * 16.0f);
int cy = (int) Math.floor(v * 16.0f);
if (GlintNoise.hash(cx, cy, 16, 11) <= 0.45f) return 0;
float pick = GlintNoise.hash(cx, cy, 16, 977);
if (pick > 0.25f) mix(color, primary, secondary, pick);
else set(color, 0xFFFFFF);
level = 0.4f + 0.6f * GlintNoise.hash(cx, cy, 16, 613);
}
case SPARKLE -> {
float glitter = GlintNoise.sparkles(u, v, 14, 0.3f, 0.55f, 5);
set(color, primary);
for (int i = 0; i < 3; i++) color[i] = 1.0f + 0.55f * (color[i] - 1.0f);
level = clamp(glitter + streak * 0.25f);
}
case AURORA -> {
float ribbon = GlintNoise.wave(TAU * (u * 2.0f + 0.22f * (float) Math.cos(TAU * v)));
mix(color, primary, secondary, ribbon);
level = clamp((float) Math.pow(ribbon, 4.0) * (0.5f + 0.7f * streak));
}
default -> {
return 0;
}
}
float lit = coverage + (1.0f - coverage) * level;
return pack(color[0] * lit, color[1] * lit, color[2] * lit);
}
private static void set(float[] out, int rgb) {
out[0] = (rgb >> 16 & 0xFF) / 255.0f;
out[1] = (rgb >> 8 & 0xFF) / 255.0f;
out[2] = (rgb & 0xFF) / 255.0f;
}
private static void mix(float[] out, int primary, int secondary, float t) {
float[] a = new float[3];
float[] b = new float[3];
set(a, primary);
set(b, secondary);
for (int i = 0; i < 3; i++) out[i] = b[i] + t * (a[i] - b[i]);
}
private static void lift(float[] color, float body, float extra) {
for (int i = 0; i < 3; i++) color[i] = clamp(color[i] * body + extra);
}
private static float clamp(float x) {
return Math.max(0.0f, Math.min(1.0f, x));
}
private static int pack(float r, float g, float b) {
return Math.round(clamp(r) * 255.0f) << 16 | Math.round(clamp(g) * 255.0f) << 8 | Math.round(clamp(b) * 255.0f);
}
}

View file

@ -0,0 +1,134 @@
package dev.loki.lovisual.features.module.modules.visuals.knife;
import com.mojang.blaze3d.vertex.PoseStack;
import dev.loki.lovisual.config.values.mode.BindMode;
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.modules.visuals.knife.anim.KnifeAnimator;
import dev.loki.lovisual.features.module.modules.visuals.knife.model.KnifeModel;
import dev.loki.lovisual.features.module.modules.visuals.knife.model.KnifeModels;
import dev.loki.lovisual.features.module.modules.visuals.knife.render.KnifeRenderer;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.tags.ItemTags;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.entity.HumanoidArm;
import net.minecraft.world.item.Item;
import net.minecraft.world.item.ItemStack;
import java.util.random.RandomGenerator;
/**
* Replaces the sword in your hand with an animated knife skin: first person view model with draw, inspect,
* attack and run animations and your own arms, a resting knife in the third person hand, and the skin's sounds.
* Hooks: KnifeItemInHandMixin, KnifeHandLayerMixin, KnifeInputMixin.
*/
@ModuleInfo(
id = "knife",
displayName = "Knife",
aliases = {"knife", "meleeskins"},
category = ModuleCategory.VISUALS
)
public final class Knife extends Module {
private static final String ACTION_INSPECT = "inspect";
private static final float MAX_DELTA_SECONDS = 0.25f;
private final ModeValue skin = modeSetting("knifeSkin", "skin", defaultSkin(), KnifeSkin.KEYS);
private final BooleanValue firstPerson = bool("knifeFirstPerson", "first_person", true);
private final BooleanValue thirdPerson = bool("knifeThirdPerson", "third_person", true);
private final BooleanValue sounds = bool("knifeSounds", "sounds", true);
private final NumberValue<Float> volume = visibleWhen(num("knifeVolume", "volume", 0.8f, 0.0f, 1.0f), sounds::get);
private final KnifeAnimator animator = new KnifeAnimator(KnifeSounds.INSTANCE, RandomGenerator.getDefault());
private final KnifeRenderer renderer = new KnifeRenderer();
private Item heldItem;
private int heldSlot = -1;
private long lastNanos;
{
action(ACTION_INSPECT, "R", BindMode.PRESS);
}
private static String defaultSkin() {
return KnifeSkin.KEYS.length == 0 ? "" : java.util.Arrays.asList(KnifeSkin.KEYS).contains("karambit") ? "karambit" : KnifeSkin.KEYS[0];
}
boolean soundsEnabled() {
return sounds.get();
}
float soundVolume() {
return volume.get();
}
@Override
public void onTick() {
if (isActionPressedOnce(ACTION_INSPECT) && isEnabled()) animator.onInspect();
}
@Override
public void onDisable() {
animator.reset();
heldItem = null;
heldSlot = -1;
}
public static boolean isKnife(ItemStack stack) {
return stack != null && stack.is(ItemTags.SWORDS);
}
/** Draws the view model for the main hand; false leaves the hand to vanilla. */
public boolean renderFirstPerson(AbstractClientPlayer player, InteractionHand hand, ItemStack item, PoseStack stack,
SubmitNodeCollector collector, int light, float equipProgress) {
if (!isEnabled() || !firstPerson.get() || hand != InteractionHand.MAIN_HAND || !isKnife(item)) return false;
KnifeSkin current = KnifeSkin.byKey(skin.get());
KnifeModel model = current == null ? null : KnifeModels.get(current.model());
if (model == null) return false;
if (!animator.isBoundTo(current.key())) animator.bind(model, current.key(), current.variants());
if (heldChanged(player)) animator.restart();
animator.update(deltaSeconds(), new KnifeAnimator.Motion(player.isSprinting(), player.onGround(), player.moveDist));
float retire = isKnife(player.getMainHandItem()) ? 0.0f : equipProgress;
renderer.firstPerson(player, current, model, animator, stack, collector, light, retire);
return true;
}
/** Draws the resting knife in the third person hand of your own player. */
public boolean renderThirdPerson(ItemStack item, HumanoidArm arm, PoseStack stack, SubmitNodeCollector collector, int light) {
if (!isEnabled() || !thirdPerson.get() || !isKnife(item)) return false;
KnifeSkin current = KnifeSkin.byKey(skin.get());
KnifeModel model = current == null ? null : KnifeModels.get(current.model());
if (model == null) return false;
renderer.thirdPerson(current, model, arm, stack, collector, light);
return true;
}
/** A click with the main hand holding a knife; the secondary click plays the third attack. */
public void onAttack(boolean primary) {
Minecraft mc = Minecraft.getInstance();
if (isEnabled() && firstPerson.get() && mc.player != null && isKnife(mc.player.getMainHandItem())) {
animator.onAttack(primary);
}
}
private boolean heldChanged(AbstractClientPlayer player) {
Item item = player.getMainHandItem().getItem();
int slot = player.getInventory().getSelectedSlot();
boolean changed = item != heldItem || slot != heldSlot;
heldItem = item;
heldSlot = slot;
return changed;
}
private float deltaSeconds() {
long now = System.nanoTime();
float delta = lastNanos == 0L ? 0.0f : (now - lastNanos) / 1.0E9f;
lastNanos = now;
return Minecraft.getInstance().isPaused() ? 0.0f : Math.max(0.0f, Math.min(delta, MAX_DELTA_SECONDS));
}
}

View file

@ -0,0 +1,95 @@
package dev.loki.lovisual.features.module.modules.visuals.knife;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.types.KnifeVariant;
import net.minecraft.resources.Identifier;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
/**
* One entry of the knife catalog ({@code assets/lovisual/knife/index.json}). Recolours share the geometry
* of {@code model}; {@code holyBones} get the holy shader; the transforms place the knife in a third person hand.
*/
public record KnifeSkin(String key, String name, String model, List<String> holyBones, List<KnifeVariant> variants,
Transform thirdRight, Transform thirdLeft) {
/** Display-style placement in pixels and degrees. */
public record Transform(float[] rotation, float[] translation, float[] scale) {
public static final Transform NONE = new Transform(new float[3], new float[3], new float[]{1, 1, 1});
}
private static final String INDEX = "/assets/lovisual/knife/index.json";
public static final List<KnifeSkin> ALL = load();
public static final String[] KEYS = ALL.stream().map(KnifeSkin::key).toArray(String[]::new);
public static KnifeSkin byKey(String key) {
for (KnifeSkin skin : ALL) {
if (skin.key.equals(key)) return skin;
}
return ALL.isEmpty() ? null : ALL.get(0);
}
public Identifier texture() {
return Identifier.fromNamespaceAndPath("lovisual", "textures/knife/" + key + ".png");
}
static List<KnifeSkin> load() {
try (InputStream in = KnifeSkin.class.getResourceAsStream(INDEX)) {
return in == null ? List.of() : parse(new String(in.readAllBytes(), StandardCharsets.UTF_8));
} catch (IOException | RuntimeException e) {
return List.of();
}
}
public static List<KnifeSkin> parse(String json) {
List<KnifeSkin> out = new ArrayList<>();
for (JsonElement entry : JsonParser.parseString(json).getAsJsonObject().getAsJsonArray("melee")) {
JsonObject data = entry.getAsJsonObject();
String key = data.get("key").getAsString();
JsonObject transforms = data.has("transforms") ? data.getAsJsonObject("transforms") : new JsonObject();
out.add(new KnifeSkin(key, data.get("name").getAsString(),
data.has("model") ? data.get("model").getAsString() : key,
strings(data.get("holyBones")), variants(data.get("variants")),
transform(transforms.get("thirdperson_righthand")), transform(transforms.get("thirdperson_lefthand"))));
}
return out;
}
private static List<KnifeVariant> variants(JsonElement element) {
List<KnifeVariant> out = new ArrayList<>();
if (element == null || !element.isJsonArray()) return out;
for (JsonElement entry : element.getAsJsonArray()) {
JsonObject data = entry.getAsJsonObject();
out.add(new KnifeVariant(data.has("suffix") ? data.get("suffix").getAsString() : "", strings(data.get("hidden"))));
}
return out;
}
private static Transform transform(JsonElement element) {
if (element == null || !element.isJsonObject()) return Transform.NONE;
JsonObject data = element.getAsJsonObject();
return new Transform(vec(data.get("rotation"), 0), vec(data.get("translation"), 0), vec(data.get("scale"), 1));
}
private static float[] vec(JsonElement element, float fallback) {
float[] out = {fallback, fallback, fallback};
if (element == null || !element.isJsonArray()) return out;
JsonArray array = element.getAsJsonArray();
for (int i = 0; i < Math.min(3, array.size()); i++) out[i] = array.get(i).getAsFloat();
return out;
}
private static List<String> strings(JsonElement element) {
List<String> out = new ArrayList<>();
if (element == null || !element.isJsonArray()) return out;
for (JsonElement entry : element.getAsJsonArray()) out.add(entry.getAsString());
return out;
}
}

View file

@ -0,0 +1,42 @@
package dev.loki.lovisual.features.module.modules.visuals.knife;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.types.KnifeSoundSink;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.resources.sounds.SimpleSoundInstance;
import net.minecraft.client.resources.sounds.SoundInstance;
import net.minecraft.resources.Identifier;
import net.minecraft.sounds.SoundEvent;
import net.minecraft.sounds.SoundSource;
import net.minecraft.util.RandomSource;
/** Plays the sound events of knife clips (registered in {@code assets/lovisual/sounds.json}) at the player. */
final class KnifeSounds implements KnifeSoundSink {
static final KnifeSounds INSTANCE = new KnifeSounds();
private KnifeSounds() {
}
@Override
public Object play(String effect) {
Knife knife = Modules.get(Knife.class);
LocalPlayer player = Minecraft.getInstance().player;
if (knife == null || player == null || !knife.soundsEnabled()) return null;
SoundEvent event = SoundEvent.createVariableRangeEvent(Identifier.fromNamespaceAndPath("lovisual", effect));
SimpleSoundInstance sound = new SimpleSoundInstance(event, SoundSource.PLAYERS, knife.soundVolume(), 1.0f,
RandomSource.create(), player.getX(), player.getY(), player.getZ());
Minecraft.getInstance().getSoundManager().play(sound);
return sound;
}
@Override
public boolean active(Object handle) {
return handle instanceof SoundInstance sound && Minecraft.getInstance().getSoundManager().isActive(sound);
}
@Override
public void stop(Object handle) {
if (handle instanceof SoundInstance sound) Minecraft.getInstance().getSoundManager().stop(sound);
}
}

View file

@ -0,0 +1,250 @@
package dev.loki.lovisual.features.module.modules.visuals.knife.anim;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.types.ClipSource;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.types.KnifeSoundSink;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.types.KnifeVariant;
import java.util.Arrays;
import java.util.List;
import java.util.random.RandomGenerator;
/**
* The view-model animation of one hand: a looping idle underneath, one-shot actions (draw, inspect, attacks) on
* top of it, and an additive run cycle. Switching clips cross-fades from the pose that was showing.
*/
public final class KnifeAnimator {
/** What the animator reads from the player each frame. */
public record Motion(boolean sprinting, boolean onGround, float moveDist) {
}
private static final float FADE_ACTION = 0.2f;
private static final float FADE_RUN_START = 0.2f;
private static final float FADE_RUN_LOOP = 0.2f;
private static final float FADE_RUN_END = 0.3f;
private static final float RUN_CYCLE_DISTANCE = 2.0f;
private static final int PRIMARY_ATTACK_VARIANTS = 2;
private static final int STRIDE = KnifeClip.STRIDE;
private enum Main {START, IDLE}
private enum Run {IDLE, RUNNING}
private final KnifeTrack base;
private final KnifeTrack main;
private final KnifeTrack movement;
private final RandomGenerator random;
private ClipSource model;
private String modelKey;
private KnifeVariant variant = KnifeVariant.DEFAULT;
private Main mainState = Main.START;
private Run runState = Run.IDLE;
private boolean runCycling;
private float runAnchor;
private int attackIndex;
private float[] staticPose = new float[0];
private float[] additivePose = new float[0];
private float[] staticFade = new float[0];
private float[] additiveFade = new float[0];
private float[] composed = new float[0];
private boolean[] hidden = new boolean[0];
private float staticFadeRemaining;
private float staticFadeDuration;
private float additiveFadeRemaining;
private float additiveFadeDuration;
public KnifeAnimator(KnifeSoundSink sounds, RandomGenerator random) {
this.base = new KnifeTrack(sounds);
this.main = new KnifeTrack(sounds);
this.movement = new KnifeTrack(sounds);
this.random = random;
}
public boolean isBoundTo(String key) {
return model != null && key.equals(modelKey);
}
public float[] pose() {
return composed;
}
public boolean[] hidden() {
return hidden;
}
public String variantSuffix() {
return variant.suffix();
}
public void reset() {
model = null;
modelKey = null;
base.clear();
restart();
}
/** Starts over with the draw clip, as after picking the knife up. */
public void restart() {
mainState = Main.START;
runState = Run.IDLE;
runCycling = false;
attackIndex = 0;
main.clear();
movement.clear();
staticFadeRemaining = 0.0f;
additiveFadeRemaining = 0.0f;
}
public void bind(ClipSource source, String key, List<KnifeVariant> variants) {
model = source;
modelKey = key;
int size = source.boneCount() * STRIDE;
staticPose = new float[size];
additivePose = new float[size];
staticFade = new float[size];
additiveFade = new float[size];
composed = new float[size];
hidden = new boolean[source.boneCount()];
variant = variants.isEmpty() ? KnifeVariant.DEFAULT : variants.get(random.nextInt(variants.size()));
source.markBranches(variant.hidden(), hidden);
restart();
base.play(source.clip(named("static_idle")), KnifeTrack.Mode.LOOP);
}
public void onAttack(boolean primary) {
if (model == null || mainState != Main.IDLE) return;
if (primary) {
playMain(named("melee_" + (attackIndex + 1)), FADE_ACTION);
attackIndex = (attackIndex + 1) % PRIMARY_ATTACK_VARIANTS;
} else {
playMain(named("melee_3"), FADE_ACTION);
}
}
public void onInspect() {
if (model == null || mainState != Main.IDLE) return;
String alternate = named("inspect_1");
playMain(model.hasClip(alternate) && random.nextBoolean() ? alternate : named("inspect"), FADE_ACTION);
}
/** Advances the clocks by {@code seconds} and recomposes the pose. */
public void update(float seconds, Motion motion) {
if (model == null) return;
base.advance(seconds);
main.advance(seconds);
movement.advance(seconds);
staticFadeRemaining = Math.max(0.0f, staticFadeRemaining - seconds);
additiveFadeRemaining = Math.max(0.0f, additiveFadeRemaining - seconds);
updateMain();
updateMovement(motion);
compose();
}
private void updateMain() {
if (mainState != Main.START) return;
String alternate = named("draw_1");
playMain(model.hasClip(alternate) && random.nextBoolean() ? alternate : named("draw"), 0.0f);
mainState = Main.IDLE;
}
private void updateMovement(Motion motion) {
boolean canRun = model.hasClip(named("run_start")) && motion.sprinting() && motion.onGround() && main.isStopped();
if (runState == Run.IDLE) {
if (!canRun) return;
runState = Run.RUNNING;
runCycling = false;
playMovement(named("run_start"), KnifeTrack.Mode.ONCE_HOLD, FADE_RUN_START);
return;
}
if (!canRun) {
runState = Run.IDLE;
runCycling = false;
playMovement(named("run_end"), KnifeTrack.Mode.ONCE_HOLD, FADE_RUN_END);
return;
}
if (!runCycling) {
if (!movement.isHolding()) return;
runCycling = true;
runAnchor = motion.moveDist();
playMovement(named("run"), KnifeTrack.Mode.LOOP, FADE_RUN_LOOP);
}
float travelled = (motion.moveDist() - runAnchor) % RUN_CYCLE_DISTANCE;
if (travelled < 0.0f) travelled += RUN_CYCLE_DISTANCE;
movement.setProgress(travelled / RUN_CYCLE_DISTANCE);
}
private void playMain(String clip, float fade) {
KnifeClip next = model.clip(clip);
if (next == null) return;
beginStaticFade(fade);
main.play(next, KnifeTrack.Mode.ONCE_STOP);
}
private void playMovement(String clip, KnifeTrack.Mode mode, float fade) {
KnifeClip next = model.clip(clip);
if (next == null) {
movement.clear();
return;
}
beginAdditiveFade(fade);
movement.play(next, mode);
}
private void beginStaticFade(float duration) {
if (duration <= 0.0f) {
staticFadeRemaining = 0.0f;
return;
}
System.arraycopy(staticPose, 0, staticFade, 0, staticPose.length);
staticFadeDuration = duration;
staticFadeRemaining = duration;
}
private void beginAdditiveFade(float duration) {
if (duration <= 0.0f) {
additiveFadeRemaining = 0.0f;
return;
}
System.arraycopy(additivePose, 0, additiveFade, 0, additivePose.length);
additiveFadeDuration = duration;
additiveFadeRemaining = duration;
}
private void compose() {
neutral(staticPose);
base.evaluate(staticPose);
if (main.isActive()) main.evaluate(staticPose);
blend(staticPose, staticFade, staticFadeRemaining, staticFadeDuration);
neutral(additivePose);
if (movement.isActive() || movement.isHolding()) movement.evaluate(additivePose);
blend(additivePose, additiveFade, additiveFadeRemaining, additiveFadeDuration);
for (int at = 0; at + STRIDE <= composed.length; at += STRIDE) {
for (int k = 0; k < 6; k++) composed[at + k] = staticPose[at + k] + additivePose[at + k];
for (int k = 6; k < STRIDE; k++) composed[at + k] = staticPose[at + k] * additivePose[at + k];
}
}
static void neutral(float[] pose) {
Arrays.fill(pose, 0.0f);
for (int at = KnifeClip.SCALE; at + 3 <= pose.length; at += STRIDE) {
pose[at] = 1.0f;
pose[at + 1] = 1.0f;
pose[at + 2] = 1.0f;
}
}
/** Pulls {@code pose} back towards {@code from} while a fade is running. */
static void blend(float[] pose, float[] from, float remaining, float duration) {
if (duration <= 0.0f || remaining <= 0.0f) return;
float t = 1.0f - remaining / duration;
for (int i = 0; i < pose.length; i++) pose[i] = from[i] + (pose[i] - from[i]) * t;
}
private String named(String clip) {
String suffixed = clip + variant.suffix();
return !variant.suffix().isEmpty() && model != null && model.hasClip(suffixed) ? suffixed : clip;
}
}

View file

@ -0,0 +1,58 @@
package dev.loki.lovisual.features.module.modules.visuals.knife.anim;
import dev.loki.lovisual.util.bedrock.anim.Channel;
import dev.loki.lovisual.util.bedrock.anim.Clip;
import java.util.Map;
/**
* A clip bound to a model: bone names are resolved to indices once. A pose holds nine floats per bone:
* rotation (degrees), position (pixels), scale.
*/
public final class KnifeClip {
public static final int STRIDE = 9;
public static final int POSITION = 3;
public static final int SCALE = 6;
private final Clip clip;
private final int[] bones;
private final Clip.Tracks[] tracks;
public KnifeClip(Clip clip, Map<String, Integer> boneIndex) {
this.clip = clip;
int count = 0;
int[] indices = new int[clip.bones().size()];
Clip.Tracks[] resolved = new Clip.Tracks[indices.length];
for (Map.Entry<String, Clip.Tracks> entry : clip.bones().entrySet()) {
Integer index = boneIndex.get(entry.getKey());
if (index == null) continue;
indices[count] = index;
resolved[count++] = entry.getValue();
}
this.bones = java.util.Arrays.copyOf(indices, count);
this.tracks = java.util.Arrays.copyOf(resolved, count);
}
public Clip clip() {
return clip;
}
public float length() {
return clip.length();
}
/** Overwrites the channels this clip animates; every other bone keeps what {@code pose} already holds. */
public void evaluate(float time, float[] pose) {
for (int i = 0; i < bones.length; i++) {
int at = bones[i] * STRIDE;
if (at + STRIDE > pose.length) continue;
Clip.Tracks t = tracks[i];
Channel rotation = t.rotation();
Channel position = t.position();
Channel scale = t.scale();
if (rotation != null) rotation.sample(time, pose, at);
if (position != null) position.sample(time, pose, at + POSITION);
if (scale != null) scale.sample(time, pose, at + SCALE);
}
}
}

View file

@ -0,0 +1,109 @@
package dev.loki.lovisual.features.module.modules.visuals.knife.anim;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.types.KnifeSoundSink;
import java.util.ArrayList;
import java.util.List;
/** Plays one clip at a time: looping, once and stopping, or once and holding the last pose. */
public final class KnifeTrack {
public enum Mode {LOOP, ONCE_STOP, ONCE_HOLD}
private final KnifeSoundSink sounds;
private final List<Object> playing = new ArrayList<>();
private KnifeClip clip;
private Mode mode = Mode.ONCE_STOP;
private float time;
private int soundCursor;
private boolean stopped = true;
public KnifeTrack(KnifeSoundSink sounds) {
this.sounds = sounds;
}
public void play(KnifeClip next, Mode how) {
silence();
clip = next;
mode = how;
time = 0.0f;
soundCursor = 0;
stopped = next == null;
}
public void clear() {
play(null, Mode.ONCE_STOP);
}
public boolean isActive() {
return clip != null && !stopped;
}
/** True once a hold-mode clip has reached its end and keeps showing the last pose. */
public boolean isHolding() {
return clip != null && mode == Mode.ONCE_HOLD && time >= clip.length();
}
public boolean isStopped() {
return stopped || clip == null;
}
public float time() {
return time;
}
/** Jumps to a fraction of the clip, used to tie a run cycle to the distance walked. */
public void setProgress(float fraction) {
if (clip != null && clip.length() > 0.0f) time = Math.max(0.0f, Math.min(fraction, 1.0f)) * clip.length();
}
public void evaluate(float[] pose) {
if (clip != null) clip.evaluate(time, pose);
}
public void advance(float seconds) {
if (clip == null || stopped) return;
float length = clip.length();
if (length <= 0.0f) {
fire(0.0f);
stopped = mode != Mode.ONCE_HOLD;
return;
}
float next = time + seconds;
if (next < length) {
time = next;
fire(next);
return;
}
fire(length);
switch (mode) {
case LOOP -> {
time = next % length;
soundCursor = 0;
fire(time);
}
case ONCE_HOLD -> time = length;
case ONCE_STOP -> {
time = length;
stopped = true;
}
}
}
private void fire(float until) {
if (clip == null) return;
var source = clip.clip();
while (soundCursor < source.soundCount() && source.soundTime(soundCursor) <= until) {
Object handle = sounds.play(source.soundName(soundCursor));
if (handle != null) {
playing.removeIf(h -> !sounds.active(h));
playing.add(handle);
}
soundCursor++;
}
}
private void silence() {
for (Object handle : playing) sounds.stop(handle);
playing.clear();
}
}

View file

@ -0,0 +1,17 @@
package dev.loki.lovisual.features.module.modules.visuals.knife.anim.types;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.KnifeClip;
/** What the animator needs from a model: its clips by name and its bone count. */
public interface ClipSource {
KnifeClip clip(String name);
int boneCount();
default boolean hasClip(String name) {
return clip(name) != null;
}
/** Marks the named bones and everything below them in {@code flags}. */
void markBranches(java.util.List<String> names, boolean[] flags);
}

View file

@ -0,0 +1,27 @@
package dev.loki.lovisual.features.module.modules.visuals.knife.anim.types;
/** Where animation sound events go; handles let a track silence what it started when its clip is replaced. */
public interface KnifeSoundSink {
KnifeSoundSink SILENT = new KnifeSoundSink() {
@Override
public Object play(String effect) {
return null;
}
@Override
public boolean active(Object handle) {
return false;
}
@Override
public void stop(Object handle) {
}
};
/** Starts the sound event and returns a handle, or null when nothing plays. */
Object play(String effect);
boolean active(Object handle);
void stop(Object handle);
}

View file

@ -0,0 +1,8 @@
package dev.loki.lovisual.features.module.modules.visuals.knife.anim.types;
import java.util.List;
/** A look of one skin: clips carry {@code suffix} and the {@code hidden} bone branches are not drawn. */
public record KnifeVariant(String suffix, List<String> hidden) {
public static final KnifeVariant DEFAULT = new KnifeVariant("", List.of());
}

View file

@ -0,0 +1,206 @@
package dev.loki.lovisual.features.module.modules.visuals.knife.model;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.KnifeClip;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.types.ClipSource;
import dev.loki.lovisual.util.bedrock.anim.Clip;
import dev.loki.lovisual.util.bedrock.geo.GeoBone;
import dev.loki.lovisual.util.bedrock.geo.GeoCube;
import dev.loki.lovisual.util.bedrock.geo.GeoParser;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* A knife view model ready to draw: bones in parent-first order with pivots (pixels, entity frame) and rest
* rotations, the geometry of every bone baked to quads in blocks, and the model's clips. Special bones are
* found by name: {@code righthand_pos}, {@code lefthand_pos}, {@code camera}, {@code idle_view} and
* {@code thirdperson_hand}.
*/
public final class KnifeModel implements ClipSource {
public static final int FLOATS_PER_VERTEX = 8;
public static final int VERTICES_PER_FACE = 4;
private final String[] names;
private final int[] parents;
private final float[] pivots;
private final float[] rest;
private final float[][] meshes;
private final Map<String, Integer> index;
private final Map<String, KnifeClip> clips;
private KnifeModel(String[] names, int[] parents, float[] pivots, float[] rest, float[][] meshes,
Map<String, Integer> index, Map<String, KnifeClip> clips) {
this.names = names;
this.parents = parents;
this.pivots = pivots;
this.rest = rest;
this.meshes = meshes;
this.index = index;
this.clips = clips;
}
public static KnifeModel bake(String geometryJson, String animationJson) {
GeoParser.Model geo = GeoParser.parse(geometryJson);
List<GeoBone> ordered = new ArrayList<>();
for (GeoBone root : geo.roots()) collect(root, ordered);
int count = ordered.size();
Map<String, Integer> index = new HashMap<>();
for (int i = 0; i < count; i++) index.put(ordered.get(i).name, i);
Map<GeoBone, GeoBone> parentOf = new HashMap<>();
for (GeoBone bone : ordered) {
for (GeoBone child : bone.children) parentOf.put(child, bone);
}
String[] names = new String[count];
int[] parents = new int[count];
float[] pivots = new float[count * 3];
float[] rest = new float[count * 3];
float[][] meshes = new float[count][];
for (int i = 0; i < count; i++) {
GeoBone bone = ordered.get(i);
names[i] = bone.name;
GeoBone parent = parentOf.get(bone);
parents[i] = parent == null ? -1 : index.get(parent.name);
System.arraycopy(bone.pivot, 0, pivots, i * 3, 3);
System.arraycopy(bone.baseRot, 0, rest, i * 3, 3);
meshes[i] = mesh(bone, geo.textureWidth(), geo.textureHeight());
}
Map<String, KnifeClip> clips = new HashMap<>();
for (Map.Entry<String, Clip> entry : Clip.parseAll(animationJson).entrySet()) {
clips.put(entry.getKey(), new KnifeClip(entry.getValue(), index));
}
return new KnifeModel(names, parents, pivots, rest, meshes, Map.copyOf(index), clips);
}
private static void collect(GeoBone bone, List<GeoBone> out) {
out.add(bone);
for (GeoBone child : bone.children) collect(child, out);
}
/** Quads in blocks, each vertex: position (3), uv (2), normal (3); the cube's own rotation is baked in. */
private static float[] mesh(GeoBone bone, int textureWidth, int textureHeight) {
int faces = 0;
for (GeoCube cube : bone.cubes) faces += cube.faces().size();
if (faces == 0) return null;
float[] out = new float[faces * VERTICES_PER_FACE * FLOATS_PER_VERTEX];
int at = 0;
Matrix4f matrix = new Matrix4f();
Vector3f[] corner = {new Vector3f(), new Vector3f(), new Vector3f(), new Vector3f()};
Vector3f edgeRight = new Vector3f();
Vector3f edgeDown = new Vector3f();
for (GeoCube cube : bone.cubes) {
cube.transform(matrix.identity());
for (float[] face : cube.faces()) {
for (int i = 0; i < 4; i++) {
corner[i].set(face[i * 5] / 16.0f, face[i * 5 + 1] / 16.0f, face[i * 5 + 2] / 16.0f);
matrix.transformPosition(corner[i]);
}
corner[1].sub(corner[0], edgeRight);
corner[3].sub(corner[0], edgeDown);
Vector3f normal = edgeDown.cross(edgeRight, new Vector3f());
if (normal.lengthSquared() > 0.0f) normal.normalize();
for (int i = 0; i < 4; i++) {
out[at++] = corner[i].x;
out[at++] = corner[i].y;
out[at++] = corner[i].z;
out[at++] = face[i * 5 + 3] / textureWidth;
out[at++] = face[i * 5 + 4] / textureHeight;
out[at++] = normal.x;
out[at++] = normal.y;
out[at++] = normal.z;
}
}
}
return out;
}
@Override
public int boneCount() {
return names.length;
}
@Override
public KnifeClip clip(String name) {
return clips.get(name);
}
@Override
public void markBranches(List<String> boneNames, boolean[] flags) {
java.util.Arrays.fill(flags, false);
for (String name : boneNames) {
Integer bone = index.get(name);
if (bone != null) flags[bone] = true;
}
for (int i = 0; i < flags.length; i++) {
if (parents[i] >= 0 && flags[parents[i]]) flags[i] = true;
}
}
public String name(int bone) {
return names[bone];
}
public int parentOf(int bone) {
return parents[bone];
}
public float pivotX(int bone) {
return pivots[bone * 3];
}
public float pivotY(int bone) {
return pivots[bone * 3 + 1];
}
public float pivotZ(int bone) {
return pivots[bone * 3 + 2];
}
public float restX(int bone) {
return rest[bone * 3];
}
public float restY(int bone) {
return rest[bone * 3 + 1];
}
public float restZ(int bone) {
return rest[bone * 3 + 2];
}
/** Quads of one bone, or null when it has no geometry. */
public float[] meshOf(int bone) {
return meshes[bone];
}
public int bone(String name) {
return index.getOrDefault(name, -1);
}
public int rightHand() {
return bone("righthand_pos");
}
public int leftHand() {
return bone("lefthand_pos");
}
public int camera() {
return bone("camera");
}
/** The bone that sits at the player's eye; falls back to the camera bone like the reference does. */
public int viewAnchor() {
int anchor = bone("idle_view");
return anchor >= 0 ? anchor : camera();
}
public int thirdPersonHand() {
return bone("thirdperson_hand");
}
}

View file

@ -0,0 +1,55 @@
package dev.loki.lovisual.features.module.modules.visuals.knife.model;
import dev.loki.lovisual.util.logging.DebugLog;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/** Loads and bakes knife models on a background thread; {@link #get} returns null until a model is ready. */
public final class KnifeModels {
private static final String ROOT = "/assets/lovisual/knife/";
private static final Map<String, KnifeModel> READY = new ConcurrentHashMap<>();
private static final Set<String> PENDING = ConcurrentHashMap.newKeySet();
private static final Set<String> FAILED = ConcurrentHashMap.newKeySet();
private KnifeModels() {
}
public static KnifeModel get(String modelKey) {
KnifeModel ready = READY.get(modelKey);
if (ready != null || FAILED.contains(modelKey)) return ready;
if (PENDING.add(modelKey)) {
Thread loader = new Thread(() -> load(modelKey), "lovisual-knife-loader");
loader.setDaemon(true);
loader.start();
}
return null;
}
public static void clear() {
READY.clear();
FAILED.clear();
}
private static void load(String modelKey) {
try {
READY.put(modelKey, KnifeModel.bake(read(modelKey + ".geo.json"), read(modelKey + ".anim.json")));
} catch (IOException | RuntimeException e) {
FAILED.add(modelKey);
DebugLog.warn("[LoVisual] Knife model '" + modelKey + "' failed to load: " + e);
} finally {
PENDING.remove(modelKey);
}
}
private static String read(String file) throws IOException {
try (InputStream in = KnifeModels.class.getResourceAsStream(ROOT + file)) {
if (in == null) throw new IOException("missing resource " + file);
return new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
}
}

View file

@ -0,0 +1,64 @@
package dev.loki.lovisual.features.module.modules.visuals.knife.render;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.KnifeClip;
import dev.loki.lovisual.features.module.modules.visuals.knife.model.KnifeModel;
import org.joml.Matrix4f;
import org.joml.Quaternionf;
/** World matrices of a posed knife model, one per bone, plus which bones are drawn. */
final class KnifeBones {
private static final float DEGREES = (float) (Math.PI / 180.0);
Matrix4f[] world = new Matrix4f[0];
boolean[] visible = new boolean[0];
private final Matrix4f local = new Matrix4f();
private final Quaternionf rotation = new Quaternionf();
/**
* Poses every bone. {@code pose} holds nine floats per bone (see {@link KnifeClip}); a null pose is the
* model at rest. Bones flagged in {@code hidden} and the two hand marker bones are not drawn.
*/
void build(KnifeModel model, float[] pose, boolean[] hidden) {
int count = model.boneCount();
ensure(count);
for (int i = 0; i < count; i++) {
visible[i] = hidden == null || i >= hidden.length || !hidden[i];
int at = i * KnifeClip.STRIDE;
boolean posed = pose != null && at + KnifeClip.STRIDE <= pose.length;
float rx = model.restX(i) - (posed ? pose[at] * DEGREES : 0.0f);
float ry = model.restY(i) - (posed ? pose[at + 1] * DEGREES : 0.0f);
float rz = model.restZ(i) + (posed ? pose[at + 2] * DEGREES : 0.0f);
local.identity();
if (posed) local.translate(-pose[at + 3] / 16.0f, pose[at + 4] / 16.0f, pose[at + 5] / 16.0f);
float px = model.pivotX(i) / 16.0f;
float py = model.pivotY(i) / 16.0f;
float pz = model.pivotZ(i) / 16.0f;
local.translate(px, py, pz);
local.rotate(rotation.rotationZ(rz).rotateY(ry).rotateX(rx));
if (posed) local.scale(pose[at + 6], pose[at + 7], pose[at + 8]);
local.translate(-px, -py, -pz);
int parent = model.parentOf(i);
if (parent < 0) world[i].set(local);
else world[i].set(world[parent]).mul(local);
}
hideHands(model);
}
private void hideHands(KnifeModel model) {
int right = model.rightHand();
int left = model.leftHand();
if (right >= 0) visible[right] = false;
if (left >= 0) visible[left] = false;
}
private void ensure(int count) {
if (world.length >= count) return;
Matrix4f[] grown = new Matrix4f[count];
System.arraycopy(world, 0, grown, 0, world.length);
for (int i = world.length; i < count; i++) grown[i] = new Matrix4f();
world = grown;
visible = new boolean[count];
}
}

View file

@ -0,0 +1,48 @@
package dev.loki.lovisual.features.module.modules.visuals.knife.render;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.blaze3d.vertex.VertexConsumer;
import dev.loki.lovisual.features.module.modules.visuals.knife.model.KnifeModel;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.resources.Identifier;
import org.joml.Matrix3f;
import org.joml.Matrix4f;
import org.joml.Vector3f;
/** Sends the posed bones of a knife model to the renderer as one custom-geometry layer. */
final class KnifeGeometry {
private KnifeGeometry() {
}
static void submit(KnifeBones bones, KnifeModel model, Identifier texture, PoseStack stack,
SubmitNodeCollector collector, int light) {
int count = model.boneCount();
collector.submitCustomGeometry(stack, RenderTypes.entityTranslucent(texture), (pose, consumer) -> {
Matrix3f normalMatrix = new Matrix3f();
Vector3f point = new Vector3f();
Vector3f normal = new Vector3f();
for (int bone = 0; bone < count; bone++) {
float[] mesh = model.meshOf(bone);
if (mesh == null || !bones.visible[bone]) continue;
Matrix4f world = bones.world[bone];
world.normal(normalMatrix);
for (int v = 0; v + KnifeModel.FLOATS_PER_VERTEX <= mesh.length; v += KnifeModel.FLOATS_PER_VERTEX) {
point.set(mesh[v], mesh[v + 1], mesh[v + 2]);
world.transformPosition(point);
normal.set(mesh[v + 5], mesh[v + 6], mesh[v + 7]).mul(normalMatrix).normalize();
vertex(pose, consumer, point, normal, mesh[v + 3], mesh[v + 4], light);
}
}
});
}
private static void vertex(PoseStack.Pose pose, VertexConsumer consumer, Vector3f point, Vector3f normal,
float u, float v, int light) {
consumer.addVertex(pose, point.x, point.y, point.z)
.setColor(-1).setUv(u, v)
.setOverlay(OverlayTexture.NO_OVERLAY).setLight(light)
.setNormal(pose, normal.x, normal.y, normal.z);
}
}

View file

@ -0,0 +1,100 @@
package dev.loki.lovisual.features.module.modules.visuals.knife.render;
import com.mojang.blaze3d.vertex.PoseStack;
import com.mojang.math.Axis;
import dev.loki.lovisual.features.module.modules.visuals.knife.KnifeSkin;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.KnifeAnimator;
import dev.loki.lovisual.features.module.modules.visuals.knife.anim.KnifeClip;
import dev.loki.lovisual.features.module.modules.visuals.knife.model.KnifeModel;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.player.AvatarRenderer;
import net.minecraft.resources.Identifier;
import net.minecraft.world.entity.HumanoidArm;
import net.minecraft.world.entity.player.PlayerModelPart;
import org.joml.Matrix4f;
/**
* Draws a knife skin as the first person view model (with the player's own arms attached at the model's hand
* markers) and in a third person hand. The pose stack is expected at the camera, like vanilla's hand rendering.
*/
public final class KnifeRenderer {
private static final float EQUIP_DROP = -0.6f;
private static final float ARM_ALIGN_X = 0.0625f;
private static final float ARM_ALIGN_Y = 0.5f;
private static final float THIRD_HAND_X = 1.0f / 16.0f;
private static final float THIRD_HAND_Y = 0.125f;
private static final float THIRD_HAND_Z = -0.625f;
private final KnifeBones primary = new KnifeBones();
private final KnifeBones rest = new KnifeBones();
private final Matrix4f armPose = new Matrix4f();
public void firstPerson(AbstractClientPlayer player, KnifeSkin skin, KnifeModel model, KnifeAnimator animator,
PoseStack stack, SubmitNodeCollector collector, int light, float retire) {
float[] pose = animator.pose();
boolean mirrored = player.getMainArm() == HumanoidArm.LEFT;
stack.pushPose();
stack.translate(0.0f, retire * EQUIP_DROP, 0.0f);
if (mirrored) stack.scale(-1.0f, 1.0f, 1.0f);
applyCamera(model, pose, stack);
applyViewAnchor(model, stack);
primary.build(model, pose, animator.hidden());
KnifeGeometry.submit(primary, model, skin.texture(), stack, collector, light);
arm(model.rightHand(), true, mirrored, player, stack, collector, light);
arm(model.leftHand(), false, mirrored, player, stack, collector, light);
stack.popPose();
}
/** Draws the resting knife in the hand the stack has been moved to (see KnifeHandLayerMixin). */
public void thirdPerson(KnifeSkin skin, KnifeModel model, HumanoidArm arm, PoseStack stack,
SubmitNodeCollector collector, int light) {
KnifeSkin.Transform place = arm == HumanoidArm.RIGHT ? skin.thirdRight() : skin.thirdLeft();
stack.pushPose();
stack.translate((arm == HumanoidArm.LEFT ? -1.0f : 1.0f) * THIRD_HAND_X, THIRD_HAND_Y, THIRD_HAND_Z);
stack.translate(place.translation()[0] / 16.0f, place.translation()[1] / 16.0f, place.translation()[2] / 16.0f);
stack.mulPose(Axis.XP.rotationDegrees(place.rotation()[0]));
stack.mulPose(Axis.YP.rotationDegrees(place.rotation()[1]));
stack.mulPose(Axis.ZP.rotationDegrees(place.rotation()[2]));
stack.scale(place.scale()[0], place.scale()[1], place.scale()[2]);
stack.mulPose(Axis.ZP.rotationDegrees(180.0f));
int hand = model.thirdPersonHand();
if (hand >= 0) stack.translate(-model.pivotX(hand) / 16.0f, -model.pivotY(hand) / 16.0f, -model.pivotZ(hand) / 16.0f);
rest.build(model, null, null);
KnifeGeometry.submit(rest, model, skin.texture(), stack, collector, light);
stack.popPose();
}
/** The model's camera bone shakes the view during inspects and attacks. */
private static void applyCamera(KnifeModel model, float[] pose, PoseStack stack) {
int camera = model.camera();
int at = camera * KnifeClip.STRIDE;
if (camera < 0 || at + KnifeClip.STRIDE > pose.length) return;
stack.translate(pose[at + 3] / 16.0f, -pose[at + 4] / 16.0f, -pose[at + 5] / 16.0f);
stack.mulPose(Axis.ZP.rotationDegrees(-pose[at + 2]));
stack.mulPose(Axis.YP.rotationDegrees(pose[at + 1]));
stack.mulPose(Axis.XP.rotationDegrees(pose[at]));
}
private static void applyViewAnchor(KnifeModel model, PoseStack stack) {
int anchor = model.viewAnchor();
if (anchor < 0) return;
stack.translate(-model.pivotX(anchor) / 16.0f, -model.pivotY(anchor) / 16.0f, -model.pivotZ(anchor) / 16.0f);
}
private void arm(int bone, boolean right, boolean mirrored, AbstractClientPlayer player, PoseStack stack,
SubmitNodeCollector collector, int light) {
if (bone < 0) return;
AvatarRenderer<?> renderer = Minecraft.getInstance().getEntityRenderDispatcher().getPlayerRenderer(player);
if (renderer == null) return;
Identifier skin = player.getSkin().body().texturePath();
armPose.set(primary.world[bone]).translate(right ? -ARM_ALIGN_X : ARM_ALIGN_X, ARM_ALIGN_Y, 0.0f);
stack.pushPose();
stack.mulPose(armPose);
if (mirrored) stack.scale(-1.0f, 1.0f, 1.0f);
if (right) renderer.renderRightHand(stack, collector, light, skin, player.isModelPartShown(PlayerModelPart.RIGHT_SLEEVE));
else renderer.renderLeftHand(stack, collector, light, skin, player.isModelPartShown(PlayerModelPart.LEFT_SLEEVE));
stack.popPose();
}
}

View file

@ -7,6 +7,9 @@ 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.modules.visuals.wings.model.WingModelDef;
import dev.loki.lovisual.features.module.modules.visuals.wings.model.WingModelRenderer;
import dev.loki.lovisual.features.module.modules.visuals.wings.model.WingModels;
import dev.loki.lovisual.features.module.phase.WorldPhase;
import dev.loki.lovisual.features.relations.PlayerRelations;
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
@ -19,7 +22,8 @@ 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}.
* lives in {@link WingShapes}, mesh building in {@link WingsRenderer}. The "Model" source draws one of
* 14 textured Bedrock models instead (package {@code model}); colour then tints the texture.
*/
@ModuleInfo(
id = "wings",
@ -29,23 +33,27 @@ import net.minecraft.world.item.Items;
)
public final class Wings extends Module {
private final ModeValue wingType = modeSetting(
"wingsType", "type", "Angelic", WingShapes.TYPES);
private final ModeValue source = modeSetting(
"wingsSource", "source", "Procedural", "Procedural", "Model");
private final ModeValue wingModel = visibleWhen(modeSetting(
"wingsModel", "model", "Angel", WingModelDef.NAMES), this::usesModel);
private final ModeValue wingType = visibleWhen(modeSetting(
"wingsType", "type", "Angelic", WingShapes.TYPES), () -> !usesModel());
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);
visibleWhen(bool("wingsFlapping", "flapping", true), () -> !usesModel());
private final NumberValue<Float> flapStrength =
visibleWhen(num("wingsFlapStrength", "flap_strength", 30.0f, 5.0f, 60.0f), flapping::get);
visibleWhen(num("wingsFlapStrength", "flap_strength", 30.0f, 5.0f, 60.0f), () -> !usesModel() && flapping.get());
private final NumberValue<Float> flapSpeed =
visibleWhen(num("wingsFlapSpeed", "flap_speed", 3.0f, 0.5f, 8.0f), flapping::get);
visibleWhen(num("wingsFlapSpeed", "flap_speed", 3.0f, 0.5f, 8.0f), () -> !usesModel() && 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 BooleanValue glow = visibleWhen(bool("wingsGlow", "glow", true), () -> !usesModel());
private final BooleanValue outline = visibleWhen(bool("wingsOutline", "outline", true), () -> !usesModel());
private final BooleanValue ribs = visibleWhen(bool("wingsRibs", "ribs", true), () -> !usesModel());
private final RGBAColorValue color =
color("wingsColor", "color", "#FFFFFFDC");
@ -57,11 +65,20 @@ public final class Wings extends Module {
return WorldPhase.END_MAIN;
}
@Override
public void onEnable() {
WingModels.clear();
}
@Override
public void onDisable() {
selfYawReady = false;
}
private boolean usesModel() {
return "Model".equals(source.get());
}
@Override
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
Minecraft mc = Minecraft.getInstance();
@ -71,21 +88,26 @@ public final class Wings extends Module {
wingType.get(), scale.get(), flapping.get(), flapStrength.get(), flapSpeed.get(),
color.getArgb(), glow.get(), outline.get(), ribs.get(), !throughWalls.get());
WingModels.Loaded model = usesModel() ? WingModels.get(WingModelDef.byName(wingModel.get())) : null;
if (usesModel() && model == null) return;
WingModelRenderer.Style modelStyle = new WingModelRenderer.Style(
scale.get(), color.getArgb() & 0xFFFFFF, !throughWalls.get());
if (showsSelf() && !mc.options.getCameraType().isFirstPerson()
&& mc.player.isAlive() && !hasElytra(mc.player)) {
renderOne(renderer, mc.player, tickDelta, style, true);
renderOne(renderer, mc.player, tickDelta, style, model, modelStyle, 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);
renderOne(renderer, player, tickDelta, style, model, modelStyle, false);
}
}
}
private void renderOne(Renderer3D renderer, Player player, float tickDelta,
WingsRenderer.Style style, boolean isSelf) {
private void renderOne(Renderer3D renderer, Player player, float tickDelta, WingsRenderer.Style style,
WingModels.Loaded model, WingModelRenderer.Style modelStyle, boolean isSelf) {
float targetYaw = Mth.rotLerp(tickDelta, player.yBodyRotO, player.yBodyRot);
float bodyYaw;
if (isSelf) {
@ -99,7 +121,11 @@ public final class Wings extends Module {
} else {
bodyYaw = targetYaw;
}
WingsRenderer.render(renderer, player, tickDelta, bodyYaw, style);
if (model != null) {
WingModelRenderer.render(renderer, player, tickDelta, bodyYaw, model, modelStyle);
} else {
WingsRenderer.render(renderer, player, tickDelta, bodyYaw, style);
}
}
private boolean showsSelf() {

View file

@ -0,0 +1,41 @@
package dev.loki.lovisual.features.module.modules.visuals.wings.model;
import java.util.List;
/**
* One entry of the wing model catalog. {@code animation} names an {@code .animation.json} next to the model, or
* is one of the procedural ids ("angel", "demon", "dragon"). {@code scale}, {@code offsetY} and {@code offsetZ}
* place the model on the back; {@code frames} is the number of texture frames stacked vertically.
*/
public record WingModelDef(String name, String geometry, String texture, String animation,
float scale, float offsetY, float offsetZ, int frames) {
public static final List<WingModelDef> ALL = List.of(
new WingModelDef("Baby Dragon", "dragon", "baby_dragon_white", "dragon", 1.0f, 0.45f, 0.0f, 1),
new WingModelDef("Angel", "angel", "angel_white", "angel", 1.0f, 0.45f, 0.0f, 1),
new WingModelDef("Demon Red", "demon", "demon_red", "demon", 1.0f, 0.45f, 0.0f, 1),
new WingModelDef("Demon Black", "demon", "demon_black", "demon", 1.0f, 0.45f, 0.0f, 1),
new WingModelDef("Pulse", "pulse", "pulse", "pulse", 1.0f, -1.2f, 0.0f, 1),
new WingModelDef("Dark", "dark", "dark", "dark", 0.9f, -1.05f, 0.05f, 1),
new WingModelDef("Ghoul", "ghoul", "ghoul", "ghoul", 0.6f, -0.65f, 0.0f, 1),
new WingModelDef("Rocker", "rocker", "rocker", "rocker", 0.9f, -1.05f, 0.0f, 1),
new WingModelDef("Wota", "wota", "wota", "wota", 0.9f, -1.05f, 0.0f, 10),
new WingModelDef("Trident", "trident", "trident", "trident", 0.9f, -1.05f, 0.2f, 1),
new WingModelDef("Easter", "easter", "easter", "easter", 0.9f, -1.05f, 0.0f, 1),
new WingModelDef("Blue Dragon", "blue_dragon", "blue_dragon", "blue_dragon", 0.9f, -1.05f, 0.0f, 1),
new WingModelDef("Spider", "spider", "spider", "spider", 1.0f, 0.0f, 0.0f, 1),
new WingModelDef("Vulcano", "vulcano", "vulcano", "vulcano", 1.0f, 0.0f, 0.0f, 1));
public static final String[] NAMES = ALL.stream().map(WingModelDef::name).toArray(String[]::new);
public static WingModelDef byName(String name) {
for (WingModelDef def : ALL) {
if (def.name.equals(name)) return def;
}
return ALL.get(0);
}
public boolean procedural() {
return animation.equals("angel") || animation.equals("demon") || animation.equals("dragon");
}
}

View file

@ -0,0 +1,141 @@
package dev.loki.lovisual.features.module.modules.visuals.wings.model;
import dev.loki.lovisual.util.bedrock.geo.GeoBone;
import dev.loki.lovisual.util.bedrock.geo.GeoCube;
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.core.BlockPos;
import net.minecraft.world.entity.EquipmentSlot;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.phys.Vec3;
import org.joml.Matrix4f;
import org.joml.Vector3f;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Draws a loaded wing model on a player's back. The anchor chain follows the vanilla player model (body
* pivot, crouch lean) and is then moved by the catalog offsets; the bones are posed by the model's animator.
*/
public final class WingModelRenderer {
private static final float MODEL_HEIGHT = 1.501f;
private static final float ATTACHMENT_SHIFT = -0.3f;
private static final float CHEST_ARMOR_OFFSET = 0.078125f;
private static final float CROUCH_BODY_DROP = 0.2f;
private static final float CROUCH_LEAN = 0.5f;
private static final float FLIGHT_BLEND_PER_FRAME = 0.1f;
private static final float CLOCK_WRAP_SECONDS = 360.0f;
private static final long FRAME_MILLIS = 150L;
private static final float MIN_LIGHT = 0.3f;
/** Per-frame knobs resolved by the module. */
public record Style(float scale, int tintRgb, boolean depthTest) {
}
private static final Map<UUID, Float> FLIGHT = new HashMap<>();
private WingModelRenderer() {
}
public static void render(Renderer3D renderer, Player player, float tickDelta, float bodyYaw,
WingModels.Loaded wing, Style style) {
WingModelDef def = wing.def();
float seconds = (player.tickCount + tickDelta) / 20.0f % CLOCK_WRAP_SECONDS;
wing.animator().apply(wing.model(), seconds, flight(player));
Matrix4f anchor = anchor(player, tickDelta, bodyYaw, def, style.scale());
MeshBuilder mesh = renderer.batchTextured(
style.depthTest() ? LoVisualRenderPipelines.WORLD_TEXTURED_DEPTH : LoVisualRenderPipelines.WORLD_TEXTURED,
wing.texture(), style.depthTest() ? Renderer3D.DepthMode.MAIN : Renderer3D.DepthMode.NONE);
if (mesh == null) return;
float light = light(player);
int r = Math.round((style.tintRgb() >> 16 & 0xFF) * light);
int g = Math.round((style.tintRgb() >> 8 & 0xFF) * light);
int b = Math.round((style.tintRgb() & 0xFF) * light);
int frame = def.frames() > 1 ? (int) ((System.currentTimeMillis() / FRAME_MILLIS) % def.frames()) : 0;
Sink sink = new Sink(mesh, wing.model().textureWidth(), wing.model().textureHeight(), def.frames(), frame, r, g, b);
for (GeoBone root : wing.model().roots()) draw(root, anchor, sink);
}
/** translate(player) · yaw · body (crouch) · flip · catalog offset · scale, in blocks. */
static Matrix4f anchor(Player player, float tickDelta, float bodyYaw, WingModelDef def, float userScale) {
Vec3 pos = player.getPosition(tickDelta);
Matrix4f m = new Matrix4f()
.translate((float) pos.x, (float) pos.y, (float) pos.z)
.rotateY((float) Math.toRadians(180.0f - bodyYaw))
.scale(-1.0f, -1.0f, 1.0f)
.translate(0.0f, -MODEL_HEIGHT, 0.0f);
if (player.isShiftKeyDown()) m.translate(0.0f, CROUCH_BODY_DROP, 0.0f).rotateX(CROUCH_LEAN);
float back = player.getItemBySlot(EquipmentSlot.CHEST).isEmpty() ? 0.0f : CHEST_ARMOR_OFFSET;
m.rotateZ((float) Math.PI)
.translate(0.0f, def.offsetY() + ATTACHMENT_SHIFT, def.offsetZ() + back)
.scale(def.scale() * userScale);
return m;
}
private static void draw(GeoBone bone, Matrix4f parent, Sink sink) {
Matrix4f matrix = bone.transform(new Matrix4f(parent));
for (GeoCube cube : bone.cubes) {
Matrix4f cubeMatrix = cube.rotated() ? cube.transform(new Matrix4f(matrix)) : matrix;
for (float[] face : cube.faces()) sink.quad(cubeMatrix, face);
}
for (GeoBone child : bone.children) draw(child, matrix, sink);
}
private static float flight(Player player) {
float target = player.isFallFlying() ? 1.0f : 0.0f;
float current = FLIGHT.getOrDefault(player.getUUID(), target);
current += Math.max(-FLIGHT_BLEND_PER_FRAME, Math.min(FLIGHT_BLEND_PER_FRAME, target - current));
FLIGHT.put(player.getUUID(), current);
return current;
}
private static float light(Player player) {
Minecraft mc = Minecraft.getInstance();
if (mc.level == null) return 1.0f;
int level = mc.level.getMaxLocalRawBrightness(BlockPos.containing(player.getEyePosition()));
return MIN_LIGHT + (1.0f - MIN_LIGHT) * (level / 15.0f);
}
/** Writes transformed quads to the mesh, mapping uv to the current texture frame. */
private static final class Sink {
private final MeshBuilder mesh;
private final float texW;
private final float texH;
private final int frames;
private final int frame;
private final int r;
private final int g;
private final int b;
private final Vector3f point = new Vector3f();
Sink(MeshBuilder mesh, int texW, int texH, int frames, int frame, int r, int g, int b) {
this.mesh = mesh;
this.texW = texW;
this.texH = texH;
this.frames = frames;
this.frame = frame;
this.r = r;
this.g = g;
this.b = b;
}
void quad(Matrix4f matrix, float[] face) {
mesh.ensureQuadCapacity();
int[] index = new int[4];
for (int i = 0; i < 4; i++) {
point.set(face[i * 5] / 16.0f, face[i * 5 + 1] / 16.0f, face[i * 5 + 2] / 16.0f);
matrix.transformPosition(point);
float u = face[i * 5 + 3] / texW;
float v = (face[i * 5 + 4] / texH + frame) / frames;
index[i] = mesh.vec3(point.x, point.y, point.z).vec2(u, v).color(r, g, b, 255).next();
}
mesh.quad(index[0], index[1], index[2], index[3]);
}
}
}

View file

@ -0,0 +1,69 @@
package dev.loki.lovisual.features.module.modules.visuals.wings.model;
import dev.loki.lovisual.features.module.modules.visuals.wings.model.anim.BedrockClip;
import dev.loki.lovisual.features.module.modules.visuals.wings.model.anim.ProceduralWings;
import dev.loki.lovisual.features.module.modules.visuals.wings.model.anim.WingAnimator;
import dev.loki.lovisual.util.bedrock.geo.GeoParser;
import dev.loki.lovisual.util.logging.DebugLog;
import net.minecraft.client.Minecraft;
import net.minecraft.resources.Identifier;
import net.minecraft.server.packs.resources.Resource;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
/** Lazily loads wing models from {@code assets/lovisual/wings} and keeps them until the resources reload. */
public final class WingModels {
private static final String NAMESPACE = "lovisual";
/** A model ready to pose and draw. */
public record Loaded(WingModelDef def, GeoParser.Model model, WingAnimator animator, Identifier texture) {
}
private static final Map<String, Optional<Loaded>> CACHE = new HashMap<>();
private WingModels() {
}
public static Loaded get(WingModelDef def) {
return CACHE.computeIfAbsent(def.name(), name -> Optional.ofNullable(load(def))).orElse(null);
}
public static void clear() {
CACHE.clear();
}
private static Loaded load(WingModelDef def) {
try {
GeoParser.Model model = GeoParser.parse(read("wings/" + def.geometry() + ".geo.json"));
WingAnimator animator = animator(def);
Identifier texture = Identifier.fromNamespaceAndPath(NAMESPACE, "textures/wings/" + def.texture() + ".png");
return new Loaded(def, model, animator, texture);
} catch (IOException | RuntimeException e) {
DebugLog.warn("[LoVisual] Wing model '" + def.name() + "' failed to load: " + e);
return null;
}
}
private static WingAnimator animator(WingModelDef def) throws IOException {
return switch (def.animation()) {
case "angel" -> ProceduralWings.ANGEL;
case "demon" -> ProceduralWings.DEMON;
case "dragon" -> ProceduralWings.DRAGON;
default -> BedrockClip.parse(read("wings/" + def.animation() + ".animation.json"));
};
}
private static String read(String path) throws IOException {
Optional<Resource> resource = Minecraft.getInstance().getResourceManager()
.getResource(Identifier.fromNamespaceAndPath(NAMESPACE, path));
if (resource.isEmpty()) throw new IOException("missing resource " + path);
try (BufferedReader reader = resource.get().openAsReader()) {
return reader.lines().collect(Collectors.joining("\n"));
}
}
}

View file

@ -0,0 +1,58 @@
package dev.loki.lovisual.features.module.modules.visuals.wings.model.anim;
import dev.loki.lovisual.util.bedrock.anim.Channel;
import dev.loki.lovisual.util.bedrock.anim.Clip;
import dev.loki.lovisual.util.bedrock.geo.GeoBone;
import dev.loki.lovisual.util.bedrock.geo.GeoParser;
import java.util.Map;
/** The first clip of a Bedrock {@code .animation.json}, applied to the bones of a wing model. */
public final class BedrockClip implements WingAnimator {
private final Clip clip;
private BedrockClip(Clip clip) {
this.clip = clip;
}
public float length() {
return clip.length();
}
public static BedrockClip parse(String json) {
Map<String, Clip> clips = Clip.parseAll(json);
return new BedrockClip(clips.isEmpty() ? null : clips.values().iterator().next());
}
@Override
public void apply(GeoParser.Model model, float seconds, float flight) {
model.resetPose();
if (clip == null) return;
float length = Math.max(clip.length(), 0.001f);
float time = clip.loops() ? seconds % length : Math.min(seconds, length);
float[] v = new float[3];
for (Map.Entry<String, Clip.Tracks> entry : clip.bones().entrySet()) {
GeoBone bone = model.bones().get(entry.getKey());
if (bone == null) continue;
Clip.Tracks tracks = entry.getValue();
Channel rotation = tracks.rotation();
if (rotation != null) {
rotation.sample(time, v, 0);
bone.rot[0] = bone.baseRot[0] + (float) Math.toRadians(-v[0]);
bone.rot[1] = bone.baseRot[1] + (float) Math.toRadians(-v[1]);
bone.rot[2] = bone.baseRot[2] + (float) Math.toRadians(v[2]);
}
Channel position = tracks.position();
if (position != null) {
position.sample(time, v, 0);
bone.offset[0] = -v[0];
bone.offset[1] = v[1];
bone.offset[2] = v[2];
}
Channel scale = tracks.scale();
if (scale != null) {
scale.sample(time, bone.scale, 0);
}
}
}
}

View file

@ -0,0 +1,117 @@
package dev.loki.lovisual.features.module.modules.visuals.wings.model.anim;
import dev.loki.lovisual.util.bedrock.geo.GeoBone;
import dev.loki.lovisual.util.bedrock.geo.GeoParser;
/**
* Code-driven flaps for the three models that ship without an animation file. Angles are degrees added to the
* pose stored in the model, positions are pixels, and the clock runs in degrees (seconds x speed).
*/
public final class ProceduralWings {
private static final float ROOT_SCALE = 0.6f;
private static final float DEGREES_PER_SECOND = 200.0f;
private ProceduralWings() {
}
public static final WingAnimator ANGEL = (model, seconds, flight) -> {
model.resetPose();
float t = seconds * DEGREES_PER_SECOND;
scaleRoots(model, ROOT_SCALE);
float flap = cos(10 + t);
float sway = sin(10 + t);
pose(model, "Left_Wing", -18.2468f + flap * 10, -36.3088f + sway * 15, 18.01613f, -0.0288f, 0.20524f, 1.18197f);
pose(model, "Right_Wing", -18.2468f + flap * 10, 36.3088f - sway * 15, -18.01609f, -0.0288f, 0.2052f, 1.18197f);
pose(model, "bone6", 1.1552f - flap * 10, -0.543f - cos(t) * 25, 3.16854f, -0.90088f, 0.04987f, -0.43119f);
pose(model, "bone4", 1.1552f - flap * 10, 0.543f + cos(t) * 25, -3.1685f, 0.9009f, 0.04987f, -0.43119f);
pose(model, "bone7", flap * 5, -sway * 15, -sway * 15);
pose(model, "bone5", flap * 5, sway * 15, sway * 15);
pose(model, "bone3", -0.0082f + cos(t) * 10, 12.49978f, -7.57475f, -0.87628f, 1.75491f, 0.39042f);
pose(model, "bone8", -0.0082f + cos(t) * 10, -12.4998f, 7.5748f, 0.8763f, 1.75491f, 0.39042f);
pose(model, "bone2", 6.4724f - cos(60 + t) * 5, 5.1344f + sin(60 + t) * 5, -12.5338f);
pose(model, "bone9", 6.4724f - cos(60 + t) * 5, -5.1344f - sin(60 + t) * 5, 12.5338f);
pose(model, "bone10", 6.4876f + cos(200 + t) * 5, -0.97f + sin(300 + t) * 5, -6.9044f - sin(220 + t) * 7,
-0.89933f, -0.02767f, -0.01524f);
pose(model, "bone11", 6.4876f + cos(200 + t) * 5, 0.97f - sin(300 + t) * 5, 6.9044f + sin(220 + t) * 7,
0.8993f, -0.0277f, -0.0152f);
};
public static final WingAnimator DEMON = (model, seconds, flight) -> {
model.resetPose();
float t = seconds * DEGREES_PER_SECOND;
scaleRoots(model, ROOT_SCALE);
pose(model, "Wing3", cos(t) * 15 + 10, 45 - cos(t) * 15, 0);
pose(model, "Wing2", cos(t) * 15 + 10, -45 + cos(t) * 15, 0);
pose(model, "bone8", 0, -cos(50 - t) * 20, 0);
pose(model, "bone2", 0, cos(50 - t) * 20, 0);
pose(model, "bone9", 0, -cos(80 - t) * 25, 0);
pose(model, "bone3", 0, cos(80 - t) * 25, 0);
};
/** Standing and gliding flaps, blended by {@code flight}; the wings flap faster and the model grows when gliding. */
public static final WingAnimator DRAGON = (model, seconds, flight) -> {
model.resetPose();
float f = Math.max(0.0f, Math.min(flight, 1.0f));
float slow = seconds * 300.0f;
float fast = seconds * 600.0f;
scaleRoots(model, mix(f, 0.9f, 1.0f));
float leftYaw = mix(f, -30 + cos(slow) * 20, -27.5839f + cos(fast) * 20);
float rightYaw = mix(f, 30 - cos(slow) * 20, 27.5839f - cos(fast) * 20);
float pitch = mix(f, cos(-60 + slow) * 10, 7.8279f + cos(-60 + fast) * 10);
float roll = mix(f, 0, -12.46225f);
pose(model, "left_wing", pitch, leftYaw, roll);
pose(model, "right_wing", pitch, rightYaw, -roll);
float tip = mix(f, cos(90 + slow), cos(90 + fast)) * 20;
pose(model, "left_tip", 0, -tip, 0);
pose(model, "right_tip", 0, tip, 0);
float stretch = mix(f, 1.2f + cos(90 + slow) * 0.2f, 1.2f + cos(90 + fast) * 0.2f);
stretch(model, "left_wing", stretch);
stretch(model, "right_wing", stretch);
};
private static void pose(GeoParser.Model model, String name, float rx, float ry, float rz) {
GeoBone bone = model.bones().get(name);
if (bone == null) return;
bone.rot[0] = bone.baseRot[0] + (float) Math.toRadians(-rx);
bone.rot[1] = bone.baseRot[1] + (float) Math.toRadians(-ry);
bone.rot[2] = bone.baseRot[2] + (float) Math.toRadians(rz);
}
private static void pose(GeoParser.Model model, String name, float rx, float ry, float rz, float ox, float oy, float oz) {
pose(model, name, rx, ry, rz);
GeoBone bone = model.bones().get(name);
if (bone == null) return;
bone.offset[0] = -ox;
bone.offset[1] = oy;
bone.offset[2] = oz;
}
private static void stretch(GeoParser.Model model, String name, float widthScale) {
GeoBone bone = model.bones().get(name);
if (bone == null) return;
bone.scale[0] = widthScale;
bone.scale[2] = 1.1f;
}
private static void scaleRoots(GeoParser.Model model, float factor) {
for (GeoBone root : model.roots()) {
root.scale[0] = factor;
root.scale[1] = factor;
root.scale[2] = factor;
}
}
private static float mix(float t, float a, float b) {
return a + (b - a) * t;
}
static float cos(float degrees) {
return (float) Math.cos(Math.toRadians(degrees));
}
static float sin(float degrees) {
return (float) Math.sin(Math.toRadians(degrees));
}
}

View file

@ -0,0 +1,12 @@
package dev.loki.lovisual.features.module.modules.visuals.wings.model.anim;
import dev.loki.lovisual.util.bedrock.geo.GeoParser;
/** Poses a wing model for a moment in time. */
public interface WingAnimator {
/**
* @param seconds animation clock in seconds
* @param flight 0 while standing, 1 while gliding; models without a flight pose ignore it
*/
void apply(GeoParser.Model model, float seconds, float flight);
}

View file

@ -3,6 +3,7 @@ package dev.loki.lovisual.features.theme;
import dev.loki.lovisual.config.definition.SettingDef;
import dev.loki.lovisual.config.definition.SettingOwner;
import dev.loki.lovisual.config.profile.service.ConfigProfileStorage;
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;
@ -39,6 +40,10 @@ public final class EditableClickGuiTheme implements SettingOwner {
private final GradientValues surfaceGradient;
private final GradientValues cardGradient;
private final GradientValues strokeGradient;
private final ModeValue backdropMode;
private final NumberValue<Double> backdropIntensity;
private final NumberValue<Double> backdropSpeed;
private final NumberValue<Double> backdropScale;
private EditableClickGuiTheme(String id, Themes.ThemeEntry entry) {
Themes.ThemeEntry src = entry != null ? entry : Theme.currentEntry();
@ -63,6 +68,11 @@ public final class EditableClickGuiTheme implements SettingOwner {
surfaceGradient = new GradientValues("surface", src.surfaceGradient(), theme.surface());
cardGradient = new GradientValues("card", src.cardGradient(), theme.cardEnabled());
strokeGradient = new GradientValues("stroke", src.strokeGradient(), theme.windowStroke());
Backdrop backdrop = src.backdrop();
backdropMode = new ModeValue("backdrop_mode", backdrop.mode().id(), backdropModeIds());
backdropIntensity = new NumberValue<>("backdrop_intensity", (double) backdrop.intensity(), 0.0, (double) Backdrop.MAX_INTENSITY);
backdropSpeed = new NumberValue<>("backdrop_speed", (double) backdrop.speed(), (double) Backdrop.MIN_SPEED, (double) Backdrop.MAX_SPEED);
backdropScale = new NumberValue<>("backdrop_scale", (double) backdrop.scale(), (double) Backdrop.MIN_SCALE, (double) Backdrop.MAX_SCALE);
}
public static EditableClickGuiTheme existing(String id) {
@ -106,6 +116,10 @@ public final class EditableClickGuiTheme implements SettingOwner {
surfaceGradient.addSettings(out, this);
cardGradient.addSettings(out, this);
strokeGradient.addSettings(out, this);
add(out, SettingDef.mode("backdrop_mode", backdropMode));
add(out, SettingDef.number("backdrop_intensity", backdropIntensity).visibleWhen(this::hasBackdrop));
add(out, SettingDef.number("backdrop_speed", backdropSpeed).visibleWhen(this::hasBackdrop));
add(out, SettingDef.number("backdrop_scale", backdropScale).visibleWhen(this::hasBackdrop));
for (Setting setting : out) {
setting.setParent(this);
setting.preflightI18n();
@ -125,7 +139,20 @@ public final class EditableClickGuiTheme implements SettingOwner {
return new Themes.ThemeEntry(id, title(), false, theme,
windowGradient.toSpec(theme.windowBg()), headerGradient.toSpec(theme.windowHeader()),
surfaceGradient.toSpec(theme.surface()), cardGradient.toSpec(theme.cardEnabled()),
strokeGradient.toSpec(theme.windowStroke()));
strokeGradient.toSpec(theme.windowStroke()), backdrop());
}
private boolean hasBackdrop() {
return Backdrop.Mode.byId(backdropMode.get()) != Backdrop.Mode.NONE;
}
private Backdrop backdrop() {
return new Backdrop(Backdrop.Mode.byId(backdropMode.get()), backdropIntensity.get().floatValue(),
backdropSpeed.get().floatValue(), backdropScale.get().floatValue());
}
private static String[] backdropModeIds() {
return java.util.Arrays.stream(Backdrop.Mode.values()).map(Backdrop.Mode::id).toArray(String[]::new);
}
@Override

View file

@ -89,7 +89,7 @@ public final class ThemeStore {
ThemeEntry normalized = new ThemeEntry(id,
entry.name() == null || entry.name().isBlank() ? id : entry.name().trim(),
false, entry.theme(), entry.windowGradient(), entry.headerGradient(),
entry.surfaceGradient(), entry.cardGradient(), entry.strokeGradient());
entry.surfaceGradient(), entry.cardGradient(), entry.strokeGradient(), entry.backdrop());
boolean replaced = false;
for (int i = 0; i < customThemes.size(); i++) {
if (customThemes.get(i).getId().equals(id)) {

View file

@ -1,5 +1,6 @@
package dev.loki.lovisual.features.theme;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
@ -82,6 +83,14 @@ public enum Themes {
static boolean isCustomTheme(String id) { return STORE.isCustomId(id); }
static String uniqueCustomId(String name) { return STORE.uniqueCustomId(name); }
static Theme classic() { return ThemePresets1.CLASSIC; }
/** What the ClickGUI backdrop draws this frame: the target, the backdrop it replaces and the wipe progress (1 = done). */
public record BackdropView(Backdrop target, Backdrop outgoing, float wipe) { }
public static BackdropView backdropView() {
ThemesTransition.BackdropFrame frame = ThemesTransition.backdropFrame();
return new BackdropView(frame.target(), frame.outgoing(), frame.progress());
}
static void setTheme(String id) { ThemesTransition.setTheme(id); }
static void setTheme(Theme t) { ThemesTransition.setTheme(t); }
static GradientSpec flatGradient(int color) { return ThemesBlending.flatGradient(color); }
@ -119,7 +128,8 @@ public enum Themes {
String displayName = name == null || name.isBlank() ? "Custom Theme" : name.trim();
String id = STORE.uniqueCustomId(displayName);
ThemeEntry entry = new ThemeEntry(id, displayName, false, src.theme(),
src.windowGradient(), src.headerGradient(), src.surfaceGradient(), src.cardGradient(), src.strokeGradient());
src.windowGradient(), src.headerGradient(), src.surfaceGradient(), src.cardGradient(), src.strokeGradient(),
src.backdrop());
return saveCustomTheme(entry, select);
}
@ -161,8 +171,26 @@ public enum Themes {
public record ThemeEntry(
String id, String name, boolean builtin, Theme theme,
GradientSpec windowGradient, GradientSpec headerGradient,
GradientSpec surfaceGradient, GradientSpec cardGradient, GradientSpec strokeGradient
GradientSpec surfaceGradient, GradientSpec cardGradient, GradientSpec strokeGradient,
Backdrop backdrop
) {
public ThemeEntry {
backdrop = backdrop == null ? Backdrop.NONE : backdrop;
}
/** An entry without a shader backdrop. */
public ThemeEntry(String id, String name, boolean builtin, Theme theme,
GradientSpec windowGradient, GradientSpec headerGradient,
GradientSpec surfaceGradient, GradientSpec cardGradient, GradientSpec strokeGradient) {
this(id, name, builtin, theme, windowGradient, headerGradient, surfaceGradient, cardGradient,
strokeGradient, Backdrop.NONE);
}
public ThemeEntry withBackdrop(Backdrop value) {
return new ThemeEntry(id, name, builtin, theme, windowGradient, headerGradient, surfaceGradient,
cardGradient, strokeGradient, value);
}
public String getId() {
return id;
}

View file

@ -0,0 +1,70 @@
package dev.loki.lovisual.features.theme;
import java.util.Locale;
/**
* The animated shader backdrop of a theme, drawn behind the ClickGUI window and tinted with the theme's accents.
* {@code intensity} 0 means off; {@code speed} and {@code scale} stretch time and the pattern.
*/
public record Backdrop(Mode mode, float intensity, float speed, float scale) {
public enum Mode {
NONE, IRIDESCENCE, MAGMA, DIGITAL_RAIN, GRID_HORIZON, SINGULARITY, EMBER;
public String id() {
return name().toLowerCase(Locale.ROOT);
}
/** Mode number the shader switches on; 0 for none. */
public int shaderIndex() {
return ordinal();
}
public static Mode byId(String id) {
if (id != null) {
for (Mode mode : values()) {
if (mode.id().equalsIgnoreCase(id.trim())) return mode;
}
}
return NONE;
}
}
public static final float MAX_INTENSITY = 1.5f;
public static final float MIN_SPEED = 0.1f;
public static final float MAX_SPEED = 3.0f;
public static final float MIN_SCALE = 0.5f;
public static final float MAX_SCALE = 2.5f;
public static final Backdrop NONE = new Backdrop(Mode.NONE, 0.0f, 1.0f, 1.0f);
public Backdrop {
mode = mode == null ? Mode.NONE : mode;
intensity = clamp(intensity, 0.0f, MAX_INTENSITY);
speed = clamp(speed, MIN_SPEED, MAX_SPEED);
scale = clamp(scale, MIN_SCALE, MAX_SCALE);
}
public static Backdrop of(Mode mode, float intensity) {
return new Backdrop(mode, intensity, 1.0f, 1.0f);
}
public boolean isActive() {
return mode != Mode.NONE && intensity > 0.0f;
}
public Backdrop withIntensity(float value) {
return new Backdrop(mode, value, speed, scale);
}
/** Same mode: all numbers blend. Different mode: the new backdrop fades in from nothing. */
public Backdrop lerp(Backdrop to, float t) {
float f = clamp(t, 0.0f, 1.0f);
if (mode != to.mode) return new Backdrop(to.mode, to.intensity * f, to.speed, to.scale);
return new Backdrop(mode, intensity + (to.intensity - intensity) * f,
speed + (to.speed - speed) * f, scale + (to.scale - scale) * f);
}
private static float clamp(float value, float min, float max) {
if (Float.isNaN(value)) return min;
return Math.max(min, Math.min(max, value));
}
}

View file

@ -100,7 +100,8 @@ final class ThemesBlending {
blendGradient(from.headerGradient(), to.headerGradient(), t),
blendGradient(from.surfaceGradient(), to.surfaceGradient(), t),
blendGradient(from.cardGradient(), to.cardGradient(), t),
blendGradient(from.strokeGradient(), to.strokeGradient(), t)
blendGradient(from.strokeGradient(), to.strokeGradient(), t),
from.backdrop().lerp(to.backdrop(), t)
);
}
}

View file

@ -43,6 +43,10 @@ final class ThemesProfileCodec {
writeGradient(out, "surface", entry.surfaceGradient());
writeGradient(out, "card", entry.cardGradient());
writeGradient(out, "stroke", entry.strokeGradient());
out.put("backdropMode", entry.backdrop().mode().id());
out.put("backdropIntensity", entry.backdrop().intensity());
out.put("backdropSpeed", entry.backdrop().speed());
out.put("backdropScale", entry.backdrop().scale());
return out;
}
@ -74,7 +78,11 @@ final class ThemesProfileCodec {
readGradient(map, "header", baseEntry.headerGradient(), theme.windowHeader()),
readGradient(map, "surface", baseEntry.surfaceGradient(), theme.surface()),
readGradient(map, "card", baseEntry.cardGradient(), theme.cardEnabled()),
readGradient(map, "stroke", baseEntry.strokeGradient(), theme.windowStroke())
readGradient(map, "stroke", baseEntry.strokeGradient(), theme.windowStroke()),
new Backdrop(Backdrop.Mode.byId(stringValue(map.get("backdropMode"), baseEntry.backdrop().mode().id())),
floatValue(map.get("backdropIntensity"), baseEntry.backdrop().intensity()),
floatValue(map.get("backdropSpeed"), baseEntry.backdrop().speed()),
floatValue(map.get("backdropScale"), baseEntry.backdrop().scale()))
);
}

View file

@ -20,6 +20,25 @@ final class ThemesTransition {
/** Total cross-fade duration in milliseconds; matches {@code Themes.THEME_TRANSITION_MS}. */
ThemesTransition() { }
/** Length of the shader wipe that replaces the old backdrop with the new one. */
static final long WIPE_MS = 700L;
private static long wipeStartMs = -WIPE_MS;
private static Backdrop wipeFrom = Backdrop.NONE;
/** Which backdrops to draw now: the target, the one it replaces, and the wipe progress (1 when finished). */
record BackdropFrame(Backdrop target, Backdrop outgoing, float progress) {
boolean wiping() {
return progress < 1.0f;
}
}
static BackdropFrame backdropFrame() {
Backdrop target = Themes.currentEntry.backdrop();
long elapsed = Util.getMillis() - wipeStartMs;
if (elapsed >= WIPE_MS || elapsed < 0L) return new BackdropFrame(target, Backdrop.NONE, 1.0f);
return new BackdropFrame(target, wipeFrom, AnimationUtility.easeOutCubic((float) elapsed / (float) WIPE_MS));
}
/** Current theme with any in-flight transition applied; safe to call every frame. */
static Theme theme() {
return animatedTheme();
@ -104,6 +123,8 @@ final class ThemesTransition {
Themes.transitionFromTheme = fromTheme != null ? fromTheme : Themes.current;
Themes.transitionFromEntry = fromEntry != null ? fromEntry : Themes.currentEntry;
Themes.transitionStartMs = Util.getMillis();
wipeFrom = Themes.currentEntry.backdrop();
wipeStartMs = Themes.transitionStartMs;
Themes.transitionActive = true;
Themes.animatedCacheKey = -1;
}

View file

@ -126,7 +126,8 @@ static final ThemeEntry AMBER_ENTRY = new ThemeEntry(
Themes.flatGradient(AMBER.surface()),
Themes.flatGradient(AMBER.cardEnabled()),
Themes.flatGradient(AMBER.windowStroke())
);
)
.withBackdrop(Backdrop.of(Backdrop.Mode.EMBER, 0.7f));
static final ThemeEntry NOIR_ENTRY = new ThemeEntry(
Themes.THEME_NOIR,
"Noir",

View file

@ -115,7 +115,8 @@ static final ThemeEntry NITRO_ENTRY = new ThemeEntry(
new GradientSpec(true, 0xFF1A1F36, 0xFF121728, 90f),
new GradientSpec(true, 0xFF5A37D5, 0xFF2AD1FF, 45f),
new GradientSpec(true, 0xFF6C4BFF, 0xFF4BD0FF, 45f)
);
)
.withBackdrop(Backdrop.of(Backdrop.Mode.SINGULARITY, 0.8f));
static final ThemeEntry SUNSET_ENTRY = new ThemeEntry(
Themes.THEME_SUNSET,
"Sunset",

View file

@ -126,7 +126,8 @@ static final ThemeEntry DUSK_ENTRY = new ThemeEntry(
new GradientSpec(true, 0xFF1D1626, 0xFF141018, 90f),
new GradientSpec(true, 0xFF7C4BFF, 0xFFFF9B3D, 45f),
new GradientSpec(true, 0xFFA775FF, 0xFFFF7A3A, 45f)
);
)
.withBackdrop(Backdrop.of(Backdrop.Mode.GRID_HORIZON, 0.8f));
static final ThemeEntry PRISM_ENTRY = new ThemeEntry(
Themes.THEME_PRISM,
"Prism",
@ -137,7 +138,8 @@ static final ThemeEntry PRISM_ENTRY = new ThemeEntry(
new GradientSpec(true, 0xFF151C2E, 0xFF101522, 90f),
new GradientSpec(true, 0xFF59C4FF, 0xFFB35CFF, 45f),
new GradientSpec(true, 0xFF7AD4FF, 0xFFE36BFF, 45f)
);
)
.withBackdrop(Backdrop.of(Backdrop.Mode.IRIDESCENCE, 0.7f));
static final ThemeEntry CINDER_ENTRY = new ThemeEntry(
Themes.THEME_CINDER,
"Cinder",
@ -148,7 +150,8 @@ static final ThemeEntry CINDER_ENTRY = new ThemeEntry(
new GradientSpec(true, 0xFF1A1214, 0xFF120E0F, 90f),
new GradientSpec(true, 0xFFDC4A4A, 0xFF401010, 45f),
new GradientSpec(true, 0xFFE26A6A, 0xFF7A2A2A, 45f)
);
)
.withBackdrop(Backdrop.of(Backdrop.Mode.MAGMA, 0.8f));
static final ThemeEntry FOREST_ENTRY = new ThemeEntry(
Themes.THEME_FOREST,
"Forest",
@ -159,5 +162,6 @@ static final ThemeEntry FOREST_ENTRY = new ThemeEntry(
new GradientSpec(true, 0xFF161F18, 0xFF101611, 90f),
new GradientSpec(true, 0xFF7DD36B, 0xFFC6E886, 45f),
new GradientSpec(true, 0xFF9BE07C, 0xFF6BC26A, 45f)
);
)
.withBackdrop(Backdrop.of(Backdrop.Mode.DIGITAL_RAIN, 0.7f));
}

View file

@ -145,6 +145,7 @@ final class ThemeStoreJson {
gradients.add("card", gradientToJson(entry.cardGradient()));
gradients.add("stroke", gradientToJson(entry.strokeGradient()));
obj.add("gradients", gradients);
obj.add("backdrop", backdropToJson(entry.backdrop()));
return obj;
}
@ -179,7 +180,24 @@ final class ThemeStoreJson {
parseGradient(getObject(gradients, "header"), preset != null ? preset.headerGradient() : new GradientSpec(false, windowHeader, windowHeader, 90f), windowHeader),
parseGradient(getObject(gradients, "surface"), preset != null ? preset.surfaceGradient() : new GradientSpec(false, surface, surface, 90f), surface),
parseGradient(getObject(gradients, "card"), preset != null ? preset.cardGradient() : new GradientSpec(false, cardEnabled, cardEnabled, 90f), cardEnabled),
parseGradient(getObject(gradients, "stroke"), preset != null ? preset.strokeGradient() : new GradientSpec(false, windowStroke, windowStroke, 90f), windowStroke));
parseGradient(getObject(gradients, "stroke"), preset != null ? preset.strokeGradient() : new GradientSpec(false, windowStroke, windowStroke, 90f), windowStroke),
parseBackdrop(getObject(obj, "backdrop"), preset != null ? preset.backdrop() : Backdrop.NONE));
}
static JsonObject backdropToJson(Backdrop backdrop) {
JsonObject json = new JsonObject();
json.addProperty("mode", backdrop.mode().id());
json.addProperty("intensity", backdrop.intensity());
json.addProperty("speed", backdrop.speed());
json.addProperty("scale", backdrop.scale());
return json;
}
static Backdrop parseBackdrop(JsonObject map, Backdrop fallback) {
if (map == null || map.size() == 0) return fallback;
return new Backdrop(Backdrop.Mode.byId(getString(map, "mode", fallback.mode().id())),
getFloat(map, "intensity", fallback.intensity()), getFloat(map, "speed", fallback.speed()),
getFloat(map, "scale", fallback.scale()));
}
static GradientSpec parseGradient(JsonObject map, GradientSpec fallback, int baseColor) {

View file

@ -1,15 +1,18 @@
package dev.loki.lovisual.mixins.render.glint;
import com.llamalad7.mixinextras.injector.ModifyReturnValue;
import com.llamalad7.mixinextras.sugar.Local;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.items.glint.EnchantGlint;
import net.minecraft.client.renderer.rendertype.TextureTransform;
import net.minecraft.client.renderer.state.OptionsRenderState;
import org.joml.Matrix4f;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.ModifyVariable;
import org.spongepowered.asm.mixin.injection.Redirect;
/** EnchantGlint: скорость прокрутки и масштаб полос блеска в матрице текстуры. */
/** EnchantGlint: скорость прокрутки, масштаб полос и своя анимация стилей в матрице текстуры. */
@Mixin(TextureTransform.class)
public abstract class GlintTextureMixin {
@ -25,4 +28,11 @@ public abstract class GlintTextureMixin {
EnchantGlint glint = Modules.get(EnchantGlint.class);
return glint == null ? state.glintSpeed : state.glintSpeed * glint.speedFactor();
}
@ModifyReturnValue(method = "setupGlintTexturing", at = @At("RETURN"))
private static Matrix4f lovisual$styleMotion(Matrix4f vanilla, @Local(argsOnly = true) float scale) {
EnchantGlint glint = Modules.get(EnchantGlint.class);
Matrix4f custom = glint == null ? null : glint.motion(scale);
return custom == null ? vanilla : custom;
}
}

View file

@ -0,0 +1,24 @@
package dev.loki.lovisual.mixins.render.godrays;
import dev.loki.lovisual.features.module.modules.visuals.godrays.GodRaysSun;
import dev.loki.lovisual.mixins.accessors.world.LevelRendererAccessor;
import net.minecraft.client.renderer.LevelRenderer;
import net.minecraft.client.renderer.state.level.SkyRenderState;
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.CallbackInfo;
/** Records the sun angle and sunrise glow of the frame for the God Rays pass. */
@Mixin(LevelRenderer.class)
public abstract class GodRaysSkyMixin {
@Inject(method = "addSkyPass", at = @At("HEAD"))
private void lovisual$captureSun(CallbackInfo ci) {
SkyRenderState sky = ((LevelRendererAccessor) this).lovisual$getWorldRenderState().skyRenderState;
if (sky == null) {
GodRaysSun.invalidate();
} else {
GodRaysSun.capture(sky.sunAngle, sky.sunriseAndSunsetColor);
}
}
}

View file

@ -0,0 +1,46 @@
package dev.loki.lovisual.mixins.render.knife;
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.knife.Knife;
import net.minecraft.client.Minecraft;
import net.minecraft.client.player.LocalPlayer;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.entity.layers.PlayerItemInHandLayer;
import net.minecraft.client.renderer.entity.player.AvatarRenderer;
import net.minecraft.client.renderer.entity.state.AvatarRenderState;
import net.minecraft.client.renderer.item.ItemStackRenderState;
import net.minecraft.world.entity.HumanoidArm;
import net.minecraft.world.item.ItemStack;
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.CallbackInfo;
/** Knife: draws the skin in your own third person hand instead of the sword. */
@Mixin(PlayerItemInHandLayer.class)
public abstract class KnifeHandLayerMixin {
private static final float HAND_PITCH_DEGREES = -90.0f;
private static final float HAND_YAW_DEGREES = 180.0f;
@Inject(method = "submitArmWithItem(Lnet/minecraft/client/renderer/entity/state/AvatarRenderState;Lnet/minecraft/client/renderer/item/ItemStackRenderState;Lnet/minecraft/world/item/ItemStack;Lnet/minecraft/world/entity/HumanoidArm;Lcom/mojang/blaze3d/vertex/PoseStack;Lnet/minecraft/client/renderer/SubmitNodeCollector;I)V",
at = @At("HEAD"), cancellable = true)
private void lovisual$knifeThirdPerson(AvatarRenderState state, ItemStackRenderState itemState, ItemStack item,
HumanoidArm arm, PoseStack matrices, SubmitNodeCollector queue, int light,
CallbackInfo ci) {
Knife knife = Modules.get(Knife.class);
Minecraft mc = Minecraft.getInstance();
LocalPlayer player = mc.player;
if (knife == null || player == null || player.getId() != state.id || !Knife.isKnife(item)) return;
AvatarRenderer<?> renderer = mc.getEntityRenderDispatcher().getPlayerRenderer(player);
if (renderer == null) return;
matrices.pushPose();
renderer.getModel().translateToHand(state, arm, matrices);
matrices.mulPose(Axis.XP.rotationDegrees(HAND_PITCH_DEGREES));
matrices.mulPose(Axis.YP.rotationDegrees(HAND_YAW_DEGREES));
boolean drawn = knife.renderThirdPerson(item, arm, matrices, queue, light);
matrices.popPose();
if (drawn) ci.cancel();
}
}

View file

@ -0,0 +1,27 @@
package dev.loki.lovisual.mixins.render.knife;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.knife.Knife;
import net.minecraft.client.Minecraft;
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.CallbackInfo;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/** Knife: left click plays the attack clips, right click the third one. */
@Mixin(Minecraft.class)
public abstract class KnifeInputMixin {
@Inject(method = "startAttack", at = @At("HEAD"))
private void lovisual$knifePrimary(CallbackInfoReturnable<Boolean> cir) {
Knife knife = Modules.get(Knife.class);
if (knife != null) knife.onAttack(true);
}
@Inject(method = "startUseItem", at = @At("RETURN"))
private void lovisual$knifeSecondary(CallbackInfo ci) {
Knife knife = Modules.get(Knife.class);
if (knife != null) knife.onAttack(false);
}
}

View file

@ -0,0 +1,31 @@
package dev.loki.lovisual.mixins.render.knife;
import com.mojang.blaze3d.vertex.PoseStack;
import dev.loki.lovisual.features.module.lifecycle.Modules;
import dev.loki.lovisual.features.module.modules.visuals.knife.Knife;
import net.minecraft.client.player.AbstractClientPlayer;
import net.minecraft.client.renderer.ItemInHandRenderer;
import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
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.CallbackInfo;
/**
* Knife: replaces the first person sword with the skin's view model. Runs before the other hand hooks
* (low priority) so cancelling here cannot leave their pushed poses unpopped.
*/
@Mixin(value = ItemInHandRenderer.class, priority = 500)
public abstract class KnifeItemInHandMixin {
@Inject(method = "submitArmWithItem", at = @At("HEAD"), cancellable = true)
private void lovisual$knifeFirstPerson(AbstractClientPlayer player, float tickDelta, float pitch,
InteractionHand hand, float swingProgress, ItemStack item, float equipProgress,
PoseStack matrices, SubmitNodeCollector queue, int light, CallbackInfo ci) {
Knife knife = Modules.get(Knife.class);
if (knife == null || player.isScoping()) return;
if (knife.renderFirstPerson(player, hand, item, matrices, queue, light, equipProgress)) ci.cancel();
}
}

View file

@ -141,6 +141,8 @@ public enum LoVisualRenderPipelines {
public static final RenderPipeline POST_FX = add(ScreenPipelines.screen("post_fx", SHADER_POST_FX_FRAG, "PostFX", "u_Texture", "u_Lut"));
/** Fullscreen camera-only perceptual motion blur (pos2). */
public static final RenderPipeline MOTION_BLUR = add(ScreenPipelines.screen("motion_blur", SHADER_MOTION_BLUR_FRAG, "MotionBlur", "u_Texture", "u_PreviousColor"));
/** Fullscreen sun shafts: radial sky blur towards the sun, added to the scene (pos2). */
public static final RenderPipeline GOD_RAYS = add(ScreenPipelines.screen("godrays", SHADER_GOD_RAYS_FRAG, "GodRays", "u_Texture", "u_MainDepth"));
/** Fullscreen scene-depth-aware far depth of field (pos2). */
public static final RenderPipeline DEPTH_OF_FIELD = add(ScreenPipelines.screen("depth_of_field", SHADER_DEPTH_OF_FIELD_FRAG, "DepthOfField",
"u_Texture", "u_MainDepth", "u_TranslucentDepth", "u_ItemEntityDepth", "u_ParticlesDepth", "u_WeatherDepth", "u_CloudsDepth"));
@ -212,6 +214,8 @@ public enum LoVisualRenderPipelines {
LoVisualVertexFormats.POS2_LOCAL_COLOR_RECT_PARAMS, SHADER_POS_LOCAL_COLOR_RECT_PARAMS_VERT, SHADER_ROUNDED_RECT_BATCH_FRAG, BlendFunction.TRANSLUCENT, true));
public static final RenderPipeline UI_ROUNDED_FILL_SMOKE_BATCH = add(UiPipelines.batch("ui_rounded_fill_smoke_batch",
LoVisualVertexFormats.POS2_LOCAL_COLOR_RECT_PARAMS5, SHADER_POS_LOCAL_COLOR_RECT_PARAMS5_VERT, SHADER_UI_ROUNDED_FILL_SMOKE_BATCH_FRAG, BlendFunction.TRANSLUCENT, true));
public static final RenderPipeline UI_THEME_BACKDROP_BATCH = add(UiPipelines.batch("ui_theme_backdrop_batch",
LoVisualVertexFormats.POS2_LOCAL_COLOR_RECT_PARAMS5, SHADER_POS_LOCAL_COLOR_RECT_PARAMS5_VERT, SHADER_UI_THEME_BACKDROP_BATCH_FRAG, BlendFunction.TRANSLUCENT, true));
public static final RenderPipeline UI_ROUNDED_STROKE_BATCH = add(UiPipelines.batch("ui_rounded_stroke_batch",
LoVisualVertexFormats.POS2_LOCAL_COLOR_RECT_PARAMS, SHADER_POS_LOCAL_COLOR_RECT_PARAMS_VERT, SHADER_ROUNDED_RECT_STROKE_BATCH_FRAG, BlendFunction.TRANSLUCENT, true));
public static final RenderPipeline UI_ROUNDED_STROKE_ANGULAR_BATCH = add(UiPipelines.batch("ui_rounded_stroke_angular_batch",

View file

@ -61,6 +61,7 @@ public final class ShaderIds {
public static final Identifier SHADER_ROUNDED_RECT_STROKE_CORNERS_BATCH_FRAG = shader("rounded_rect_stroke_corners_batch.frag");
public static final Identifier SHADER_ROUNDED_RECT_BATCH_FRAG = shader("rounded_rect_batch.frag");
public static final Identifier SHADER_UI_ROUNDED_FILL_SMOKE_BATCH_FRAG = shader("ui_rounded_fill_smoke_batch.frag");
public static final Identifier SHADER_UI_THEME_BACKDROP_BATCH_FRAG = shader("ui_theme_backdrop_batch.frag");
public static final Identifier SHADER_ROUNDED_RECT_STROKE_BATCH_FRAG = shader("rounded_rect_stroke_batch.frag");
public static final Identifier SHADER_ROUNDED_RECT_STROKE_ANGULAR_BATCH_FRAG = shader("rounded_rect_stroke_angular_batch.frag");
public static final Identifier SHADER_CHAMFERED_RECT_BATCH_FRAG = shader("chamfered_rect_batch.frag");
@ -81,6 +82,7 @@ public final class ShaderIds {
public static final Identifier SHADER_DAMAGE_TINT_FRAG = shader("damage_tint.frag");
public static final Identifier SHADER_POST_FX_FRAG = shader("post_fx.frag");
public static final Identifier SHADER_MOTION_BLUR_FRAG = shader("motion_blur.frag");
public static final Identifier SHADER_GOD_RAYS_FRAG = shader("godrays.frag");
public static final Identifier SHADER_DEPTH_OF_FIELD_FRAG = shader("depth_of_field.frag");
public static final Identifier SHADER_WET_SURFACE_FRAG = shader("wet_surface.frag");
public static final Identifier SHADER_HEAT_FX_FRAG = shader("heat_fx.frag");

View file

@ -0,0 +1,89 @@
package dev.loki.lovisual.render.engine.renderer.renderer2d.fx.backdrop;
import dev.loki.lovisual.features.theme.Backdrop;
import dev.loki.lovisual.render.engine.renderer.Renderer2D;
import dev.loki.lovisual.render.engine.renderer.renderer2d.Batch2D;
import dev.loki.lovisual.render.engine.renderer.ui.batches.DrawBatch;
import dev.loki.lovisual.render.engine.renderer.ui.batches.ordered.UiBatchType;
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
/** Draws a theme's animated shader backdrop into a rounded rect, optionally as one side of a wipe. */
public final class ThemeBackdrop2D {
/** How a draw takes part in the theme-change wipe. */
public enum Wipe {
NONE(0.0f), REVEAL(1.0f), ERASE(2.0f);
private final float kind;
Wipe(float kind) {
this.kind = kind;
}
}
private static final double CLOCK_WRAP_SECONDS = 4096.0;
private static final float DARK_SHARE = 0.55f;
private static final float LIGHT_SHARE = 0.45f;
private ThemeBackdrop2D() {
}
/** The three ramp colours of a backdrop: a darkened accent, the accent, and the accent lifted towards the text colour. */
public static int[] palette(int accent, int text) {
return new int[]{scale(accent, DARK_SHARE), accent | 0xFF000000, mix(accent, text, LIGHT_SHARE)};
}
public static float clock() {
return (float) (System.nanoTime() / 1.0E9 % CLOCK_WRAP_SECONDS);
}
public static void draw(Renderer2D r, double x, double y, double w, double h, float radius, Backdrop backdrop,
int accent, int text, float opacity, Wipe wipe, float wipeProgress) {
if (w <= 0.0 || h <= 0.0 || !backdrop.isActive() || opacity <= 0.0f) return;
DrawBatch batch = Batch2D.open(r, UiBatchType.THEME_BACKDROP);
if (batch == null) return;
MeshBuilder mesh = batch.mesh;
mesh.ensureQuadCapacity();
int[] colors = palette(accent, text);
int[] first = Batch2D.rgba(colors[0]);
float[] second = Batch2D.rgbaNorm(colors[1]);
float[] third = Batch2D.rgbaNorm(colors[2]);
first[3] = Math.round(Math.max(0.0f, Math.min(opacity, 1.0f)) * 255.0f);
float time = clock();
float mode = backdrop.mode().shaderIndex();
int i1 = vertex(mesh, x, y, x, y, w, h, first, second, third, radius, mode, backdrop, time, wipe, wipeProgress);
int i2 = vertex(mesh, x, y + h, x, y, w, h, first, second, third, radius, mode, backdrop, time, wipe, wipeProgress);
int i3 = vertex(mesh, x + w, y + h, x, y, w, h, first, second, third, radius, mode, backdrop, time, wipe, wipeProgress);
int i4 = vertex(mesh, x + w, y, x, y, w, h, first, second, third, radius, mode, backdrop, time, wipe, wipeProgress);
mesh.quad(i1, i2, i3, i4);
Batch2D.close();
}
private static int vertex(MeshBuilder mesh, double px, double py, double rx, double ry, double w, double h,
int[] first, float[] second, float[] third, float radius, float mode, Backdrop backdrop,
float time, Wipe wipe, float wipeProgress) {
return mesh.vec2(px, py).local2(px, py).color(first[0], first[1], first[2], first[3])
.vec4((float) rx, (float) ry, (float) w, (float) h)
.vec4(radius, 0.0f, mode, backdrop.intensity())
.vec4(second[0], second[1], second[2], second[3])
.vec4(third[0], third[1], third[2], third[3])
.vec4(time, backdrop.scale(), backdrop.speed(), 0.0f)
.vec4(0.5f, 0.5f, wipeProgress, wipe.kind)
.next();
}
private static int scale(int argb, float factor) {
int r = Math.round((argb >> 16 & 0xFF) * factor);
int g = Math.round((argb >> 8 & 0xFF) * factor);
int b = Math.round((argb & 0xFF) * factor);
return 0xFF000000 | r << 16 | g << 8 | b;
}
private static int mix(int from, int to, float t) {
int r = Math.round((from >> 16 & 0xFF) + ((to >> 16 & 0xFF) - (from >> 16 & 0xFF)) * t);
int g = Math.round((from >> 8 & 0xFF) + ((to >> 8 & 0xFF) - (from >> 8 & 0xFF)) * t);
int b = Math.round((from & 0xFF) + ((to & 0xFF) - (from & 0xFF)) * t);
return 0xFF000000 | r << 16 | g << 8 | b;
}
}

View file

@ -13,6 +13,7 @@ public enum UiBatchType {
ORBIZ_RING(LoVisualRenderPipelines.UI_ORBIZ_RING_BATCH, true, false),
ROUNDED(LoVisualRenderPipelines.UI_ROUNDED_BATCH, true, false),
ROUNDED_FILL_SMOKE(LoVisualRenderPipelines.UI_ROUNDED_FILL_SMOKE_BATCH, true, false),
THEME_BACKDROP(LoVisualRenderPipelines.UI_THEME_BACKDROP_BATCH, true, false),
ROUNDED_STROKE(LoVisualRenderPipelines.UI_ROUNDED_STROKE_BATCH, true, false),
ROUNDED_STROKE_ANGULAR(LoVisualRenderPipelines.UI_ROUNDED_STROKE_ANGULAR_BATCH, true, false),
CHAMFERED(LoVisualRenderPipelines.UI_CHAMFERED_BATCH, true, false),

View file

@ -0,0 +1,63 @@
package dev.loki.lovisual.render.engine.uniform.impl.godrays;
import com.mojang.blaze3d.buffers.GpuBufferSlice;
import com.mojang.blaze3d.buffers.Std140Builder;
import com.mojang.blaze3d.buffers.Std140SizeCalculator;
import dev.loki.lovisual.render.engine.core.system.LoVisualRenderSystem;
import dev.loki.lovisual.render.engine.rhi.uniform.LoVisualUniformAllocator;
public enum GodRaysUniforms {
;
public static final int SIZE = new Std140SizeCalculator()
.putVec4().putVec4().putVec4().putVec4()
.get();
private static final Data DATA = new Data();
private static final String UNIFORM_NAME = "LoVisual - GodRays UBO";
private static final int EXPECTED_WRITES_PER_FRAME = 2;
public static void update(float sunU, float sunV, float reach, int samples, float sampleWeight,
float strength, float decay, float red, float green, float blue, float fade,
int width, int height) {
DATA.sunU = sunU;
DATA.sunV = sunV;
DATA.reach = reach;
DATA.samples = samples;
DATA.sampleWeight = sampleWeight;
DATA.strength = strength;
DATA.decay = decay;
DATA.red = red;
DATA.green = green;
DATA.blue = blue;
DATA.fade = fade;
DATA.width = width;
DATA.height = height;
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 float sunU, sunV, reach, samples;
private float sampleWeight, strength, decay;
private float red, green, blue, fade;
private int width, height;
@Override
public void write(java.nio.ByteBuffer buffer) {
Std140Builder b = Std140Builder.intoBuffer(buffer);
b.putFloat(sunU).putFloat(sunV).putFloat(reach).putFloat(0.0f);
b.putFloat(samples).putFloat(sampleWeight).putFloat(strength).putFloat(decay);
b.putFloat(red).putFloat(green).putFloat(blue).putFloat(fade);
b.putFloat(width > 0 ? 1.0f / width : 0.0f).putFloat(height > 0 ? 1.0f / height : 0.0f)
.putFloat(width).putFloat(height);
}
@Override
public boolean equals(Object o) {
return false;
}
}
}

View file

@ -0,0 +1,152 @@
package dev.loki.lovisual.util.bedrock.anim;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* One animated vector channel (rotation, position or scale of a bone): keyframes with separate values arriving
* at and leaving each key, blended linearly or, where a key asks for it, along a Catmull-Rom spline.
*/
public final class Channel {
private final float[] times;
private final float[] pre;
private final float[] post;
private final boolean[] smooth;
private Channel(float[] times, float[] pre, float[] post, boolean[] smooth) {
this.times = times;
this.pre = pre;
this.post = post;
this.smooth = smooth;
}
public float lastTime() {
return times[times.length - 1];
}
/** Writes the three components at {@code time} into {@code out} starting at {@code at}. */
public void sample(float time, float[] out, int at) {
int last = times.length - 1;
if (time <= times[0]) {
copy(pre, 0, out, at);
return;
}
if (time >= times[last]) {
copy(post, last, out, at);
return;
}
int i = 0;
while (times[i + 1] <= time) i++;
float span = times[i + 1] - times[i];
if (span <= 0.0f) {
copy(post, i, out, at);
return;
}
float f = (time - times[i]) / span;
if (smooth[i]) spline(i, f, out, at, last);
else lerp(i, f, out, at);
}
private void lerp(int i, float f, float[] out, int at) {
for (int a = 0; a < 3; a++) {
float from = post[i * 3 + a];
out[at + a] = from + (pre[(i + 1) * 3 + a] - from) * f;
}
}
private void spline(int i, float f, float[] out, int at, int last) {
int before = Math.max(i - 1, 0) * 3;
int after = Math.min(i + 2, last) * 3;
float f2 = f * f;
float f3 = f2 * f;
for (int a = 0; a < 3; a++) {
float p0 = post[before + a];
float p1 = post[i * 3 + a];
float p2 = pre[(i + 1) * 3 + a];
float p3 = pre[after + a];
out[at + a] = 0.5f * (2.0f * p1 + (p2 - p0) * f
+ (2.0f * p0 - 5.0f * p1 + 4.0f * p2 - p3) * f2
+ (-p0 + 3.0f * p1 - 3.0f * p2 + p3) * f3);
}
}
private static void copy(float[] from, int index, float[] out, int at) {
System.arraycopy(from, index * 3, out, at, 3);
}
/** A channel is a constant vector or a map of time to key; null when absent or empty. */
public static Channel parse(JsonElement element) {
if (element == null) return null;
if (element.isJsonArray()) {
float[] v = vector(element);
return new Channel(new float[]{0.0f}, v.clone(), v, new boolean[]{false});
}
if (!element.isJsonObject()) return null;
List<Object[]> keys = new ArrayList<>();
for (Map.Entry<String, JsonElement> entry : element.getAsJsonObject().entrySet()) {
float time;
try {
time = Float.parseFloat(entry.getKey());
} catch (NumberFormatException e) {
continue;
}
keys.add(new Object[]{time, entry.getValue()});
}
if (keys.isEmpty()) return null;
keys.sort((a, b) -> Float.compare((Float) a[0], (Float) b[0]));
int n = keys.size();
float[] times = new float[n];
float[] pre = new float[n * 3];
float[] post = new float[n * 3];
boolean[] smooth = new boolean[n];
for (int i = 0; i < n; i++) {
times[i] = (Float) keys.get(i)[0];
JsonElement key = (JsonElement) keys.get(i)[1];
float[] arriving;
float[] leaving;
if (key.isJsonObject()) {
JsonObject object = key.getAsJsonObject();
leaving = object.has("post") ? vector(object.get("post")) : object.has("vector") ? vector(object.get("vector")) : null;
arriving = object.has("pre") ? vector(object.get("pre")) : null;
if (leaving == null) leaving = arriving == null ? new float[3] : arriving;
if (arriving == null) arriving = leaving;
smooth[i] = object.has("lerp_mode") && "catmullrom".equals(object.get("lerp_mode").getAsString());
} else {
leaving = vector(key);
arriving = leaving;
}
System.arraycopy(arriving, 0, pre, i * 3, 3);
System.arraycopy(leaving, 0, post, i * 3, 3);
}
return new Channel(times, pre, post, smooth);
}
private static float[] vector(JsonElement value) {
float[] out = new float[3];
if (value.isJsonArray()) {
JsonArray array = value.getAsJsonArray();
for (int i = 0; i < Math.min(3, array.size()); i++) out[i] = number(array.get(i));
} else if (value.isJsonObject()) {
JsonObject object = value.getAsJsonObject();
for (String key : new String[]{"post", "vector", "pre"}) {
if (object.has(key)) return vector(object.get(key));
}
} else {
java.util.Arrays.fill(out, number(value));
}
return out;
}
private static float number(JsonElement element) {
try {
return element.getAsFloat();
} catch (RuntimeException e) {
return 0.0f;
}
}
}

View file

@ -0,0 +1,124 @@
package dev.loki.lovisual.util.bedrock.anim;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/** One Bedrock animation: its length, looping, per-bone channels and timed sound events. */
public final class Clip {
/** The channels of one bone; any of them may be null. */
public record Tracks(Channel rotation, Channel position, Channel scale) {
}
private final String name;
private final float length;
private final boolean loop;
private final Map<String, Tracks> bones;
private final float[] soundTimes;
private final String[] soundNames;
private Clip(String name, float length, boolean loop, Map<String, Tracks> bones, float[] soundTimes, String[] soundNames) {
this.name = name;
this.length = length;
this.loop = loop;
this.bones = bones;
this.soundTimes = soundTimes;
this.soundNames = soundNames;
}
public String name() {
return name;
}
public float length() {
return length;
}
public boolean loops() {
return loop;
}
public Map<String, Tracks> bones() {
return bones;
}
public int soundCount() {
return soundTimes.length;
}
public float soundTime(int index) {
return soundTimes[index];
}
public String soundName(int index) {
return soundNames[index];
}
/** Every clip of an animation file, keyed by name without its {@code animation.<model>.} prefix. */
public static Map<String, Clip> parseAll(String json) {
JsonObject root = JsonParser.parseString(json).getAsJsonObject();
JsonObject clips = root.has("animations") ? root.getAsJsonObject("animations") : root;
Map<String, Clip> out = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : clips.entrySet()) {
if (!entry.getValue().isJsonObject()) continue;
JsonObject clip = entry.getValue().getAsJsonObject();
if (!clip.has("bones")) continue;
String name = entry.getKey().substring(entry.getKey().lastIndexOf('.') + 1);
out.put(name, parse(name, clip));
}
return out;
}
private static Clip parse(String name, JsonObject clip) {
Map<String, Tracks> bones = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : clip.getAsJsonObject("bones").entrySet()) {
if (!entry.getValue().isJsonObject()) continue;
JsonObject channels = entry.getValue().getAsJsonObject();
bones.put(entry.getKey(), new Tracks(Channel.parse(channels.get("rotation")),
Channel.parse(channels.get("position")), Channel.parse(channels.get("scale"))));
}
float length = clip.has("animation_length") ? clip.get("animation_length").getAsFloat() : 0.0f;
if (length <= 0.0f) length = lastKey(bones);
boolean loop = !clip.has("loop") || !clip.get("loop").isJsonPrimitive() || !clip.get("loop").getAsJsonPrimitive().isBoolean()
|| clip.get("loop").getAsBoolean();
List<float[]> sounds = new ArrayList<>();
List<String> names = new ArrayList<>();
if (clip.has("sound_effects") && clip.get("sound_effects").isJsonObject()) {
for (Map.Entry<String, JsonElement> entry : clip.getAsJsonObject("sound_effects").entrySet()) {
JsonElement value = entry.getValue();
if (!value.isJsonObject() || !value.getAsJsonObject().has("effect")) continue;
try {
sounds.add(new float[]{Float.parseFloat(entry.getKey())});
names.add(value.getAsJsonObject().get("effect").getAsString());
} catch (NumberFormatException ignored) {
}
}
}
float[] times = new float[sounds.size()];
String[] effects = new String[times.length];
Integer[] order = new Integer[times.length];
for (int i = 0; i < order.length; i++) order[i] = i;
java.util.Arrays.sort(order, (a, b) -> Float.compare(sounds.get(a)[0], sounds.get(b)[0]));
for (int i = 0; i < order.length; i++) {
times[i] = sounds.get(order[i])[0];
effects[i] = names.get(order[i]);
}
return new Clip(name, length, loop, bones, times, effects);
}
private static float lastKey(Map<String, Tracks> bones) {
float last = 0.0f;
for (Tracks tracks : bones.values()) {
for (Channel channel : new Channel[]{tracks.rotation(), tracks.position(), tracks.scale()}) {
if (channel != null) last = Math.max(last, channel.lastTime());
}
}
return last;
}
}

View file

@ -0,0 +1,44 @@
package dev.loki.lovisual.util.bedrock.geo;
import org.joml.Matrix4f;
import java.util.ArrayList;
import java.util.List;
/**
* One bone of a Bedrock model. Pivots and offsets are in model pixels, rotations in radians, already in the
* entity frame (x mirrored). The {@code rot/offset/scale} arrays are the animated pose, the {@code base*}
* ones the pose stored in the file.
*/
public final class GeoBone {
public final String name;
public final float[] pivot;
public final float[] baseRot;
public final float[] rot = new float[3];
public final float[] offset = new float[3];
public final float[] scale = {1.0f, 1.0f, 1.0f};
public final List<GeoCube> cubes = new ArrayList<>();
public final List<GeoBone> children = new ArrayList<>();
GeoBone(String name, float[] pivot, float[] baseRot) {
this.name = name;
this.pivot = pivot;
this.baseRot = baseRot;
resetPose();
}
public void resetPose() {
System.arraycopy(baseRot, 0, rot, 0, 3);
java.util.Arrays.fill(offset, 0.0f);
java.util.Arrays.fill(scale, 1.0f);
}
/** Applies this bone's pose to {@code matrix}: offset, then rotation and scale about the pivot. */
public Matrix4f transform(Matrix4f matrix) {
return matrix.translate(offset[0] / 16.0f, offset[1] / 16.0f, offset[2] / 16.0f)
.translate(pivot[0] / 16.0f, pivot[1] / 16.0f, pivot[2] / 16.0f)
.rotateZYX(rot[2], rot[1], rot[0])
.scale(scale[0], scale[1], scale[2])
.translate(-pivot[0] / 16.0f, -pivot[1] / 16.0f, -pivot[2] / 16.0f);
}
}

View file

@ -0,0 +1,23 @@
package dev.loki.lovisual.util.bedrock.geo;
import org.joml.Matrix4f;
import java.util.List;
/**
* A cube of a bone: its own pivot and rotation (radians) plus the baked textured faces in model pixels.
* Faces are stored as four corners (x, y, z, u, v each, in texture pixels) in drawing order.
*/
public record GeoCube(float[] pivot, float[] rot, List<float[]> faces) {
public boolean rotated() {
return rot[0] != 0.0f || rot[1] != 0.0f || rot[2] != 0.0f;
}
/** Applies the cube's own rotation about its pivot to {@code matrix} (blocks). */
public Matrix4f transform(Matrix4f matrix) {
return matrix.translate(pivot[0] / 16.0f, pivot[1] / 16.0f, pivot[2] / 16.0f)
.rotateZYX(rot[2], rot[1], rot[0])
.translate(-pivot[0] / 16.0f, -pivot[1] / 16.0f, -pivot[2] / 16.0f);
}
}

View file

@ -0,0 +1,143 @@
package dev.loki.lovisual.util.bedrock.geo;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.util.ArrayList;
import java.util.List;
/**
* Turns a Bedrock cube (origin, size, inflate, box or per-face uv) into textured quads in the entity frame,
* where x is mirrored. Every texture runs towards the viewer's right and down as seen from outside the face,
* so a painted texture reads the same way it does in Blockbench.
*/
final class GeoFaces {
/** Face order: north, south, east, west, up, down. */
private static final String[] NAMES = {"north", "south", "east", "west", "up", "down"};
/** Per face: viewer-right axis, viewer-down axis, outward normal axis (as signed unit vectors). */
private static final float[][] AXES = {
{-1, 0, 0, 0, -1, 0, 0, 0, -1},
{1, 0, 0, 0, -1, 0, 0, 0, 1},
{0, 0, 1, 0, -1, 0, -1, 0, 0},
{0, 0, -1, 0, -1, 0, 1, 0, 0},
{-1, 0, 0, 0, 0, -1, 0, 1, 0},
{1, 0, 0, 0, 0, -1, 0, -1, 0},
};
private GeoFaces() {
}
static List<float[]> build(JsonObject cube, float[] origin, float[] size, float inflate) {
float[] centre = new float[3];
float[] half = new float[3];
// x is mirrored into the entity frame
centre[0] = -(origin[0] + size[0] / 2.0f);
centre[1] = origin[1] + size[1] / 2.0f;
centre[2] = origin[2] + size[2] / 2.0f;
half[0] = size[0] / 2.0f + inflate;
half[1] = size[1] / 2.0f + inflate;
half[2] = size[2] / 2.0f + inflate;
float[][] regions = regions(cube, size);
// a flat cube has two coincident planes; drawing both would z-fight, and the faces are double sided anyway
dropTwin(regions, 0, 1, half[2]);
dropTwin(regions, 2, 3, half[0]);
dropTwin(regions, 4, 5, half[1]);
List<float[]> faces = new ArrayList<>();
for (int face = 0; face < 6; face++) {
float[] region = regions[face];
if (region == null) continue;
float[] axes = AXES[face];
float[] extent = extent(face, half);
if (extent[0] <= 0.0f || extent[1] <= 0.0f) continue;
faces.add(quad(centre, half, axes, extent, region));
}
return faces;
}
private static void dropTwin(float[][] regions, int keep, int twin, float halfThickness) {
if (halfThickness > 0.0f) return;
if (regions[keep] != null) regions[twin] = null;
}
/** Half extents of a face along its right and down axes. */
private static float[] extent(int face, float[] half) {
return switch (face) {
case 0, 1 -> new float[]{half[0], half[1]};
case 2, 3 -> new float[]{half[2], half[1]};
default -> new float[]{half[0], half[2]};
};
}
private static float[] quad(float[] centre, float[] half, float[] axes, float[] extent, float[] region) {
float[] out = new float[20];
float[][] corners = {{-1, -1}, {1, -1}, {1, 1}, {-1, 1}};
int rotation = Math.floorMod((int) region[4] / 90, 4);
float depth = Math.abs(axes[6]) * half[0] + Math.abs(axes[7]) * half[1] + Math.abs(axes[8]) * half[2];
for (int i = 0; i < 4; i++) {
float sx = corners[i][0] * extent[0];
float sy = corners[i][1] * extent[1];
for (int a = 0; a < 3; a++) {
out[i * 5 + a] = centre[a] + axes[a] * sx + axes[3 + a] * sy + axes[6 + a] * depth;
}
// uv_rotation turns the texture a quarter turn per step by shifting which corner gets which uv
float[] uv = corner(region, (i + rotation) % 4);
out[i * 5 + 3] = uv[0];
out[i * 5 + 4] = uv[1];
}
return out;
}
private static float[] corner(float[] region, int index) {
float u0 = region[0];
float v0 = region[1];
float u1 = u0 + region[2];
float v1 = v0 + region[3];
return switch (index) {
case 0 -> new float[]{u0, v0};
case 1 -> new float[]{u1, v0};
case 2 -> new float[]{u1, v1};
default -> new float[]{u0, v1};
};
}
/** Per face {u, v, width, height, rotation} in texture pixels, or null when the cube has no such face. */
private static float[][] regions(JsonObject cube, float[] size) {
float[][] out = new float[6][];
JsonElement uv = cube.get("uv");
if (uv == null) return out;
if (uv.isJsonArray()) {
boxRegions(out, uv.getAsJsonArray(), size, cube.has("mirror") && cube.get("mirror").getAsBoolean());
} else if (uv.isJsonObject()) {
JsonObject faces = uv.getAsJsonObject();
for (int face = 0; face < 6; face++) {
if (!faces.has(NAMES[face])) continue;
JsonObject data = faces.getAsJsonObject(NAMES[face]);
JsonArray at = data.getAsJsonArray("uv");
JsonArray span = data.has("uv_size") ? data.getAsJsonArray("uv_size") : null;
float w = span == null ? 0 : span.get(0).getAsFloat();
float h = span == null ? 0 : span.get(1).getAsFloat();
float rotation = data.has("uv_rotation") ? data.get("uv_rotation").getAsFloat() : 0;
out[face] = new float[]{at.get(0).getAsFloat(), at.get(1).getAsFloat(), w, h, rotation};
}
}
return out;
}
private static void boxRegions(float[][] out, JsonArray uv, float[] size, boolean mirror) {
float u = uv.get(0).getAsFloat();
float v = uv.get(1).getAsFloat();
float w = size[0];
float h = size[1];
float d = size[2];
float[] east = {u, v + d, d, h, 0};
float[] west = {u + d + w, v + d, d, h, 0};
out[0] = new float[]{u + d, v + d, w, h, 0};
out[1] = new float[]{u + 2 * d + w, v + d, w, h, 0};
out[2] = mirror ? west : east;
out[3] = mirror ? east : west;
out[4] = new float[]{u + d, v, w, d, 0};
out[5] = new float[]{u + d + w, v + d, w, -d, 0};
}
}

View file

@ -0,0 +1,83 @@
package dev.loki.lovisual.util.bedrock.geo;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Reads a Bedrock {@code .geo.json} (formats 1.12 and 1.21) into a bone tree. Pivots, origins and rotations
* are moved into the entity frame: x is mirrored, and rotations become (-x, -y, z) radians.
*/
public final class GeoParser {
/** A parsed model: the root bones, every bone by name and the texture size the uv are measured in. */
public record Model(List<GeoBone> roots, Map<String, GeoBone> bones, int textureWidth, int textureHeight) {
public void resetPose() {
for (GeoBone bone : bones.values()) bone.resetPose();
}
}
private GeoParser() {
}
public static Model parse(String json) {
JsonObject geometry = JsonParser.parseString(json).getAsJsonObject()
.getAsJsonArray("minecraft:geometry").get(0).getAsJsonObject();
JsonObject description = geometry.getAsJsonObject("description");
int width = description.has("texture_width") ? description.get("texture_width").getAsInt() : 64;
int height = description.has("texture_height") ? description.get("texture_height").getAsInt() : 64;
Map<String, GeoBone> bones = new LinkedHashMap<>();
Map<String, String> parents = new LinkedHashMap<>();
for (JsonElement entry : geometry.getAsJsonArray("bones")) {
JsonObject data = entry.getAsJsonObject();
String name = data.get("name").getAsString();
GeoBone bone = new GeoBone(name, pivot(data), rotation(data));
if (data.has("cubes")) {
for (JsonElement cube : data.getAsJsonArray("cubes")) bone.cubes.add(cube(cube.getAsJsonObject()));
}
bones.put(name, bone);
if (data.has("parent")) parents.put(name, data.get("parent").getAsString());
}
List<GeoBone> roots = new ArrayList<>();
for (GeoBone bone : bones.values()) {
GeoBone parent = bones.get(parents.get(bone.name));
if (parent == null || parent == bone) roots.add(bone);
else parent.children.add(bone);
}
return new Model(roots, bones, width, height);
}
private static GeoCube cube(JsonObject data) {
float[] origin = vec(data, "origin", 0.0f);
float[] size = vec(data, "size", 0.0f);
float inflate = data.has("inflate") ? data.get("inflate").getAsFloat() : 0.0f;
float[] pivot = data.has("pivot") ? vec(data, "pivot", 0.0f)
: new float[]{origin[0] + size[0] / 2.0f, origin[1] + size[1] / 2.0f, origin[2] + size[2] / 2.0f};
float[] pivotMirrored = {-pivot[0], pivot[1], pivot[2]};
return new GeoCube(pivotMirrored, rotation(data), GeoFaces.build(data, origin, size, inflate));
}
private static float[] pivot(JsonObject data) {
float[] pivot = vec(data, "pivot", 0.0f);
return new float[]{-pivot[0], pivot[1], pivot[2]};
}
private static float[] rotation(JsonObject data) {
float[] degrees = vec(data, "rotation", 0.0f);
return new float[]{(float) Math.toRadians(-degrees[0]), (float) Math.toRadians(-degrees[1]), (float) Math.toRadians(degrees[2])};
}
private static float[] vec(JsonObject data, String key, float fallback) {
float[] out = {fallback, fallback, fallback};
if (!data.has(key) || !data.get(key).isJsonArray()) return out;
JsonArray array = data.getAsJsonArray(key);
for (int i = 0; i < Math.min(3, array.size()); i++) out[i] = array.get(i).getAsFloat();
return out;
}
}