From f42745be42395efc4453f7f07566ee10e471f011 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 24 Sep 2026 22:48:40 +0200 Subject: [PATCH 1/4] feat(bedrock): render custom entities and player skins natively Use ViaBedrock resource pack definitions and entity state to render Bedrock geometry in the Fabric client. Apply Bedrock player skins, capes, and custom geometry without a separate utility mod. Keep the protocol translation and pack conversion in ViaBedrock, and retain normal interaction rendering for non-Bedrock entities. --- .../friends/FriendWorldSkinProvider.java | 8 + .../render/MixinAbstractClientPlayer.java | 38 ++++ .../features/render/MixinCustomEntity.java | 56 +++++ .../render/MixinEntityRenderDispatcher.java | 76 +++++++ .../render/MixinMinecraftSkinLifecycle.java | 41 ++++ .../bedrock/render/BedrockEntityRenderer.java | 117 ++++++++++ .../bedrock/render/BedrockGeometry.java | 202 ++++++++++++++++++ .../bedrock/render/BedrockPlayerRenderer.java | 33 +++ .../bedrock/render/BedrockPlayerSkins.java | 194 +++++++++++++++++ .../render/CustomEntityRenderStore.java | 51 +++++ .../viafabricplus-bedrock.mixins.json | 4 + 11 files changed, 820 insertions(+) create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinAbstractClientPlayer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinCustomEntity.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinEntityRenderDispatcher.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinMinecraftSkinLifecycle.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerRenderer.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java create mode 100644 src/main/java/com/viaversion/viafabricplus/bedrock/render/CustomEntityRenderStore.java diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/friends/FriendWorldSkinProvider.java b/src/main/java/com/viaversion/viafabricplus/bedrock/friends/FriendWorldSkinProvider.java index a81e5ca..79efc1d 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/friends/FriendWorldSkinProvider.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/friends/FriendWorldSkinProvider.java @@ -21,8 +21,11 @@ package com.viaversion.viafabricplus.bedrock.friends; +import com.viaversion.viafabricplus.bedrock.render.BedrockPlayerSkins; import com.viaversion.viaversion.api.connection.UserConnection; import java.util.Map; +import java.util.UUID; +import net.raphimc.viabedrock.protocol.model.SkinData; import net.raphimc.viabedrock.protocol.provider.SkinProvider; /** Adds the host-issued session nonce to Bedrock's client data for friend worlds. */ @@ -38,4 +41,9 @@ public Map getClientPlayerSkin(final UserConnection user) { return claims; } + @Override + public void setSkin(final UserConnection user, final UUID playerUuid, final SkinData skin) { + BedrockPlayerSkins.accept(user, playerUuid, skin); + } + } diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinAbstractClientPlayer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinAbstractClientPlayer.java new file mode 100644 index 0000000..13cd684 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinAbstractClientPlayer.java @@ -0,0 +1,38 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.viaversion.viafabricplus.bedrock.injection.mixin.features.render; + +import com.viaversion.viafabricplus.bedrock.render.BedrockPlayerSkins; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.world.entity.player.PlayerSkin; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(AbstractClientPlayer.class) +public abstract class MixinAbstractClientPlayer { + + @Inject(method = "getSkin", at = @At("RETURN"), cancellable = true) + private void bedrockSkin(final CallbackInfoReturnable cir) { + final AbstractClientPlayer player = (AbstractClientPlayer) (Object) this; + cir.setReturnValue(BedrockPlayerSkins.skin(player.getUUID(), cir.getReturnValue())); + } + +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinCustomEntity.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinCustomEntity.java new file mode 100644 index 0000000..9c74f84 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinCustomEntity.java @@ -0,0 +1,56 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.viaversion.viafabricplus.bedrock.injection.mixin.features.render; + +import com.viaversion.viafabricplus.bedrock.render.CustomEntityRenderStore; +import java.util.List; +import net.raphimc.viabedrock.api.model.entity.CustomEntity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +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; + +@Mixin(value = CustomEntity.class, remap = false) +public abstract class MixinCustomEntity { + + @Shadow + private List models; + + @Shadow + private boolean spawned; + + @Inject(method = "evaluateRenderControllerChange", at = @At("RETURN")) + private void publishRenderModels(final CallbackInfoReturnable cir) { + CustomEntityRenderStore.update(((CustomEntity) (Object) this).javaUuid(), this.models); + } + + @Inject(method = "spawn", at = @At("HEAD"), cancellable = true) + private void useNativeRenderer(final CallbackInfo ci) { + this.spawned = true; + ci.cancel(); + } + + @Inject(method = "remove", at = @At("HEAD")) + private void removeRenderModels(final CallbackInfo ci) { + CustomEntityRenderStore.remove(((CustomEntity) (Object) this).javaUuid()); + } + +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinEntityRenderDispatcher.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinEntityRenderDispatcher.java new file mode 100644 index 0000000..fdd49ce --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinEntityRenderDispatcher.java @@ -0,0 +1,76 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.viaversion.viafabricplus.bedrock.injection.mixin.features.render; + +import com.llamalad7.mixinextras.injector.wrapoperation.Operation; +import com.llamalad7.mixinextras.injector.wrapoperation.WrapOperation; +import com.viaversion.viafabricplus.bedrock.render.BedrockEntityRenderer; +import com.viaversion.viafabricplus.bedrock.render.BedrockPlayerSkins; +import java.util.HashMap; +import java.util.Map; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.client.renderer.entity.EntityRenderDispatcher; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.EntityRenderers; +import net.minecraft.client.renderer.entity.player.AvatarRenderer; +import net.minecraft.client.renderer.entity.state.AvatarRenderState; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.EntityTypes; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; + +@Mixin(EntityRenderDispatcher.class) +public abstract class MixinEntityRenderDispatcher { + + @WrapOperation(method = "onResourceManagerReload", at = @At(value = "INVOKE", + target = "Lnet/minecraft/client/renderer/entity/EntityRenderers;createEntityRenderers(Lnet/minecraft/client/renderer/entity/EntityRendererProvider$Context;)Ljava/util/Map;")) + private Map, EntityRenderer> bedrockInteractionRenderer( + final EntityRendererProvider.Context context, + final Operation, EntityRenderer>> original + ) { + final Map, EntityRenderer> renderers = new HashMap<>(original.call(context)); + renderers.put(EntityTypes.INTERACTION, new BedrockEntityRenderer(context)); + BedrockPlayerSkins.rendererContext(context); + return renderers; + } + + @SuppressWarnings("unchecked") + @Inject(method = "getRenderer(Lnet/minecraft/world/entity/Entity;)Lnet/minecraft/client/renderer/entity/EntityRenderer;", at = @At("HEAD"), cancellable = true) + private void bedrockPlayerRenderer(final T entity, final CallbackInfoReturnable> cir) { + if (entity instanceof AbstractClientPlayer player) { + final AvatarRenderer renderer = BedrockPlayerSkins.renderer(player.getUUID()); + if (renderer != null) { + cir.setReturnValue((EntityRenderer) renderer); + } + } + } + + @Inject(method = "getRenderer(Lnet/minecraft/client/renderer/entity/state/AvatarRenderState;)Lnet/minecraft/client/renderer/entity/player/AvatarRenderer;", at = @At("HEAD"), cancellable = true) + private void bedrockPlayerRenderState(final AvatarRenderState state, final CallbackInfoReturnable> cir) { + final AvatarRenderer renderer = BedrockPlayerSkins.renderer(state.skin); + if (renderer != null) { + cir.setReturnValue(renderer); + } + } + +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinMinecraftSkinLifecycle.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinMinecraftSkinLifecycle.java new file mode 100644 index 0000000..4ac84b7 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinMinecraftSkinLifecycle.java @@ -0,0 +1,41 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.viaversion.viafabricplus.bedrock.injection.mixin.features.render; + +import com.viaversion.viafabricplus.bedrock.render.BedrockPlayerSkins; +import com.viaversion.viafabricplus.bedrock.render.CustomEntityRenderStore; +import net.minecraft.client.Minecraft; +import net.minecraft.client.multiplayer.ClientLevel; +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; + +@Mixin(Minecraft.class) +public abstract class MixinMinecraftSkinLifecycle { + + @Inject(method = "setLevel", at = @At("HEAD")) + private void clearBedrockSkinsOnDisconnect(final ClientLevel level, final CallbackInfo ci) { + if (level == null) { + BedrockPlayerSkins.clear(); + CustomEntityRenderStore.clear(); + } + } + +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java new file mode 100644 index 0000000..cf2b2f8 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java @@ -0,0 +1,117 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.viaversion.viafabricplus.bedrock.render; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; +import com.viaversion.viafabricplus.ViaFabricPlus; +import com.viaversion.viaversion.api.connection.UserConnection; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.minecraft.client.model.Model; +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.culling.Frustum; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.Interaction; +import net.raphimc.viabedrock.api.util.StringUtil; +import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; +import org.cube.converter.model.impl.bedrock.BedrockGeometryModel; + +/** Renders ViaBedrock custom actors while leaving ordinary interactions invisible. */ +public final class BedrockEntityRenderer extends EntityRenderer { + + private final Map> models = new HashMap<>(); + private ResourcePackStorage currentPacks; + + public BedrockEntityRenderer(final EntityRendererProvider.Context context) { + super(context); + } + + @Override + public State createRenderState() { + return new State(); + } + + @Override + public boolean shouldRender(final Interaction entity, final Frustum frustum, final double x, final double y, final double z, final float partialTicks) { + if (CustomEntityRenderStore.get(entity.getUUID()) != null) { + return x * x + y * y + z * z <= 96D * 96D; + } + return super.shouldRender(entity, frustum, x, y, z, partialTicks); + } + + @Override + public void extractRenderState(final Interaction entity, final State state, final float partialTicks) { + super.extractRenderState(entity, state, partialTicks); + state.models = List.of(); + final UserConnection connection = ViaFabricPlus.api().userConnection(); + if (connection == null || !connection.has(ResourcePackStorage.class)) { + return; + } + final List evaluatedModels = CustomEntityRenderStore.get(entity.getUUID()); + if (evaluatedModels == null) { + return; + } + final ResourcePackStorage packs = connection.get(ResourcePackStorage.class); + if (this.currentPacks != packs) { + this.currentPacks = packs; + this.models.clear(); + } + final List resolved = new ArrayList<>(); + for (var evaluated : evaluatedModels) { + final BedrockGeometryModel geometry = packs.getModels().entityModels().get(evaluated.geometryValue()); + if (geometry == null) { + continue; + } + final Model model = this.models.computeIfAbsent(evaluated.geometryValue(), _ -> BedrockGeometry.entityModel(geometry)); + final String path = "textures/item/entities/" + StringUtil.makeIdentifierValueSafe(evaluated.textureValue().replace("textures/", "")) + ".png"; + resolved.add(new RenderedModel(model, Identifier.fromNamespaceAndPath("viabedrock", path))); + } + state.models = List.copyOf(resolved); + state.yaw = entity.getYRot(); + } + + @Override + public void submit(final State state, final PoseStack poseStack, final SubmitNodeCollector nodes, final CameraRenderState camera) { + for (RenderedModel rendered : state.models) { + poseStack.pushPose(); + poseStack.rotateDegrees(Axis.YP, 180F - state.yaw); + poseStack.scale(-1F, -1F, 1F); + poseStack.translate(0F, -1.5F, 0F); + nodes.submitModel(rendered.model, state, poseStack, rendered.model.renderType(rendered.texture), state.lightCoords, OverlayTexture.NO_OVERLAY, -1); + poseStack.popPose(); + } + } + + public static final class State extends EntityRenderState { + private List models = List.of(); + private float yaw; + } + + private record RenderedModel(Model model, Identifier texture) { + } + +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java new file mode 100644 index 0000000..2b0a789 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java @@ -0,0 +1,202 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.viaversion.viafabricplus.bedrock.render; + +import java.util.EnumSet; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import net.minecraft.client.model.Model; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.model.player.PlayerModel; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.client.renderer.rendertype.RenderTypes; +import net.minecraft.core.Direction; +import org.cube.converter.model.element.Cube; +import org.cube.converter.model.element.Parent; +import org.cube.converter.model.impl.bedrock.BedrockGeometryModel; +import org.cube.converter.util.element.UVMap; + +/** Builds client models from the geometry that ViaBedrock has already parsed. */ +public final class BedrockGeometry { + + private static final float DEGREES_TO_RADIANS = (float) (Math.PI / 180D); + private static final List PLAYER_PARTS = List.of("head", "body", "right_arm", "left_arm", "right_leg", "left_leg"); + private static final Map PLAYER_OVERLAYS = Map.of( + "hat", "head", "jacket", "body", "right_sleeve", "right_arm", "left_sleeve", "left_arm", + "right_pants", "right_leg", "left_pants", "left_leg" + ); + + private BedrockGeometry() { + } + + public static Model entityModel(final BedrockGeometryModel geometry) { + return new Model<>(root(geometry, false), RenderTypes::entityTranslucent) { + }; + } + + public static PlayerModel playerModel(final BedrockGeometryModel geometry, final boolean slim) { + return new PlayerModel(root(geometry, true), slim); + } + + private static ModelPart root(final BedrockGeometryModel geometry, final boolean player) { + final Map bones = new HashMap<>(); + final float textureWidth = geometry.getTextureSize().getX(); + final float textureHeight = geometry.getTextureSize().getY(); + + for (Parent parent : geometry.getParents()) { + final Map children = new HashMap<>(); + final ModelPart part = new ModelPart(List.of(), children); + part.setRotation(parent.getRotation().getX() * DEGREES_TO_RADIANS, parent.getRotation().getY() * DEGREES_TO_RADIANS, parent.getRotation().getZ() * DEGREES_TO_RADIANS); + int cubeNumber = 0; + for (Cube cube : parent.getCubes().values()) { + children.put("cube_" + cubeNumber++, cubePart(cube, parent, textureWidth, textureHeight)); + } + bones.put(parent.getName(), new Bone(parent, part, children)); + } + + final Map roots = new HashMap<>(); + final Map> playerChildren = new HashMap<>(); + for (Bone bone : bones.values()) { + final String name = player ? playerPartName(bone.definition.getName()) : bone.definition.getName(); + final Bone parent = bones.get(bone.definition.getParent()); + final boolean directPlayerPart = player && (PLAYER_PARTS.contains(name) || PLAYER_OVERLAYS.containsKey(name)); + final float parentX = directPlayerPart || parent == null ? 0 : parent.definition.getPivot().getX(); + final float parentY = directPlayerPart || parent == null ? 24 : parent.definition.getPivot().getY(); + final float parentZ = directPlayerPart || parent == null ? 0 : parent.definition.getPivot().getZ(); + bone.part.setPos(bone.definition.getPivot().getX() - parentX, parentY - bone.definition.getPivot().getY(), bone.definition.getPivot().getZ() - parentZ); + bone.part.setInitialPose(bone.part.storePose()); + if (player && PLAYER_OVERLAYS.containsKey(name)) { + continue; + } + if (directPlayerPart || parent == null) { + roots.put(name, bone.part); + if (player && PLAYER_PARTS.contains(name)) { + playerChildren.put(name, bone.children); + } + } else { + parent.children.put(name, bone.part); + } + } + if (player) { + for (String name : PLAYER_PARTS) { + if (!roots.containsKey(name)) { + final Map children = new HashMap<>(); + roots.put(name, new ModelPart(List.of(), children)); + playerChildren.put(name, children); + } + } + for (Map.Entry overlay : PLAYER_OVERLAYS.entrySet()) { + final Bone bone = bones.values().stream() + .filter(value -> playerPartName(value.definition.getName()).equals(overlay.getKey())) + .findFirst().orElse(null); + final ModelPart overlayPart = bone == null ? new ModelPart(List.of(), Map.of()) : bone.part; + if (bone != null) { + final Bone body = bones.values().stream() + .filter(value -> playerPartName(value.definition.getName()).equals(overlay.getValue())) + .findFirst().orElse(null); + final float parentX = body == null ? 0 : body.definition.getPivot().getX(); + final float parentY = body == null ? 24 : body.definition.getPivot().getY(); + final float parentZ = body == null ? 0 : body.definition.getPivot().getZ(); + overlayPart.setPos(bone.definition.getPivot().getX() - parentX, parentY - bone.definition.getPivot().getY(), bone.definition.getPivot().getZ() - parentZ); + overlayPart.setInitialPose(overlayPart.storePose()); + } + playerChildren.get(overlay.getValue()).put(overlay.getKey(), overlayPart); + } + } + return new ModelPart(List.of(), roots); + } + + private static ModelPart cubePart(final Cube cube, final Parent bone, final float textureWidth, final float textureHeight) { + final float pivotX = cube.getPivot().getX(); + final float pivotY = cube.getPivot().getY(); + final float pivotZ = cube.getPivot().getZ(); + final float x = cube.getPosition().getX() - pivotX; + final float y = pivotY - cube.getPosition().getY() - cube.getSize().getY(); + final float z = cube.getPosition().getZ() - pivotZ; + final float sizeX = cube.getSize().getX(); + final float sizeY = cube.getSize().getY(); + final float sizeZ = cube.getSize().getZ(); + final UVMap uv = cube.getUvMap(); + final Set faces = EnumSet.noneOf(Direction.class); + for (Direction direction : Direction.values()) { + if (uv.getUvMap().containsKey(org.cube.converter.util.element.Direction.valueOf(direction.name()))) { + faces.add(direction); + } + } + final ModelPart.Cube partCube = new ModelPart.Cube(0, 0, x, y, z, sizeX, sizeY, sizeZ, cube.getInflate(), cube.getInflate(), cube.getInflate(), cube.isMirror(), textureWidth, textureHeight, faces); + setFaceUvs(partCube, uv, faces, textureWidth, textureHeight, cube.isMirror()); + final ModelPart part = new ModelPart(List.of(partCube), Map.of()); + part.setPos(pivotX - bone.getPivot().getX(), bone.getPivot().getY() - pivotY, pivotZ - bone.getPivot().getZ()); + part.setRotation(cube.getRotation().getX() * DEGREES_TO_RADIANS, cube.getRotation().getY() * DEGREES_TO_RADIANS, cube.getRotation().getZ() * DEGREES_TO_RADIANS); + part.setInitialPose(part.storePose()); + return part; + } + + private static void setFaceUvs(final ModelPart.Cube cube, final UVMap uv, final Set faces, final float width, final float height, final boolean mirror) { + final float x = cube.minX; + final float y = cube.minY; + final float z = cube.minZ; + final float maxX = cube.maxX; + final float maxY = cube.maxY; + final float maxZ = cube.maxZ; + final ModelPart.Vertex v0 = new ModelPart.Vertex(x, y, z, 0, 0); + final ModelPart.Vertex v1 = new ModelPart.Vertex(maxX, y, z, 0, 0); + final ModelPart.Vertex v2 = new ModelPart.Vertex(maxX, maxY, z, 0, 0); + final ModelPart.Vertex v3 = new ModelPart.Vertex(x, maxY, z, 0, 0); + final ModelPart.Vertex v4 = new ModelPart.Vertex(x, y, maxZ, 0, 0); + final ModelPart.Vertex v5 = new ModelPart.Vertex(maxX, y, maxZ, 0, 0); + final ModelPart.Vertex v6 = new ModelPart.Vertex(maxX, maxY, maxZ, 0, 0); + final ModelPart.Vertex v7 = new ModelPart.Vertex(x, maxY, maxZ, 0, 0); + final Map vertices = Map.of( + Direction.DOWN, new ModelPart.Vertex[]{v5, v4, v0, v1}, + Direction.UP, new ModelPart.Vertex[]{v2, v3, v7, v6}, + Direction.WEST, new ModelPart.Vertex[]{v0, v4, v7, v3}, + Direction.NORTH, new ModelPart.Vertex[]{v1, v0, v3, v2}, + Direction.EAST, new ModelPart.Vertex[]{v5, v1, v2, v6}, + Direction.SOUTH, new ModelPart.Vertex[]{v4, v5, v6, v7} + ); + int index = 0; + for (Direction direction : Direction.values()) { + if (faces.contains(direction)) { + final Float[] face = uv.getUvMap().get(org.cube.converter.util.element.Direction.valueOf(direction.name())); + cube.polygons[index++] = new ModelPart.Polygon(vertices.get(direction), face[0], face[1], face[2], face[3], width, height, mirror, direction); + } + } + } + + private static String playerPartName(final String name) { + return switch (name.toLowerCase(Locale.ROOT)) { + case "leftarm" -> "left_arm"; + case "rightarm" -> "right_arm"; + case "leftleg" -> "left_leg"; + case "rightleg" -> "right_leg"; + case "leftsleeve" -> "left_sleeve"; + case "rightsleeve" -> "right_sleeve"; + case "leftpants" -> "left_pants"; + case "rightpants" -> "right_pants"; + default -> name.toLowerCase(Locale.ROOT); + }; + } + + private record Bone(Parent definition, ModelPart part, Map children) { + } +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerRenderer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerRenderer.java new file mode 100644 index 0000000..4b9f121 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerRenderer.java @@ -0,0 +1,33 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.viaversion.viafabricplus.bedrock.render; + +import net.minecraft.client.model.player.PlayerModel; +import net.minecraft.client.player.AbstractClientPlayer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.player.AvatarRenderer; + +final class BedrockPlayerRenderer extends AvatarRenderer { + + BedrockPlayerRenderer(final EntityRendererProvider.Context context, final PlayerModel model, final boolean slim) { + super(context, slim); + this.model = model; + } + +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java new file mode 100644 index 0000000..38299c3 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java @@ -0,0 +1,194 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.viaversion.viafabricplus.bedrock.render; + +import com.mojang.blaze3d.platform.NativeImage; +import com.viaversion.viafabricplus.ViaFabricPlus; +import com.viaversion.viafabricplus.bedrock.ViaFabricPlusBedrock; +import com.viaversion.viaversion.api.connection.UserConnection; +import com.viaversion.viaversion.libs.gson.JsonObject; +import com.viaversion.viaversion.libs.gson.JsonParser; +import java.awt.image.BufferedImage; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.entity.player.AvatarRenderer; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.core.ClientAsset; +import net.minecraft.resources.Identifier; +import net.minecraft.world.entity.player.PlayerModelType; +import net.minecraft.world.entity.player.PlayerSkin; +import net.raphimc.viabedrock.protocol.model.SkinData; +import org.cube.converter.model.impl.bedrock.BedrockGeometryModel; + +/** Owns client textures and geometry for Bedrock player appearances. */ +public final class BedrockPlayerSkins { + + private static final int MAX_TEXTURE_DIMENSION = 1024; + private static final Map APPEARANCES = new HashMap<>(); + private static final Map PLAYER_BY_TEXTURE = new HashMap<>(); + private static volatile EntityRendererProvider.Context rendererContext; + private static volatile long rendererEpoch; + + private BedrockPlayerSkins() { + } + + public static void accept(final UserConnection connection, final UUID playerUuid, final SkinData skin) { + if (skin == null || skin.skinData() == null || skin.persona() || !supportedImage(skin.skinData())) { + return; + } + Minecraft.getInstance().execute(() -> { + if (ViaFabricPlus.api().userConnection() != connection) { + return; + } + final Appearance previous = APPEARANCES.remove(playerUuid); + if (previous != null) { + PLAYER_BY_TEXTURE.remove(previous.bodyId); + release(previous); + } + final Identifier bodyId = textureId(playerUuid, "body"); + register(bodyId, skin.skinData()); + final Identifier capeId = supportedImage(skin.capeData()) ? textureId(playerUuid, "cape") : null; + if (capeId != null) { + register(capeId, skin.capeData()); + } + final boolean slim = "slim".equalsIgnoreCase(skin.armSize()) || geometryName(skin).toLowerCase(Locale.ROOT).contains("slim"); + final PlayerModelType modelType = slim ? PlayerModelType.SLIM : PlayerModelType.WIDE; + final PlayerSkin playerSkin = PlayerSkin.insecure(new ClientAsset.ResourceTexture(bodyId), + capeId == null ? null : new ClientAsset.ResourceTexture(capeId), null, modelType); + final BedrockGeometryModel geometry = geometry(skin); + APPEARANCES.put(playerUuid, new Appearance(playerSkin, geometry, slim, bodyId, capeId)); + PLAYER_BY_TEXTURE.put(bodyId, playerUuid); + }); + } + + public static PlayerSkin skin(final UUID playerUuid, final PlayerSkin fallback) { + final Appearance appearance = APPEARANCES.get(playerUuid); + return appearance == null ? fallback : appearance.skin; + } + + public static AvatarRenderer renderer(final UUID playerUuid) { + final Appearance appearance = APPEARANCES.get(playerUuid); + if (appearance == null || appearance.geometry == null || rendererContext == null) { + return null; + } + if (appearance.renderer == null || appearance.rendererEpoch != rendererEpoch) { + appearance.renderer = new BedrockPlayerRenderer(rendererContext, BedrockGeometry.playerModel(appearance.geometry, appearance.slim), appearance.slim); + appearance.rendererEpoch = rendererEpoch; + } + return appearance.renderer; + } + + public static AvatarRenderer renderer(final PlayerSkin skin) { + if (skin == null || skin.body() == null) { + return null; + } + final UUID playerUuid = PLAYER_BY_TEXTURE.get(skin.body().id()); + return playerUuid == null ? null : renderer(playerUuid); + } + + public static void rendererContext(final EntityRendererProvider.Context context) { + rendererContext = context; + rendererEpoch++; + } + + public static void clear() { + APPEARANCES.values().forEach(BedrockPlayerSkins::release); + APPEARANCES.clear(); + PLAYER_BY_TEXTURE.clear(); + } + + private static void release(final Appearance appearance) { + final var textureManager = Minecraft.getInstance().getTextureManager(); + textureManager.release(appearance.bodyId); + if (appearance.capeId != null) { + textureManager.release(appearance.capeId); + } + } + + private static Identifier textureId(final UUID playerUuid, final String kind) { + return Identifier.fromNamespaceAndPath("viafabricplus-bedrock", "skins/" + playerUuid + '/' + kind); + } + + private static boolean supportedImage(final BufferedImage image) { + return image != null && image.getWidth() > 0 && image.getHeight() > 0 + && image.getWidth() <= MAX_TEXTURE_DIMENSION && image.getHeight() <= MAX_TEXTURE_DIMENSION; + } + + private static void register(final Identifier id, final BufferedImage image) { + final NativeImage nativeImage = new NativeImage(image.getWidth(), image.getHeight(), false); + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + final int argb = image.getRGB(x, y); + final int abgr = (argb & 0xFF00FF00) | ((argb & 0xFF) << 16) | ((argb >>> 16) & 0xFF); + nativeImage.setPixelABGR(x, y, abgr); + } + } + Minecraft.getInstance().getTextureManager().register(id, new DynamicTexture(id::toString, nativeImage)); + } + + private static String geometryName(final SkinData skin) { + try { + return JsonParser.parseString(skin.skinResourcePatch()).getAsJsonObject() + .getAsJsonObject("geometry").get("default").getAsString(); + } catch (RuntimeException e) { + return ""; + } + } + + private static BedrockGeometryModel geometry(final SkinData skin) { + if (skin.geometryData() == null || skin.geometryData().isBlank() || "null".equalsIgnoreCase(skin.geometryData())) { + return null; + } + try { + final JsonObject data = JsonParser.parseString(skin.geometryData()).getAsJsonObject(); + final List models = BedrockGeometryModel.fromJson(data); + final String name = geometryName(skin); + return models.stream().filter(model -> model.getIdentifier().equals(name)).findFirst() + .orElseGet(() -> models.isEmpty() ? null : models.getFirst()); + } catch (RuntimeException e) { + ViaFabricPlusBedrock.impl().logger().warn("Failed to parse Bedrock player geometry", e); + return null; + } + } + + private static final class Appearance { + private final PlayerSkin skin; + private final BedrockGeometryModel geometry; + private final boolean slim; + private final Identifier bodyId; + private final Identifier capeId; + private AvatarRenderer renderer; + private long rendererEpoch; + + private Appearance(final PlayerSkin skin, final BedrockGeometryModel geometry, final boolean slim, + final Identifier bodyId, final Identifier capeId) { + this.skin = skin; + this.geometry = geometry; + this.slim = slim; + this.bodyId = bodyId; + this.capeId = capeId; + } + } + +} diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/CustomEntityRenderStore.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/CustomEntityRenderStore.java new file mode 100644 index 0000000..8146848 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/CustomEntityRenderStore.java @@ -0,0 +1,51 @@ +/* + * This file is part of ViaFabricPlus Bedrock - https://github.com/ViaVersionAddons/viafabricplus-bedrock + * Copyright (C) 2026 ViaVersion and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.viaversion.viafabricplus.bedrock.render; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.raphimc.viabedrock.api.model.entity.CustomEntity; + +/** Publishes immutable actor render state across the protocol and render threads. */ +public final class CustomEntityRenderStore { + + private static final Map> MODELS = new ConcurrentHashMap<>(); + + private CustomEntityRenderStore() { + } + + public static void update(final UUID uuid, final List models) { + MODELS.put(uuid, List.copyOf(models)); + } + + public static List get(final UUID uuid) { + return MODELS.get(uuid); + } + + public static void remove(final UUID uuid) { + MODELS.remove(uuid); + } + + public static void clear() { + MODELS.clear(); + } + +} diff --git a/src/main/resources/viafabricplus-bedrock.mixins.json b/src/main/resources/viafabricplus-bedrock.mixins.json index 17da109..ed12c1b 100644 --- a/src/main/resources/viafabricplus-bedrock.mixins.json +++ b/src/main/resources/viafabricplus-bedrock.mixins.json @@ -45,6 +45,10 @@ "features.misc.MixinClientSuggestionProvider", "features.misc.MixinInventoryScreen", "features.misc.MixinMinecraft", + "features.render.MixinAbstractClientPlayer", + "features.render.MixinCustomEntity", + "features.render.MixinEntityRenderDispatcher", + "features.render.MixinMinecraftSkinLifecycle", "features.movement.MixinEntity", "features.movement.MixinEntityFluidInteraction", "features.movement.MixinEntityFluidInteraction_CurrentAccumulator", From adb718c72b3b540f8497928a3981b6c510aaca43 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:05:00 +0200 Subject: [PATCH 2/4] fix(bedrock): render player skins from registered textures Minecraft 26.3 derives a resource file path from the one-argument ResourceTexture constructor. Dynamic Bedrock skin and cape textures are registered under their direct IDs, so use those IDs as the render paths to avoid the missing-texture model. --- .../viafabricplus/bedrock/render/BedrockPlayerSkins.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java index 38299c3..80e5831 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockPlayerSkins.java @@ -74,8 +74,8 @@ public static void accept(final UserConnection connection, final UUID playerUuid } final boolean slim = "slim".equalsIgnoreCase(skin.armSize()) || geometryName(skin).toLowerCase(Locale.ROOT).contains("slim"); final PlayerModelType modelType = slim ? PlayerModelType.SLIM : PlayerModelType.WIDE; - final PlayerSkin playerSkin = PlayerSkin.insecure(new ClientAsset.ResourceTexture(bodyId), - capeId == null ? null : new ClientAsset.ResourceTexture(capeId), null, modelType); + final PlayerSkin playerSkin = PlayerSkin.insecure(new ClientAsset.ResourceTexture(bodyId, bodyId), + capeId == null ? null : new ClientAsset.ResourceTexture(capeId, capeId), null, modelType); final BedrockGeometryModel geometry = geometry(skin); APPEARANCES.put(playerUuid, new Appearance(playerSkin, geometry, slim, bodyId, capeId)); PLAYER_BY_TEXTURE.put(bodyId, playerUuid); From 528d61a0753506ec4aeadfc017e6f1a51d5ffe42 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:13:16 +0200 Subject: [PATCH 3/4] fix(bedrock): preserve geometry UVs and inflated layers CubeConverter box UV face labels do not line up with Minecraft model face placement. Map them to the corresponding player faces and retain the cube vertices built with inflation, so skins and clothing layers render without misplaced pixels or overlap. --- .../bedrock/render/BedrockGeometry.java | 57 ++++++++++--------- 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java index 2b0a789..73f8c72 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java @@ -34,11 +34,15 @@ import org.cube.converter.model.element.Parent; import org.cube.converter.model.impl.bedrock.BedrockGeometryModel; import org.cube.converter.util.element.UVMap; +import org.cube.converter.util.element.UVMap.UVType; /** Builds client models from the geometry that ViaBedrock has already parsed. */ public final class BedrockGeometry { private static final float DEGREES_TO_RADIANS = (float) (Math.PI / 180D); + private static final List CUBE_FACES = List.of( + Direction.DOWN, Direction.UP, Direction.WEST, Direction.NORTH, Direction.EAST, Direction.SOUTH + ); private static final List PLAYER_PARTS = List.of("head", "body", "right_arm", "left_arm", "right_leg", "left_leg"); private static final Map PLAYER_OVERLAYS = Map.of( "hat", "head", "jacket", "body", "right_sleeve", "right_arm", "left_sleeve", "left_arm", @@ -138,7 +142,7 @@ private static ModelPart cubePart(final Cube cube, final Parent bone, final floa final UVMap uv = cube.getUvMap(); final Set faces = EnumSet.noneOf(Direction.class); for (Direction direction : Direction.values()) { - if (uv.getUvMap().containsKey(org.cube.converter.util.element.Direction.valueOf(direction.name()))) { + if (uv.getUvMap().containsKey(sourceFace(uv, direction))) { faces.add(direction); } } @@ -152,37 +156,38 @@ private static ModelPart cubePart(final Cube cube, final Parent bone, final floa } private static void setFaceUvs(final ModelPart.Cube cube, final UVMap uv, final Set faces, final float width, final float height, final boolean mirror) { - final float x = cube.minX; - final float y = cube.minY; - final float z = cube.minZ; - final float maxX = cube.maxX; - final float maxY = cube.maxY; - final float maxZ = cube.maxZ; - final ModelPart.Vertex v0 = new ModelPart.Vertex(x, y, z, 0, 0); - final ModelPart.Vertex v1 = new ModelPart.Vertex(maxX, y, z, 0, 0); - final ModelPart.Vertex v2 = new ModelPart.Vertex(maxX, maxY, z, 0, 0); - final ModelPart.Vertex v3 = new ModelPart.Vertex(x, maxY, z, 0, 0); - final ModelPart.Vertex v4 = new ModelPart.Vertex(x, y, maxZ, 0, 0); - final ModelPart.Vertex v5 = new ModelPart.Vertex(maxX, y, maxZ, 0, 0); - final ModelPart.Vertex v6 = new ModelPart.Vertex(maxX, maxY, maxZ, 0, 0); - final ModelPart.Vertex v7 = new ModelPart.Vertex(x, maxY, maxZ, 0, 0); - final Map vertices = Map.of( - Direction.DOWN, new ModelPart.Vertex[]{v5, v4, v0, v1}, - Direction.UP, new ModelPart.Vertex[]{v2, v3, v7, v6}, - Direction.WEST, new ModelPart.Vertex[]{v0, v4, v7, v3}, - Direction.NORTH, new ModelPart.Vertex[]{v1, v0, v3, v2}, - Direction.EAST, new ModelPart.Vertex[]{v5, v1, v2, v6}, - Direction.SOUTH, new ModelPart.Vertex[]{v4, v5, v6, v7} - ); int index = 0; - for (Direction direction : Direction.values()) { + for (Direction direction : CUBE_FACES) { if (faces.contains(direction)) { - final Float[] face = uv.getUvMap().get(org.cube.converter.util.element.Direction.valueOf(direction.name())); - cube.polygons[index++] = new ModelPart.Polygon(vertices.get(direction), face[0], face[1], face[2], face[3], width, height, mirror, direction); + final ModelPart.Vertex[] vertices = cube.polygons[index].vertices().clone(); + if (mirror) { + for (int i = 0; i < vertices.length / 2; i++) { + final ModelPart.Vertex opposite = vertices[vertices.length - 1 - i]; + vertices[vertices.length - 1 - i] = vertices[i]; + vertices[i] = opposite; + } + } + final Float[] face = uv.getUvMap().get(sourceFace(uv, direction)); + final boolean rotate = uv.getUvType() == UVType.BOX && (direction == Direction.DOWN || direction == Direction.UP); + cube.polygons[index++] = new ModelPart.Polygon(vertices, + rotate ? face[2] : face[0], rotate ? face[3] : face[1], + rotate ? face[0] : face[2], rotate ? face[1] : face[3], + width, height, mirror, direction); } } } + private static org.cube.converter.util.element.Direction sourceFace(final UVMap uv, final Direction direction) { + final Direction source = uv.getUvType() == UVType.BOX ? switch (direction) { + case DOWN -> Direction.UP; + case UP -> Direction.DOWN; + case WEST -> Direction.EAST; + case EAST -> Direction.WEST; + default -> direction; + } : direction; + return org.cube.converter.util.element.Direction.valueOf(source.name()); + } + private static String playerPartName(final String name) { return switch (name.toLowerCase(Locale.ROOT)) { case "leftarm" -> "left_arm"; From a7f4b8ecc6aecdd6ce70a33bc42a359ae7434386 Mon Sep 17 00:00:00 2001 From: AlexProgrammerDE <40795980+AlexProgrammerDE@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:42:18 +0200 Subject: [PATCH 4/4] perf(bedrock): cache resolved custom entity models Publish model snapshots only when ViaBedrock reports a render controller change. Cache geometry and texture resolution by evaluated model set so render-state extraction no longer repeats that work each frame. Clear the cache when resource pack storage changes or the connection is unavailable. --- .../features/render/MixinCustomEntity.java | 4 +++- .../bedrock/render/BedrockEntityRenderer.java | 19 ++++++++++++++++--- .../bedrock/render/BedrockGeometry.java | 2 ++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinCustomEntity.java b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinCustomEntity.java index 9c74f84..d6c7e23 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinCustomEntity.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinCustomEntity.java @@ -39,7 +39,9 @@ public abstract class MixinCustomEntity { @Inject(method = "evaluateRenderControllerChange", at = @At("RETURN")) private void publishRenderModels(final CallbackInfoReturnable cir) { - CustomEntityRenderStore.update(((CustomEntity) (Object) this).javaUuid(), this.models); + if (cir.getReturnValueZ()) { + CustomEntityRenderStore.update(((CustomEntity) (Object) this).javaUuid(), this.models); + } } @Inject(method = "spawn", at = @At("HEAD"), cancellable = true) diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java index cf2b2f8..5ab965f 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java @@ -36,6 +36,7 @@ import net.minecraft.client.renderer.texture.OverlayTexture; import net.minecraft.resources.Identifier; import net.minecraft.world.entity.Interaction; +import net.raphimc.viabedrock.api.model.entity.CustomEntity; import net.raphimc.viabedrock.api.util.StringUtil; import net.raphimc.viabedrock.protocol.storage.ResourcePackStorage; import org.cube.converter.model.impl.bedrock.BedrockGeometryModel; @@ -44,6 +45,7 @@ public final class BedrockEntityRenderer extends EntityRenderer { private final Map> models = new HashMap<>(); + private final Map, List> resolvedModelSets = new HashMap<>(); private ResourcePackStorage currentPacks; public BedrockEntityRenderer(final EntityRendererProvider.Context context) { @@ -69,9 +71,12 @@ public void extractRenderState(final Interaction entity, final State state, fina state.models = List.of(); final UserConnection connection = ViaFabricPlus.api().userConnection(); if (connection == null || !connection.has(ResourcePackStorage.class)) { + this.currentPacks = null; + this.models.clear(); + this.resolvedModelSets.clear(); return; } - final List evaluatedModels = CustomEntityRenderStore.get(entity.getUUID()); + final List evaluatedModels = CustomEntityRenderStore.get(entity.getUUID()); if (evaluatedModels == null) { return; } @@ -79,7 +84,16 @@ public void extractRenderState(final Interaction entity, final State state, fina if (this.currentPacks != packs) { this.currentPacks = packs; this.models.clear(); + this.resolvedModelSets.clear(); } + state.models = this.resolvedModelSets.computeIfAbsent(evaluatedModels, models -> this.resolveModels(models, packs)); + state.yaw = entity.getYRot(); + } + + private List resolveModels( + final List evaluatedModels, + final ResourcePackStorage packs + ) { final List resolved = new ArrayList<>(); for (var evaluated : evaluatedModels) { final BedrockGeometryModel geometry = packs.getModels().entityModels().get(evaluated.geometryValue()); @@ -90,8 +104,7 @@ public void extractRenderState(final Interaction entity, final State state, fina final String path = "textures/item/entities/" + StringUtil.makeIdentifierValueSafe(evaluated.textureValue().replace("textures/", "")) + ".png"; resolved.add(new RenderedModel(model, Identifier.fromNamespaceAndPath("viabedrock", path))); } - state.models = List.copyOf(resolved); - state.yaw = entity.getYRot(); + return List.copyOf(resolved); } @Override diff --git a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java index 73f8c72..d0265c4 100644 --- a/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java @@ -83,6 +83,7 @@ private static ModelPart root(final BedrockGeometryModel geometry, final boolean final String name = player ? playerPartName(bone.definition.getName()) : bone.definition.getName(); final Bone parent = bones.get(bone.definition.getParent()); final boolean directPlayerPart = player && (PLAYER_PARTS.contains(name) || PLAYER_OVERLAYS.containsKey(name)); + // Bedrock pivots are absolute; ModelPart pivots are relative to their parent. final float parentX = directPlayerPart || parent == null ? 0 : parent.definition.getPivot().getX(); final float parentY = directPlayerPart || parent == null ? 24 : parent.definition.getPivot().getY(); final float parentZ = directPlayerPart || parent == null ? 0 : parent.definition.getPivot().getZ(); @@ -133,6 +134,7 @@ private static ModelPart cubePart(final Cube cube, final Parent bone, final floa final float pivotX = cube.getPivot().getX(); final float pivotY = cube.getPivot().getY(); final float pivotZ = cube.getPivot().getZ(); + // Keep vertices relative to the cube pivot so its rotation uses the Bedrock pivot. final float x = cube.getPosition().getX() - pivotX; final float y = pivotY - cube.getPosition().getY() - cube.getSize().getY(); final float z = cube.getPosition().getZ() - pivotZ;