Skip to content

Commit

Permalink
Add custom skull render distance (GeyserMC#2751)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
davchoo authored May 14, 2022
1 parent db13b4c commit b33cc51
Show file tree
Hide file tree
Showing 12 changed files with 306 additions and 82 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ public interface GeyserConfiguration {

boolean isAllowCustomSkulls();

int getMaxVisibleCustomSkulls();

int getCustomSkullRenderDistance();

IMetricsInfo getMetrics();

int getPendingAuthenticationTimeout();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -95,8 +90,57 @@ public void spawnEntity() {
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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<>();

/**
Expand Down Expand Up @@ -530,6 +530,7 @@ public GeyserSession(GeyserImpl geyser, BedrockServerSession bedrockServerSessio
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);

Expand Down
211 changes: 211 additions & 0 deletions core/src/main/java/org/geysermc/geyser/session/cache/SkullCache.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading

0 comments on commit b33cc51

Please sign in to comment.