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..d6c7e23 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/injection/mixin/features/render/MixinCustomEntity.java @@ -0,0 +1,58 @@ +/* + * 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) { + if (cir.getReturnValueZ()) { + 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..5ab965f --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockEntityRenderer.java @@ -0,0 +1,130 @@ +/* + * 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.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; + +/** Renders ViaBedrock custom actors while leaving ordinary interactions invisible. */ +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) { + 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)) { + this.currentPacks = null; + this.models.clear(); + this.resolvedModelSets.clear(); + 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(); + 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()); + 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))); + } + return List.copyOf(resolved); + } + + @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..d0265c4 --- /dev/null +++ b/src/main/java/com/viaversion/viafabricplus/bedrock/render/BedrockGeometry.java @@ -0,0 +1,209 @@ +/* + * 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; +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", + "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)); + // 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(); + 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(); + // 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; + 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(sourceFace(uv, direction))) { + 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) { + int index = 0; + for (Direction direction : CUBE_FACES) { + if (faces.contains(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"; + 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..80e5831 --- /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, 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); + }); + } + + 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",