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:
parent
6c641c4a94
commit
8bc884c297
278 changed files with 36225 additions and 56 deletions
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
124
mod/src/main/java/dev/loki/lovisual/util/bedrock/anim/Clip.java
Normal file
124
mod/src/main/java/dev/loki/lovisual/util/bedrock/anim/Clip.java
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue