[Minecraft]你的皮肤是怎么显示出来的?皮肤加载与渲染详解
Nickid2018
2021年07月16日 10:51
收录于文集
共10篇

在MC中,只要是拥有正版的玩家在正版服务器中都能显示自己独特的皮肤。那么,这些皮肤是怎么加载与渲染的呢,这篇专栏将会对皮肤机制进行详解。

目录

  1. 加载皮肤

    1. GameProfile/游戏档案

    2. Yggdrasil API

  2. 应用皮肤

    1. ClientboundPlayerInfoPacket

    2. 渲染

  3. 如何自定义你的皮肤

    1. 正版更换皮肤原理

    2. 代理Yggdrasil服务

    3. 使用URL定位你的皮肤

一.加载皮肤

1.GameProfile/游戏资料

在MC中,每个玩家都有GameProfile。这个游戏资料的内部存储了玩家的名称、UUID和皮肤信息,位于authlib库中。它的定义如下:

代码块
text/x-java
自动换行
复制代码
private final UUID id; // 玩家UUID数据,它代表了玩家的唯一标识

private final String name; // 玩家名称

private final PropertyMap properties = new PropertyMap(); // 属性信息,这内部存储了皮肤信息

private boolean legacy; // 这个我也无法确定是干什么的>_<,有可能为区分老账号与新账号的

public GameProfile(UUID id, String name) {
	// 名称和UUID不能同时为空(在玩家刚进入服务器时可能找不到UUID)
	if (id == null && StringUtils.isBlank(name))
		throw new IllegalArgumentException("Name and ID cannot both be blank");
	this.id = id;
	this.name = name;
}
复制成功

在PropertyMap中包含了我们的皮肤数据。皮肤数据是这个表中的一个Property,它是这样定义的:

代码块
text/x-java
自动换行
复制代码
private final String name; // 属性名称,对于皮肤属性此项通常为"textures"
private final String value; // 皮肤数据,为被Base64加密的JSON数据
private final String signature; // 签名,此签名是以Yggdrasil API的RSA私钥加密的结果,用于校验正确性
复制成功

在value中保存的就是我们的皮肤数据,是一个JSON。这个JSON是这样的:

代码块
JavaScript
自动换行
复制代码
{
	"timestamp":<这一项是Java的时间值,单位是ms>,
	"profileId":"<玩家的UUID>",
	"profileName":"<玩家的名称>",
	"signatureRequired":true, // 当皮肤属性含有签名时才会含有这项,否则不含有此属性
	"textures":{
		"SKIN":{
			"url":"<皮肤所处的URL>",
			"metadata":{ // 如果为Steve皮肤则不含有此项
				"model":"slim"
			}
		},
		"CAPE":{ // 如果玩家没有披风,则没有此项
			"url":"<披风所处的URL>"
		}
	}
}
复制成功

可以看到,皮肤最后是引用到了一个URL上,这个URL通常是来源于http://textures.minecraft.net/textures/<哈希码值>

在了解这些后,我们看看GameProfile是如何被序列化的。

GameProfile也可以转变为JSON,它的JSON看起来像这样(也把PropertyMap的数据写入了)

代码块
text/x-java
自动换行
复制代码
{
	"id":"<玩家UUID>",
	"name":"<玩家名称>",
	"properties":[
      {
			"name":"textures",
			"value":"<Base64加密的皮肤数据>",
			"signature":"<Yggdrasil API的RSA私钥加密的value数据>" // 这项可能不存在
      }
	]
}
复制成功

玩家可以通过GameProfile得到皮肤的信息,那么MC是如何获得玩家的游戏数据的呢。接下来就是获得GameProfile的方法:Yggdrasil API

2.Yggdrsail API

MC采用了一套特殊的API用于玩家账户的验证和获取数据,这就是Yggdrasil API,位于authlib库中。这篇专栏主要讨论它加载皮肤的部分。

在玩家加入世界后,数据是不完全的,只含有玩家名称和UUID。这时系统就会调用fillGameProfile填充玩家的数据,补全成为上文那样。

代码块
text/x-java
自动换行
复制代码
// com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService#fillGameProfile
protected GameProfile fillGameProfile(GameProfile profile, boolean requireSecure) {
	try {
		URL url = HttpAuthenticationService
				.constantURL("https://sessionserver.mojang.com/session/minecraft/profile/" + UUIDTypeAdapter.fromUUID(profile.getId()));
		url = HttpAuthenticationService.concatenateURL(url, "unsigned=" + (!requireSecure ? 1 : 0));
		MinecraftProfilePropertiesResponse response = getAuthenticationService().makeRequest(url, null, MinecraftProfilePropertiesResponse.class);
		if (response == null) {
			LOGGER.debug("Couldn't fetch profile properties for " + profile + " as the profile does not exist");
			return profile;
		}
		GameProfile result = new GameProfile(response.getId(), response.getName());
		result.getProperties().putAll((Multimap) response.getProperties());
		profile.getProperties().putAll((Multimap) response.getProperties());
		LOGGER.debug("Successfully fetched profile properties for " + profile);
		return result;
	} catch (AuthenticationException e) {
		LOGGER.warn("Couldn't look up profile properties for " + profile, e);
		return profile;
	}
}
复制成功

Yggdrasil API在进行GameProfile数据填充时会向https://sessionserver.mojang.com/profile/<玩家UUID>发送一个GET请求,附加参数unsigned代表需不需要进行签名。在通常情况下,requireSecure都为true,也就是会请求签名。GET请求的结果是一个JSON文本,内容就是上文提到的GameProfile的序列化JSON。通过这个JSON,系统就能解析出玩家的资料。

获取皮肤使用了另一个方法:getTextures,它能通过玩家的资料提取出皮肤材质信息:

代码块
text/x-java
自动换行
复制代码
// com.mojang.authlib.yggdrasil.YggdrasilMinecraftSessionService#getTextures
public Map<MinecraftProfileTexture.Type, MinecraftProfileTexture> getTextures(GameProfile profile, boolean requireSecure) {
	MinecraftTexturesPayload result;
	Property textureProperty = (Property) Iterables.getFirst(profile.getProperties().get("textures"), null);
	if (textureProperty == null)
		return new HashMap<>();
	if (requireSecure) {
		if (!textureProperty.hasSignature()) {
			LOGGER.error("Signature is missing from textures payload");
			throw new InsecureTextureException("Signature is missing from textures payload");
		}
			if (!textureProperty.isSignatureValid(this.publicKey)) {
			LOGGER.error("Textures payload has been tampered with (signature invalid)");
			throw new InsecureTextureException("Textures payload has been tampered with (signature invalid)");
		}
	}
	try {
		String json = new String(Base64.decodeBase64(textureProperty.getValue()), Charsets.UTF_8);
		result = (MinecraftTexturesPayload) this.gson.fromJson(json, MinecraftTexturesPayload.class);
	} catch (JsonParseException e) {
		LOGGER.error("Could not decode textures payload", (Throwable) e);
		return new HashMap<>();
	}
	if (result == null || result.getTextures() == null)
		return new HashMap<>();
	for (Map.Entry<MinecraftProfileTexture.Type, MinecraftProfileTexture> entry : result.getTextures().entrySet()) {
		if (!isWhitelistedDomain(((MinecraftProfileTexture) entry.getValue()).getUrl())) {
			LOGGER.error("Textures payload has been tampered with (non-whitelisted domain)");
			return new HashMap<>();
		}
	}
	return result.getTextures();
}
复制成功

从这段代码中,我们可以看到皮肤的成功加载由下面几个条件:

1)这个皮肤数据必须是有签名的,否则不进行解析

2)找到文件签名后,就用Yggdrasil API的公钥进行解密。这个公钥在MC的文件中,位于/yggdrasil_session_pubkey.der。解密之后的文本应该与value相同,否则验证失败

3)进行解析,提取出URL。如果URL不属于白名单上的域名(这些在白名单的域名有".minecraft.net"和".mojang.com"),那么也不会通过验证

在三重验证之后,就得到了一个皮肤的映射,可以让客户端下载并使用。

但是,填充GameProfile是服务端的操作,而getTexture是客户端操作,客户端要怎么获取填充好的GameProfile呢?

二.应用皮肤

1.ClientboundPlayerInfoPacket

紧接着上文,客户端需要服务端的已经填充好的GameProfile,那么服务端就要发送一个数据包向客户端通知客户端获得数据,这个数据包就是ClientboundPlayerInfoPacket。

这个数据包一共有5种模式:ADD_PLAYER(玩家加入),UPDATE_GAME_MODE(改变游戏模式),UPDATE_LATENCY,UPDATE_DISPLAY_NAME(显示名称改变),REMOVE_PLAYER(玩家移除)。有关于GameProfile的传输是在ADD_PLAYER内部的。

在玩家加入服务器后,服务器会把所有玩家(包括自身)的GameProfile通过ADD_PLAYER模式发送到客户端。

代码块
text/x-java
自动换行
复制代码
// net.minecraft.network.protocol.game.ClientboundPlayerInfoPacket.Action#ADD_PLAYER#read
// 读取部分
protected ClientboundPlayerInfoPacket.PlayerUpdate read(FriendlyByteBuf buf) {
	GameProfile gameProfile = new GameProfile(buf.readUUID(), buf.readUtf(16));
	PropertyMap propertyMap = gameProfile.getProperties();
	buf.readWithCount(friendlyByteBuf -> {
		String name = friendlyByteBuf.readUtf();
		String value = friendlyByteBuf.readUtf();
		if (friendlyByteBuf.readBoolean()) {
			String signature = friendlyByteBuf.readUtf();
			gameProfile.put(name, new Property(name, value, signature));
		} else {
			gameProfile.put(name, new Property(name, value));
		} 
	});
	GameType gameType = GameType.byId(buf.readVarInt());
	int i = buf.readVarInt();
	Component component = ClientboundPlayerInfoPacket.readDisplayName(buf);
	return new ClientboundPlayerInfoPacket.PlayerUpdate(gameProfile, i, gameType, component);
}
复制成功

在这个数据包传到客户端后,客户端的监听器将处理这个数据包:

代码块
text/x-java
自动换行
复制代码
// net.minecraft.client.multiplayer.ClientPacketListener#handlePlayerInfo
public void handlePlayerInfo(ClientboundPlayerInfoPacket packet) {
	PacketUtils.ensureRunningOnSameThread(packet, this, minecraft);
	for (ClientboundPlayerInfoPacket.PlayerUpdate playerUpdate : packet.getEntries()) {
		// 删除了一部分无关代码
		if (packet.getAction() == ClientboundPlayerInfoPacket.Action.ADD_PLAYER) {
			playerInfo = new PlayerInfo(playerUpdate);
			playerInfoMap.put(playerInfo.getProfile().getId(), playerInfo);
			minecraft.getPlayerSocialManager().addPlayer(playerInfo);
		} 
		// 删除了无关代码
	}
}
复制成功

GameProfile的数据会在这时传入PlayerInfo中,之后渲染系统会访问这个对象获取皮肤信息。先看看渲染系统是怎么编写的。

2.渲染

玩家的渲染在PlayerRenderer中,它通过AbstractClientPlayer的方法获取皮肤数据,下面是一个例子:

代码块
text/x-java
自动换行
复制代码
// net.minecraft.client.renderer.entity.player.PlayerRenderer#renderHand
private void renderHand(PoseStack stack, MultiBufferSource buffer, int i, AbstractClientPlayer player, ModelPart modelPart1, ModelPart modelPart2) {
	PlayerModel<AbstractClientPlayer> playerModel = getModel();
	setModelProperties(player);
	playerModel.attackTime = 0.0F;
	playerModel.crouching = false;
	playerModel.swimAmount = 0.0F;
	playerModel.setupAnim(player, 0.0F, 0.0F, 0.0F, 0.0F, 0.0F);
	modelPart1.xRot = 0.0F;
	modelPart1.render(stack, buffer.getBuffer(RenderType.entitySolid(player.getSkinTextureLocation())), i, OverlayTexture.NO_OVERLAY);
	modelPart2.xRot = 0.0F;
	modelPart2.render(stack, buffer.getBuffer(RenderType.entityTranslucent(player.getSkinTextureLocation())), i, OverlayTexture.NO_OVERLAY);
}
复制成功

那么AbstractClientPlayer是哪里来的呢?答案还是在客户端数据包监听器内部。

在进入服务器时,服务器会把所有玩家GameProfile发送到客户端。而当其他玩家进入本玩家可视范围内时,服务端会再发送一个ClientboundAddPlayerPacket用于通知。通过这个数据包,客户端可以添加这个玩家对象。

代码块
text/x-java
自动换行
复制代码
// net.minecraft.client.multiplayer.ClientPacketListener#handleAddPlayer
public void handleAddPlayer(ClientboundAddPlayerPacket packet) {
	PacketUtils.ensureRunningOnSameThread(packet, this, minecraft);
	double x = packet.getX();
	double y = packet.getY();
	double z = packet.getZ();
	float yRot = (packet.getyRot() * 360) / 256.0F;
	float xRot = (packet.getxRot() * 360) / 256.0F;
	int id = packet.getEntityId();
	RemotePlayer remotePlayer = new RemotePlayer(minecraft.level, getPlayerInfo(packet.getPlayerId()).getProfile());
	remotePlayer.setId(id);
	remotePlayer.setPacketCoordinates(x, y, z);
	remotePlayer.absMoveTo(x, y, z, yRot, xRot);
	remotePlayer.setOldPosAndRot();
	level.addPlayer(id, remotePlayer);
}
复制成功

PlayerInfo在这时就传入到了RemotePlayer(AbstractClientPlayer的子类)中。当渲染器渲染玩家时,调用getSkinTextureLocation,进而调用PlayerInfo的getSkinLocation。如果没有下载皮肤材质,调用皮肤下载器,使用GameProfile中的URL地址下载到皮肤。

代码块
text/x-java
自动换行
复制代码
// net.minecraft.client.multiplayer.PlayerInfo#registerTextures
protected void registerTextures() {
	synchronized (this) {
		if (!pendingTextures) {
			pendingTextures = true;
			Minecraft.getInstance().getSkinManager().registerSkins(profile, (type, location, texture) -> {
				// 这里可能是并发线程加载   
				textureLocations.put(type, location);
				if (type == MinecraftProfileTexture.Type.SKIN) {
					skinModel = texture.getMetadata("model");
					if (skinModel == null)
						skinModel = "default"; 
				} 
			}, true);
		} 
	} 
}
复制成功

如果皮肤材质加载失败了,那么系统会自动使用Steve/Alex皮肤。使用这两个默认皮肤不是随机的,它有关于玩家UUID的哈希码值。

代码块
text/x-java
自动换行
复制代码
// net.minecraft.client.resource.DefaultPlayerSkin#isAlexDefault
private static boolean isAlexDefault(UUID uuid) {
	return uuid.hashCode() & 0x1 == 1;
	// 这段代码的意义是:如果哈希码值为奇数则使用Alex皮肤,偶数则使用Steve皮肤
}
复制成功

至此,你的皮肤就展示在你的屏幕上了!

三.如何自定义你的皮肤

1.正版更换皮肤原理

官方启动器和很多第三方启动器都支持更换皮肤,它们是通过API进行换肤操作。

官方启动器的换肤界面

这个API的基础网址是https://api.mojang.com/user/profile/<玩家UUID>/skin,支持三种操作:上传皮肤,改变皮肤,重置皮肤。注意:修改皮肤只能在已登录条件下执行,因为这些操作都需要提供accessToken(访问令牌)

1)上传皮肤

请求为PUT,Headers需要包含“Authorization”一项,值为“Bearer <访问令牌>”

负载数据有两项:

    "model"-值为slim或者为空字符串,slim代表Alex模型,空字符串代表Steve模型

    "file"-负载数据为图片

2)改变皮肤

请求为POST,Headers要求与第一项相同

在URL上负载数据“model”和“url”,分别为模型和材质地址。材质地址只能属于第一节所述的几个网站,否则不会成功换肤

返回值正常为空负载

3)重置皮肤

请求为DELETE,Headers要求与第一项相同,无负载

2.代理Yggdrasil服务

对于没有正版账号的玩家来说,无法更换皮肤,但是如果将Yggdrasil服务换成自己的,不就能用自己的皮肤了吗?这就是“皮肤站”和“外置登录”的来源。

HMCL上的外置登录

实现这个功能,就必须在启动MC时加入一个Java Agent进行代码修改,现在大家使用的注入器就是authlib-injector,它能修改Yggdrasil API的底层代码,使Yggdrasil服务定位到皮肤站的API上,这样就完成了代理。

(注:这种操作也让登录定位到了新的API上,所以MC系统会把你识别为“正版玩家”)

3.使用URL定位你的皮肤

根据Yggdrasil API的原理,可以看到我们可以只修改来源于服务端的GameProfile中的textures属性数据同时撤销客户端的Yggdrasil API的三重检查来达到重新定位皮肤的目的。

可是怎么修改呢?

首先是服务端,服务端在online-mode为false时会自动调用ServerLoginPacketListenerImpl#createFakeProfile生成一个“假的”游戏资料,,UUID的生成与名称有关:

代码块
text/x-java
自动换行
复制代码
// net.minecraft.world.entity.player.Player#createPlayerUUID(String)
public static UUID createPlayerUUID(String name) {
	return UUID.nameUUIDFromBytes(("OfflinePlayer:" + name).getBytes(StandardCharsets.UTF_8));
}
复制成功

这个对象中不包含textures属性。我们可以在这里插入代码使它加上数据,这样传送到客户端的数据就包含了skin属性。(注意,此处不要生成signature,因为没有私钥生成不了)

代码块
text/x-java
自动换行
复制代码
// net.minecraft.server.network.ServerLoginPacketListenerImpl#createFakeProfile
protected GameProfile createFakeProfile(GameProfile profile) {
	UUID uUID = Player.createPlayerUUID(profile.getName());
	return new GameProfile(uUID, profile.getName());
	// 插入点可以放在RETURN上(Mixin)
}
复制成功

另一部分是客户端,我们只需要撤销掉三重检查就可以达到目的了。由于Mixin无法修改authlib库内部的类,所以我们可以采用继承+代理这个类更换掉Minecraft类中的对象。

代码块
text/x-java
自动换行
复制代码
// net.minecraft.client.Minecraft#<init>
public Minecraft(GameConfig config) {
	// ...
	proxy = config.user.proxy;
	YggdrasilAuthenticationService yggdrasilAuthenticationService = new YggdrasilAuthenticationService(proxy);
	// 在此处替换为代理的服务
	minecraftSessionService = yggdrasilAuthenticationService.createMinecraftSessionService();
	socialInteractionsService = createSocialInteractions(yggdrasilAuthenticationService, config);
	user = config.user.user;
	// ...
}
复制成功

这样,就可以自定义你的皮肤了!(对披风一样管用,只是多加了一点代码而已)

代码来源:1.18_experiment-snapshot-1,Mojang Mapping,使用Yarn Mapping的类名及方法名可能不同,但是逻辑一样

代码反混淆器及反编译器:MCDynamicExchanger beta8,bug正在修复

Mojang API的用法:https://c4k3.github.io/wiki.vg/Mojang_API.html