Add custom skull render distance (#2751)

* Add player skull render distance

* Improve updateVisibleSkulls a bit

Avoid rechecking visibility on small movements

* Periodically despawn unused skull entities

* Don't hide skull entity for position/rotation changes

Prevents flickering for skulls that are rotating

* Update visible skulls when a skull is removed

* Only update on removal if an entity is assigned

* No need to check for skull in ChunkUtils

Update copyright year

* Avoid rechecking all skulls when a skull is added/removed

* Allow skull render distance and number to be configured

Renamed some fields to better match their values

* Compare texture property directly from GameProfile

* Remove unnecessary blockState field from SkullPlayerEntity

* Use binarySearch for insertion

Wait for player movement before loading skulls

* Allow culling to be disabled by setting max-visible-custom-skulls to -1

* Only remove skulls in inRangeSkulls when culling is enabled

* Add suggestions from review

* Merge the for loops in updateVisibleSkulls

* Fix skulls being leaked on chunk unload
This commit is contained in:
David Choo 2022-05-14 15:12:18 -04:00 committed by GitHub
parent db13b4c276
commit b33cc512b4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 306 additions and 82 deletions

View File

@ -101,6 +101,10 @@ public interface GeyserConfiguration {
boolean isAllowCustomSkulls();
int getMaxVisibleCustomSkulls();
int getCustomSkullRenderDistance();
IMetricsInfo getMetrics();
int getPendingAuthenticationTimeout();

View File

@ -130,6 +130,12 @@ public abstract class GeyserJacksonConfiguration implements GeyserConfiguration
@JsonProperty("allow-custom-skulls")
private boolean allowCustomSkulls = true;
@JsonProperty("max-visible-custom-skulls")
private int maxVisibleCustomSkulls = 128;
@JsonProperty("custom-skull-render-distance")
private int customSkullRenderDistance = 32;
@JsonProperty("add-non-bedrock-items")
private boolean addNonBedrockItems = true;

View File

@ -26,33 +26,28 @@
package org.geysermc.geyser.entity.type.player;
import com.nukkitx.math.vector.Vector3f;
import com.nukkitx.math.vector.Vector3i;
import com.nukkitx.protocol.bedrock.data.GameType;
import com.nukkitx.protocol.bedrock.data.PlayerPermission;
import com.nukkitx.protocol.bedrock.data.command.CommandPermission;
import com.nukkitx.protocol.bedrock.data.entity.EntityData;
import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
import com.nukkitx.protocol.bedrock.packet.AddPlayerPacket;
import lombok.Getter;
import org.geysermc.geyser.level.block.BlockStateValues;
import org.geysermc.geyser.session.GeyserSession;
import org.geysermc.geyser.session.cache.SkullCache;
import org.geysermc.geyser.skin.SkullSkinManager;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* A wrapper to handle skulls more effectively - skulls have to be treated as entities since there are no
* custom player skulls in Bedrock.
*/
public class SkullPlayerEntity extends PlayerEntity {
/**
* Stores the block state that the skull is associated with. Used to determine if the block in the skull's position
* has changed
*/
@Getter
private final int blockState;
public SkullPlayerEntity(GeyserSession session, long geyserId, Vector3f position, float rotation, int blockState, String texturesProperty) {
super(session, 0, geyserId, UUID.randomUUID(), position, Vector3f.ZERO, rotation, 0, rotation, "", texturesProperty);
this.blockState = blockState;
public SkullPlayerEntity(GeyserSession session, long geyserId) {
super(session, 0, geyserId, UUID.randomUUID(), Vector3f.ZERO, Vector3f.ZERO, 0, 0, 0, "", null);
setPlayerList(false);
}
@ -95,8 +90,57 @@ public class SkullPlayerEntity extends PlayerEntity {
session.sendUpstreamPacket(addPlayerPacket);
}
public void despawnEntity(Vector3i position) {
this.despawnEntity();
session.getSkullCache().remove(position, this);
/**
* Hide the player entity so that it can be reused for a different skull.
*/
public void free() {
setFlag(EntityFlag.INVISIBLE, true);
updateBedrockMetadata();
// Move skull entity out of the way
moveAbsolute(session.getPlayerEntity().getPosition().up(128), 0, 0, 0, false, true);
}
public void updateSkull(SkullCache.Skull skull) {
if (!skull.getTexturesProperty().equals(getTexturesProperty())) {
// Make skull invisible as we change skins
setFlag(EntityFlag.INVISIBLE, true);
updateBedrockMetadata();
setTexturesProperty(skull.getTexturesProperty());
SkullSkinManager.requestAndHandleSkin(this, session, (skin -> session.scheduleInEventLoop(() -> {
// Delay to minimize split-second "player" pop-in
setFlag(EntityFlag.INVISIBLE, false);
updateBedrockMetadata();
}, 250, TimeUnit.MILLISECONDS)));
} else {
// Just a rotation/position change
setFlag(EntityFlag.INVISIBLE, false);
updateBedrockMetadata();
}
float x = skull.getPosition().getX() + .5f;
float y = skull.getPosition().getY() - .01f;
float z = skull.getPosition().getZ() + .5f;
float rotation;
int blockState = skull.getBlockState();
byte floorRotation = BlockStateValues.getSkullRotation(blockState);
if (floorRotation == -1) {
// Wall skull
y += 0.25f;
rotation = BlockStateValues.getSkullWallDirections().get(blockState);
switch ((int) rotation) {
case 180 -> z += 0.24f; // North
case 0 -> z -= 0.24f; // South
case 90 -> x += 0.24f; // West
case 270 -> x -= 0.24f; // East
}
} else {
rotation = (180f + (floorRotation * 22.5f)) % 360;
}
moveAbsolute(Vector3f.from(x, y, z), rotation, 0, rotation, true, true);
}
}

View File

@ -173,6 +173,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
private final LodestoneCache lodestoneCache;
private final PistonCache pistonCache;
private final PreferencesCache preferencesCache;
private final SkullCache skullCache;
private final TagCache tagCache;
private final WorldCache worldCache;
@ -220,7 +221,6 @@ public class GeyserSession implements GeyserConnection, CommandSender {
@Setter
private ItemMappings itemMappings;
private final Map<Vector3i, SkullPlayerEntity> skullCache = new Object2ObjectOpenHashMap<>();
private final Long2ObjectMap<ClientboundMapItemDataPacket> storedMaps = new Long2ObjectOpenHashMap<>();
/**
@ -530,6 +530,7 @@ public class GeyserSession implements GeyserConnection, CommandSender {
this.lodestoneCache = new LodestoneCache();
this.pistonCache = new PistonCache(this);
this.preferencesCache = new PreferencesCache(this);
this.skullCache = new SkullCache(this);
this.tagCache = new TagCache();
this.worldCache = new WorldCache(this);

View File

@ -0,0 +1,211 @@
/*
* Copyright (c) 2019-2022 GeyserMC. http://geysermc.org
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*
* @author GeyserMC
* @link https://github.com/GeyserMC/Geyser
*/
package org.geysermc.geyser.session.cache;
import com.nukkitx.math.vector.Vector3f;
import com.nukkitx.math.vector.Vector3i;
import it.unimi.dsi.fastutil.objects.Object2ObjectOpenHashMap;
import lombok.Data;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import org.geysermc.geyser.entity.type.player.SkullPlayerEntity;
import org.geysermc.geyser.session.GeyserSession;
import java.util.*;
public class SkullCache {
private final int maxVisibleSkulls;
private final boolean cullingEnabled;
private final int skullRenderDistanceSquared;
/**
* The time in milliseconds before unused skull entities are despawned
*/
private static final long CLEANUP_PERIOD = 10000;
@Getter
private final Map<Vector3i, Skull> skulls = new Object2ObjectOpenHashMap<>();
private final List<Skull> inRangeSkulls = new ArrayList<>();
private final Deque<SkullPlayerEntity> unusedSkullEntities = new ArrayDeque<>();
private int totalSkullEntities = 0;
private final GeyserSession session;
private Vector3f lastPlayerPosition;
private long lastCleanup = System.currentTimeMillis();
public SkullCache(GeyserSession session) {
this.session = session;
this.maxVisibleSkulls = session.getGeyser().getConfig().getMaxVisibleCustomSkulls();
this.cullingEnabled = this.maxVisibleSkulls != -1;
// Normal skulls are not rendered beyond 64 blocks
int distance = Math.min(session.getGeyser().getConfig().getCustomSkullRenderDistance(), 64);
this.skullRenderDistanceSquared = distance * distance;
}
public void putSkull(Vector3i position, String texturesProperty, int blockState) {
Skull skull = skulls.computeIfAbsent(position, Skull::new);
skull.texturesProperty = texturesProperty;
skull.blockState = blockState;
if (skull.entity != null) {
skull.entity.updateSkull(skull);
} else {
if (!cullingEnabled) {
assignSkullEntity(skull);
return;
}
if (lastPlayerPosition == null) {
return;
}
skull.distanceSquared = position.distanceSquared(lastPlayerPosition.getX(), lastPlayerPosition.getY(), lastPlayerPosition.getZ());
if (skull.distanceSquared < skullRenderDistanceSquared) {
// Keep list in order
int i = Collections.binarySearch(inRangeSkulls, skull, Comparator.comparingInt(Skull::getDistanceSquared));
if (i < 0) { // skull.distanceSquared is a new distance value
i = -i - 1;
}
inRangeSkulls.add(i, skull);
if (i < maxVisibleSkulls) {
// Reassign entity from the farthest skull to this one
if (inRangeSkulls.size() > maxVisibleSkulls) {
freeSkullEntity(inRangeSkulls.get(maxVisibleSkulls));
}
assignSkullEntity(skull);
}
}
}
}
public void removeSkull(Vector3i position) {
Skull skull = skulls.remove(position);
if (skull != null) {
boolean hadEntity = skull.entity != null;
freeSkullEntity(skull);
if (cullingEnabled) {
inRangeSkulls.remove(skull);
if (hadEntity && inRangeSkulls.size() >= maxVisibleSkulls) {
// Reassign entity to the closest skull without an entity
assignSkullEntity(inRangeSkulls.get(maxVisibleSkulls - 1));
}
}
}
}
public void updateVisibleSkulls() {
if (cullingEnabled) {
// No need to recheck skull visibility for small movements
if (lastPlayerPosition != null && session.getPlayerEntity().getPosition().distanceSquared(lastPlayerPosition) < 4) {
return;
}
lastPlayerPosition = session.getPlayerEntity().getPosition();
inRangeSkulls.clear();
for (Skull skull : skulls.values()) {
skull.distanceSquared = skull.position.distanceSquared(lastPlayerPosition.getX(), lastPlayerPosition.getY(), lastPlayerPosition.getZ());
if (skull.distanceSquared > skullRenderDistanceSquared) {
freeSkullEntity(skull);
} else {
inRangeSkulls.add(skull);
}
}
inRangeSkulls.sort(Comparator.comparingInt(Skull::getDistanceSquared));
for (int i = inRangeSkulls.size() - 1; i >= 0; i--) {
if (i < maxVisibleSkulls) {
assignSkullEntity(inRangeSkulls.get(i));
} else {
freeSkullEntity(inRangeSkulls.get(i));
}
}
}
// Occasionally clean up unused entities as we want to keep skull
// entities around for later use, to reduce "player" pop-in
if ((System.currentTimeMillis() - lastCleanup) > CLEANUP_PERIOD) {
lastCleanup = System.currentTimeMillis();
for (SkullPlayerEntity entity : unusedSkullEntities) {
entity.despawnEntity();
totalSkullEntities--;
}
unusedSkullEntities.clear();
}
}
private void assignSkullEntity(Skull skull) {
if (skull.entity != null) {
return;
}
if (unusedSkullEntities.isEmpty()) {
if (!cullingEnabled || totalSkullEntities < maxVisibleSkulls) {
// Create a new entity
long geyserId = session.getEntityCache().getNextEntityId().incrementAndGet();
skull.entity = new SkullPlayerEntity(session, geyserId);
skull.entity.spawnEntity();
skull.entity.updateSkull(skull);
totalSkullEntities++;
}
} else {
// Reuse an entity
skull.entity = unusedSkullEntities.removeFirst();
skull.entity.updateSkull(skull);
}
}
private void freeSkullEntity(Skull skull) {
if (skull.entity != null) {
skull.entity.free();
unusedSkullEntities.addFirst(skull.entity);
skull.entity = null;
}
}
public void clear() {
skulls.clear();
inRangeSkulls.clear();
unusedSkullEntities.clear();
totalSkullEntities = 0;
lastPlayerPosition = null;
}
@RequiredArgsConstructor
@Data
public static class Skull {
private String texturesProperty;
private int blockState;
private SkullPlayerEntity entity;
private final Vector3i position;
private int distanceSquared;
}
}

View File

@ -29,19 +29,14 @@ import com.github.steveice10.mc.protocol.data.game.level.block.BlockEntityType;
import com.github.steveice10.opennbt.tag.builtin.CompoundTag;
import com.github.steveice10.opennbt.tag.builtin.ListTag;
import com.github.steveice10.opennbt.tag.builtin.StringTag;
import com.nukkitx.math.vector.Vector3f;
import com.nukkitx.math.vector.Vector3i;
import com.nukkitx.nbt.NbtMapBuilder;
import com.nukkitx.protocol.bedrock.data.entity.EntityFlag;
import org.geysermc.geyser.entity.type.player.SkullPlayerEntity;
import org.geysermc.geyser.level.block.BlockStateValues;
import org.geysermc.geyser.session.GeyserSession;
import org.geysermc.geyser.skin.SkinProvider;
import org.geysermc.geyser.skin.SkullSkinManager;
import java.util.LinkedHashMap;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
@BlockEntity(type = BlockEntityType.SKULL)
public class SkullBlockEntityTranslator extends BlockEntityTranslator implements RequiresBlockState {
@ -74,65 +69,18 @@ public class SkullBlockEntityTranslator extends BlockEntityTranslator implements
return CompletableFuture.completedFuture(null);
}
public static void spawnPlayer(GeyserSession session, CompoundTag tag, int posX, int posY, int posZ, int blockState) {
float x = posX + .5f;
float y = posY - .01f;
float z = posZ + .5f;
float rotation;
byte floorRotation = BlockStateValues.getSkullRotation(blockState);
if (floorRotation == -1) {
// Wall skull
y += 0.25f;
rotation = BlockStateValues.getSkullWallDirections().get(blockState);
switch ((int) rotation) {
case 180 -> z += 0.24f; // North
case 0 -> z -= 0.24f; // South
case 90 -> x += 0.24f; // West
case 270 -> x -= 0.24f; // East
}
} else {
rotation = (180f + (floorRotation * 22.5f)) % 360;
}
public static void translateSkull(GeyserSession session, CompoundTag tag, int posX, int posY, int posZ, int blockState) {
Vector3i blockPosition = Vector3i.from(posX, posY, posZ);
Vector3f entityPosition = Vector3f.from(x, y, z);
getTextures(tag).whenComplete((texturesProperty, throwable) -> {
if (texturesProperty == null) {
session.getGeyser().getLogger().debug("Custom skull with invalid SkullOwner tag: " + blockPosition + " " + tag);
return;
}
if (session.getEventLoop().inEventLoop()) {
spawnPlayer(session, texturesProperty, blockPosition, entityPosition, rotation, blockState);
session.getSkullCache().putSkull(blockPosition, texturesProperty, blockState);
} else {
session.executeInEventLoop(() -> spawnPlayer(session, texturesProperty, blockPosition, entityPosition, rotation, blockState));
session.executeInEventLoop(() -> session.getSkullCache().putSkull(blockPosition, texturesProperty, blockState));
}
});
}
private static void spawnPlayer(GeyserSession session, String texturesProperty, Vector3i blockPosition,
Vector3f entityPosition, float rotation, int blockState) {
long geyserId = session.getEntityCache().getNextEntityId().incrementAndGet();
SkullPlayerEntity existingSkull = session.getSkullCache().get(blockPosition);
if (existingSkull != null) {
// Ensure that two skulls can't spawn on the same point
existingSkull.despawnEntity(blockPosition);
}
SkullPlayerEntity player = new SkullPlayerEntity(session, geyserId, entityPosition, rotation, blockState, texturesProperty);
// Cache entity
session.getSkullCache().put(blockPosition, player);
player.spawnEntity();
SkullSkinManager.requestAndHandleSkin(player, session, (skin -> session.scheduleInEventLoop(() -> {
// Delay to minimize split-second "player" pop-in
player.setFlag(EntityFlag.INVISIBLE, false);
player.updateBedrockMetadata();
}, 250, TimeUnit.MILLISECONDS)));
}
}

View File

@ -140,6 +140,8 @@ public class BedrockMovePlayerTranslator extends PacketTranslator<MovePlayerPack
session.sendUpstreamPacket(movePlayerPacket);
}
}
session.getSkullCache().updateVisibleSkulls();
}
} else {
// Not a valid move

View File

@ -65,7 +65,7 @@ public class JavaBlockEntityDataTranslator extends PacketTranslator<ClientboundB
packet.getNbt(), blockState), packet.getPosition());
// Check for custom skulls.
if (session.getPreferencesCache().showCustomSkulls() && packet.getNbt() != null && packet.getNbt().contains("SkullOwner")) {
SkullBlockEntityTranslator.spawnPlayer(session, packet.getNbt(), position.getX(), position.getY(), position.getZ(), blockState);
SkullBlockEntityTranslator.translateSkull(session, packet.getNbt(), position.getX(), position.getY(), position.getZ(), blockState);
}
// If block entity is command block, OP permission level is appropriate, player is in creative mode and the NBT is not empty

View File

@ -32,7 +32,9 @@ import org.geysermc.geyser.translator.protocol.PacketTranslator;
import org.geysermc.geyser.translator.protocol.Translator;
import org.geysermc.geyser.util.ChunkUtils;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
@Translator(packet = ClientboundForgetLevelChunkPacket.class)
public class JavaForgetLevelChunkTranslator extends PacketTranslator<ClientboundForgetLevelChunkPacket> {
@ -41,19 +43,18 @@ public class JavaForgetLevelChunkTranslator extends PacketTranslator<Clientbound
public void translate(GeyserSession session, ClientboundForgetLevelChunkPacket packet) {
session.getChunkCache().removeChunk(packet.getX(), packet.getZ());
//Checks if a skull is in an unloaded chunk then removes it
Iterator<Vector3i> iterator = session.getSkullCache().keySet().iterator();
while (iterator.hasNext()) {
Vector3i position = iterator.next();
// Checks if a skull is in an unloaded chunk then removes it
List<Vector3i> removedSkulls = new ArrayList<>();
for (Vector3i position : session.getSkullCache().getSkulls().keySet()) {
if ((position.getX() >> 4) == packet.getX() && (position.getZ() >> 4) == packet.getZ()) {
session.getSkullCache().get(position).despawnEntity();
iterator.remove();
removedSkulls.add(position);
}
}
removedSkulls.forEach(session.getSkullCache()::removeSkull);
if (!session.getGeyser().getWorldManager().shouldExpectLecternHandled()) {
// Do the same thing with lecterns
iterator = session.getLecternCache().iterator();
Iterator<Vector3i> iterator = session.getLecternCache().iterator();
while (iterator.hasNext()) {
Vector3i position = iterator.next();
if ((position.getX() >> 4) == packet.getX() && (position.getZ() >> 4) == packet.getZ()) {

View File

@ -275,7 +275,7 @@ public class JavaLevelChunkWithLightTranslator extends PacketTranslator<Clientbo
// Check for custom skulls
if (session.getPreferencesCache().showCustomSkulls() && type == BlockEntityType.SKULL && tag != null && tag.contains("SkullOwner")) {
SkullBlockEntityTranslator.spawnPlayer(session, tag, x + chunkBlockX, y, z + chunkBlockZ, blockState);
SkullBlockEntityTranslator.translateSkull(session, tag, x + chunkBlockX, y, z + chunkBlockZ, blockState);
}
}

View File

@ -45,6 +45,7 @@ import org.geysermc.geyser.level.chunk.GeyserChunkSection;
import org.geysermc.geyser.level.chunk.bitarray.SingletonBitArray;
import org.geysermc.geyser.registry.BlockRegistries;
import org.geysermc.geyser.session.GeyserSession;
import org.geysermc.geyser.session.cache.SkullCache;
import org.geysermc.geyser.text.GeyserLocale;
import org.geysermc.geyser.level.BedrockDimension;
import org.geysermc.geyser.translator.level.block.entity.BedrockOnlyBlockEntity;
@ -150,10 +151,9 @@ public class ChunkUtils {
// Otherwise, let's still store our reference to the item frame, but let the new block take precedence for now
}
SkullPlayerEntity skull = session.getSkullCache().get(position);
if (skull != null && blockState != skull.getBlockState()) {
if (BlockStateValues.getSkullVariant(blockState) == -1) {
// Skull is gone
skull.despawnEntity(position);
session.getSkullCache().removeSkull(position);
}
// Prevent moving_piston from being placed

View File

@ -148,6 +148,13 @@ cache-images: 0
# Allows custom skulls to be displayed. Keeping them enabled may cause a performance decrease on older/weaker devices.
allow-custom-skulls: true
# The maximum number of custom skulls to be displayed per player. Increasing this may decrease performance on weaker devices.
# Setting this to -1 will cause all custom skulls to be displayed regardless of distance or number.
max-visible-custom-skulls: 128
# The radius in blocks around the player in which custom skulls are displayed.
custom-skull-render-distance: 32
# Whether to add (at this time, only) the furnace minecart as a separate item in the game, which normally does not exist in Bedrock Edition.
# This should only need to be disabled if using a proxy that does not use the "transfer packet" style of server switching.
# If this is disabled, furnace minecart items will be mapped to hopper minecart items.