[WIP] Why Preemptive Spikes Are

Preemptive has been a strategy for 4 years. Over the years, people have discovered many properties of the readings(spikes) from the pie chart we use for it, such as

  • The order of which block entity gets rendered first drastically changes the readings
  • Toggling hitboxes changes the reading
  • Hovering(outlining) blocks changes the reading
    (which is quite odd given the outline is not a block entity)
  • Readings depend heavily on the hardware, mostly GPU architecture

While people have done tons of tests in game to try and discover more properties, there has not been a proper analysis of the code that these readings are produced from.

In this post I will be presenting:

  • An incomplete model of how entities and block entities render in Minecraft 1.16.1
    and what the relevant profiler pie charts are actually profiling
    • explains:
      • why the level.entities.blockentities reading is not additive
        & what level.entities.unspecified even is
      • why chest behind spikes have ‘higher priority’
      • how toggling hitboxes changes the reading
      • why and how outlining blocks changes the reading
      • why and when silverfish entity spike works to differentiate silverfish and zombie spawners
  • (TBD) Experiments that match and don’t match the model’s predictions
    & Theories and further experiments for the mismatching cases
  • (TBD) My thoughts on what may and may not be possible to improve preemptive

The Model

Background

Every time Minecraft wants to render something, such as a block of stone, the game breaks it down to more basic units before it is sent to the GPU to be drawn. In the case of a stone block, it first gets broken down to the six faces of that cube, and then each face would be a quadrilateral determined by its four vertices (the corner of the original stone cube ends up being 3 vertices, it is not shared between faces).

Each vertex would carry some information, including its

  • position in space
  • color (overlaid on its texture,
    used for biome coloring I think)
  • position(a 2d coordinate, commonly noted as (u, v)
    to differentiate from the space position (x, y, z)) uv on a texture map
    • the texture map is uploaded to the GPU independently before the vertices
    • sometimes multiple texture layers are overlaid on top of each other,
      so there may be multiple sets of uv’s.
    • for any pixel inside the determined shape, its used texture pixel coordinate
      on the texture atlas is interpolated between the vertices’ uv
  • normal (vertex normal, basically useless in Minecraft I think
    because everything is a cube which uses face normals)

After having set the rendering states of the GPU (including the texture atlas, render mode (what shape to draw), transparency,
should or should not be affected by the light map, etc. ) these vertices are uploaded to the GPU to be drawn. (for opaque objects it does not matter in what order they are uploaded, the GPU only keeps the pixel that is closest to the camera once it’s attempted to be drawn and the further pixel is just discarded.)


RenderBuffers

Now, as each GPU upload is expensive, the game wants to minimize the amount of times doing that. As such, instead of immediately uploading every single basic shape, the game stores it in buffers and uploads them in whole batches. In the case of blocks, they are uploaded for every chunk section(subchunk).

The vast majority of blocks in the game share the same render states (RenderType) so it is natural to have a shared buffer across frames that holds the same piece of memory. However for other things rendered in the game that is not the case. For example, a chest should be affected by the light map, the eyes of a spider should not, a lightning strike should not be culled, and the beacon beam may be translucent on the outer layer but opaque on the inner layer (beacon beams have two layers, crazy I know). Each of these cases will be using a different RenderType and vertices explained by different RenderTypes cannot be uploaded to the GPU in the same batch. If each RenderType were to have its own buffer that holds a piece of memory, it can be a waste of memory since there are a lot of RenderTypes even in vanilla.

So instead the game only creates a dedicated render buffer for certain RenderTypes, including

      Sheets.solidBlockSheet()
      Sheets.cutoutBlockSheet()
      Sheets.bannerSheet()
      Sheets.translucentCullBlockSheet()
      Sheets.shieldSheet()
      Sheets.bedSheet()
      Sheets.shulkerBoxSheet()
      Sheets.signSheet()
      Sheets.chestSheet()
      RenderType.translucentNoCrumbling()
      RenderType.armorGlint()
      RenderType.armorEntityGlint()
      RenderType.glint()
      RenderType.glintDirect()
      RenderType.entityGlint()
      RenderType.entityGlintDirect()
      RenderType.waterMask()

which I assume are the ones that were determined most likely to be rendered a lot of. In the case of preemptive of everything that’s ever encountered I think chests (and maybe beds if you consider bed preemptive or something) are in this list, while pretty much everything else isn’t.

Each of the Sheets.xxxSheet() call returns the corresponding one in the following:

   private static final RenderType SHULKER_BOX_SHEET_TYPE = RenderType.entityCutoutNoCull(SHULKER_SHEET);
   private static final RenderType BED_SHEET_TYPE = RenderType.entitySolid(BED_SHEET);
   private static final RenderType BANNER_SHEET_TYPE = RenderType.entityNoOutline(BANNER_SHEET);
   private static final RenderType SHIELD_SHEET_TYPE = RenderType.entityNoOutline(SHIELD_SHEET);
   private static final RenderType SIGN_SHEET_TYPE = RenderType.entityCutoutNoCull(SIGN_SHEET);
   private static final RenderType CHEST_SHEET_TYPE = RenderType.entityCutout(CHEST_SHEET);
   private static final RenderType SOLID_BLOCK_SHEET = RenderType.entitySolid(TextureAtlas.LOCATION_BLOCKS);
   private static final RenderType CUTOUT_BLOCK_SHEET = RenderType.entityCutout(TextureAtlas.LOCATION_BLOCKS);
   private static final RenderType TRANSLUCENT_ITEM_CULL_BLOCK_SHEET = RenderType.itemEntityTranslucentCull(TextureAtlas.LOCATION_BLOCKS);
   private static final RenderType TRANSLUCENT_CULL_BLOCK_SHEET = RenderType.entityTranslucentCull(TextureAtlas.LOCATION_BLOCKS);

in which SHULKER_SHEET, BED_SHEET, BANNER_SHEET, SHIELD_SHEET, SIGN_SHEET and CHEST_SHEET are distinct ResourceLocations and not TextureAtlas.LOCATION_BLOCKS.Notice how BANNER_SHEET_TYPE and SHIELD_SHEET_TYPE are both calling RenderType.entityNoOutline but with different resource locations. These two RenderTypes will be considered distinct.


BufferSource

With all of that in mind, let’s take a look at MultiBufferSource. We only need to care about the implementation BufferSource since that is what everything except entity outlines (which I believe is only for showing team colors, not sure why it needs a separate MultiBufferSource implementation).

   class BufferSource implements MultiBufferSource {
      protected final BufferBuilder builder;
      protected final Map<RenderType, BufferBuilder> fixedBuffers;
      protected Optional<RenderType> lastState = Optional.empty();
      protected final Set<BufferBuilder> startedBuffers = Sets.newHashSet();
   ...
   }

fixedBuffers stores all the buffers (technically BufferBuilder, which, helps build the buffer and store it) of RenderTypes listed above; and builder is the fallback buffer everything else uses. In order to make sure vertices explained by different RenderTypes never end in the same buffer (causing incorrect drawing results), that fallback buffer must first clean itself by uploading its buffered vertices to GPU.

Instead of letting the caller worry about this, BufferSource was implemented so that it both finds the correct BufferBuilder for each RenderType and also uploads the buffered vertices when necessary.

      @Override
      public VertexConsumer getBuffer(RenderType renderType) {
         Optional<RenderType> optional = renderType.asOptional();
         BufferBuilder bufferBuilder = this.getBuilderRaw(renderType);
         if (!Objects.equals(this.lastState, optional)) {
            if (this.lastState.isPresent()) {
               RenderType renderType2 = (RenderType)this.lastState.get();
               if (!this.fixedBuffers.containsKey(renderType2)) {
                  this.endBatch(renderType2);
               }
            }

            if (this.startedBuffers.add(bufferBuilder)) {
               bufferBuilder.begin(renderType.mode(), renderType.format());
            }

            this.lastState = optional;
         }

         return bufferBuilder;
      }

      private BufferBuilder getBuilderRaw(RenderType renderType) {
         return (BufferBuilder)this.fixedBuffers.getOrDefault(renderType, this.builder);
      }

There’s an important caveat here. The conditions that getBuffer(renderType) calls endBatch(this.lastState.get()) are:

!Objects.equals(this.lastState, renderType.asOptional()) &&
this.lastState.isPresent() &&
!this.fixedBuffers.containsKey(renderType2)

But watch what happens when a concrete this.lastState is not in this.fixedBuffers, while renderType is. You would expect that because renderType would not be taking the default buffer builder that this.lastState is using, therefore it should not call endBatch(this.lastState.get()). But in fact it did.

I believe this may just be a bad implementation, and there should be a separate fallbackLastState variable in order to achieve that. Nevertheless, if a Sheets.chestSheet() render type is requested, it would call endBatch(this.lastState.get()) if this.lastState.get() is not in this.fixedBuffers.

Another thing to note is that startedBuffers is just used as an indicator of whether a renderType has started but not ended yet, so bufferBuilder.begin doesn’t get called repeatedly. It is correctly dealt with in the endBatch method.

      public void endBatch() {
         this.lastState.ifPresent(renderTypex -> {
            VertexConsumer vertexConsumer = this.getBuffer(renderTypex);
            if (vertexConsumer == this.builder) {
               this.endBatch(renderTypex);
            }
         });

         for (RenderType renderType : this.fixedBuffers.keySet()) {
            this.endBatch(renderType);
         }
      }

      public void endBatch(RenderType renderType) {
         BufferBuilder bufferBuilder = this.getBuilderRaw(renderType);
         boolean bl = Objects.equals(this.lastState, renderType.asOptional());
         if (bl || bufferBuilder != this.builder) {
            if (this.startedBuffers.remove(bufferBuilder)) {
               renderType.end(bufferBuilder, 0, 0, 0);
               if (bl) {
                  this.lastState = Optional.empty();
               }
            }
         }
      }

At last, endBatch(RenderType renderType) just uploads that RenderType to GPU and draws it with proper cleanup and endBatch() just calls endBatch(renderType) for every renderType there is to end the drawing session.

Concrete Example

To help understanding this logic, let’s look at a concrete example.
Suppose we have the following call sequence:

   bufferSource.endBatch(); // clean bufferSource
   VertexConsumer vertexConsumer = bufferSource.getBuffer(Sheets.chestSheet());
   // writes some chest vertices into vertexConsumer
   VertexConsumer vertexConsumer2 = bufferSource.getBuffer(RenderType.lines());
   // writes some lines (like hitboxes, chunk borders) vertices into vertexConsumer2
   VertexConsumer vertexConsumer3 = bufferSource.getBuffer(Sheets.chestSheet());
   // writes some chest vertices into vertexConsumer3
   bufferSource.endBatch();

The first getBuffer calls for Sheets.chestSheet() which is in this.fixedBuffers so this.getBuilderRaw returns this.fixedBuffers.get(Sheets.chestSheet()). Before this this.lastState had been set to Optional.empty() by bufferSource.endBatch();, which is obviously not equal to Sheets.chestSheet() but does not meet this.lastState.isPresent(). Then this.startedBuffers.add(bufferBuilder) would be true because Sheets.chestSheet() was never added.

The second getBuffer calls for RenderType.lines() which is not in this.fixedBuffers so this.getBuilderRaw returns the fallback buffer this.builder, this.lastState had been set by the previous getBuffer call to Sheets.chestSheet(), which is not equal to RenderType.lines() and meets this.lastState.isPresent(). But then !this.fixedBuffers.containsKey(Sheets.chestSheet()) is false so the vertices from the previous batch from Sheets.chestSheet() is not uploaded here. this.startedBuffers.add(bufferBuilder) would also be true because RenderType.lines() was never added.

The final getBuffer calls for Sheets.chestSheet() again which is in this.fixedBuffers so this.getBuilderRaw returns this.fixedBuffers.get(Sheets.chestSheet()). this.lastState had been set by the previous getBuffer call to RenderType.lines(), which is not equal to Sheets.chestSheet() and meets this.lastState.isPresent(). Then !this.fixedBuffers.containsKey(RenderType.lines()) is true so the vertices from the previous batch from RenderType.lines() is uploaded here. this.startedBuffers.add(bufferBuilder) would be false because Sheets.chestSheet() was added and not ended.

Finally the bufferSource.endBatch() call uploads vertices from this.fixedBuffers.get(Sheets.chestSheet()).

Step Call lastState before Buffer hit endBatch(lastState) invoked? Uploaded to GPU startedBuffers.add → begin? lastState after
0 endBatch() empty – – - – empty
1 getBuffer(Sheets.chestSheet()) empty fixed (chestSheet) no (lastState is empty) – true → begin chestSheet
2 getBuffer(RenderType.lines()) chestSheet fallback builder no (chestSheet is in fixedBuffers) – true → begin lines
3 getBuffer(Sheets.chestSheet()) lines fixed (chestSheet), same buffer as step 1 yes (lines is not in fixedBuffers) lines vertices (fallback buffer) false → no begin (already started) chestSheet
4 endBatch() chestSheet – – chestSheet vertices (both the step 1 and step 3 writes) – empty

Even though the chest vertices are written first, the lines vertices are actually uploaded earlier, and the two chest writes are merged and uploaded last.


LevelRenderer.renderLevel

Now it is finally time to take a look at the main rendering logic.

(...).level.entities.prepare & entities

LevelRenderer:995

   public void renderLevel(
      PoseStack poseStack, float f, long l, boolean bl, Camera camera, GameRenderer gameRenderer, LightTexture lightTexture, Matrix4f matrix4f
   ) {
      ...

      profilerFiller.popPush("entities"); // root.gameRenderer.level.entities
      profilerFiller.push("prepare"); // root.gameRenderer.level.entities.prepare (dark gray)
      this.renderedEntities = 0;
      this.culledEntities = 0;
      profilerFiller.popPush("entities"); // root.gameRenderer.level.entities.entities (magenta)

      ...

      BufferSource bufferSource = this.renderBuffers.bufferSource(); // this BufferSource has the fixedBuffers set to what we want

      for (Entity entity : this.level.entitiesForRendering()) {
         if ( (this.entityRenderDispatcher.shouldRender(entity, frustum, d, e, g) || entity.hasIndirectPassenger(this.minecraft.player))
            && (
               entity != camera.getEntity()
                  || camera.isDetached()
                  || camera.getEntity() instanceof LivingEntity && ((LivingEntity)camera.getEntity()).isSleeping()
            )
            && (!(entity instanceof LocalPlayer) || camera.getEntity() == entity) ) {
            this.renderedEntities++;
            
            ...

            this.renderEntity(entity, d, e, g, f, poseStack, multiBufferSource);
         }
      }

      this.checkPoseStack(poseStack);
      bufferSource.endBatch(RenderType.entitySolid(TextureAtlas.LOCATION_BLOCKS));
      bufferSource.endBatch(RenderType.entityCutout(TextureAtlas.LOCATION_BLOCKS));
      bufferSource.endBatch(RenderType.entityCutoutNoCull(TextureAtlas.LOCATION_BLOCKS));
      bufferSource.endBatch(RenderType.entitySmoothCutout(TextureAtlas.LOCATION_BLOCKS));
      profilerFiller.popPush("blockentities");
      ...

For context, renderLevel is called every frame.

Upon entering root.gameRenderer.level.entities, the game immediately swaps to root.gameRenderer.level.entities.prepare which has a grand total of 2 lines that just set 2 variables to 0. Even the profilerFiller.push("prepare"); call is more expensive.

Next the game swaps to root.gameRenderer.level.entities.entities. It gets bufferSource from this.renderBuffers.bufferSource() which itself is just

   public BufferSource bufferSource() {
      return this.bufferSource;
   }

, meaning this same bufferSource instance is shared across frames. This bufferSource instance will have called endBatch() at the end of last frame so it is clean.

Then it iterates through this.level.entitiesForRendering() which is a hash map so the order of entities being iterated is rather unpredictable. This means each entity will still be costing a bit of time regardless of it ultimately being decided to render or not.After some processing the game calls this.renderEntity to render it (in which, each entity requests getBuffer on their required RenderTypes, translates the entity to be rendered into vertices and write them into that buffer).

Before moving on to root.gameRenderer.level.entities.blockentities, the game calls endBatch on

   RenderType.entitySolid(TextureAtlas.LOCATION_BLOCKS)
   RenderType.entityCutout(TextureAtlas.LOCATION_BLOCKS)
   RenderType.entityCutoutNoCull(TextureAtlas.LOCATION_BLOCKS)
   RenderType.entitySmoothCutout(TextureAtlas.LOCATION_BLOCKS)

. At first glance, since entityCutoutNoCull is the category that the vast majority of entities fall into, it may seem like this would have already uploaded their vertices to GPU. However, notice how their textures are all from resourceLocation=TextureAtlas.LOCATION_BLOCKS. I certainly can’t be bothered to read through what falls into these categories, so I just sent this to my AI, turns out, in the root.gameRenderer.level.entities.entities split:

  • only item frames fall into the category of RenderType.entitySolid(TextureAtlas.LOCATION_BLOCKS)
  • only the fire of entities on fire and block items (includes blocks in minecarts, held by enderman, flower held by iron golem, mushroom on mooshroom cows, held in hand and on the ground)
    fall into the category of RenderType.entityCutout(TextureAtlas.LOCATION_BLOCKS)
  • nothing falls into RenderType.entityCutoutNoCull(TextureAtlas.LOCATION_BLOCKS)
    or RenderType.entitySmoothCutout(TextureAtlas.LOCATION_BLOCKS)

Now if you remember, RenderType.entitySolid(TextureAtlas.LOCATION_BLOCKS) and RenderType.entityCutout(TextureAtlas.LOCATION_BLOCKS) are also known as Sheets.solidBlockSheet() and Sheets.cutoutBlockSheet(). That means they have fixed buffers and will not be affecting the fallback buffer once uploaded.

As for endBatch calls on buffers that are neither fixed nor ever started, if (bl || bufferBuilder != this.builder) is just false and nothing happens.

(...).level.entities.blockentities

      ...

      profilerFiller.popPush("blockentities"); // root.gameRenderer.level.entities.blockentities (orange)
      ObjectListIterator var53 = this.renderChunks.iterator();

      while (var53.hasNext()) {
         LevelRenderer.RenderChunkInfo renderChunkInfo = (LevelRenderer.RenderChunkInfo)var53.next();
         List<BlockEntity> list = renderChunkInfo.chunk.getCompiledChunk().getRenderableBlockEntities();
         if (!list.isEmpty()) {
            for (BlockEntity blockEntity : list) {
               BlockPos blockPos = blockEntity.getBlockPos();
               MultiBufferSource multiBufferSource2 = bufferSource;
               poseStack.pushPose();
               poseStack.translate(blockPos.getX() - d, blockPos.getY() - e, blockPos.getZ() - g);
               
               ...

               BlockEntityRenderDispatcher.instance.render(blockEntity, f, poseStack, multiBufferSource2);
               poseStack.popPose();
            }
         }
      }

      ...

      this.checkPoseStack(poseStack);
      bufferSource.endBatch(RenderType.solid());
      bufferSource.endBatch(Sheets.solidBlockSheet());
      bufferSource.endBatch(Sheets.cutoutBlockSheet());
      bufferSource.endBatch(Sheets.bedSheet());
      bufferSource.endBatch(Sheets.shulkerBoxSheet());
      bufferSource.endBatch(Sheets.signSheet());
      bufferSource.endBatch(Sheets.chestSheet());
      this.renderBuffers.outlineBufferSource().endOutlineBatch();

      ...

      profilerFiller.popPush("destroyProgress"); // root.gameRenderer.level.entities.destroyProgress (dark orange)

      ...

In root.gameRenderer.level.entities.blockentities, the game cycles through all subchunks that are decided to render (the order determined by the bfs culling algorithm (because translucent objects require strict rendering order), which is already rather well known and I will not be readdressing here). This means each subchunk rendered would still cost a little even if it has zero block entities in it.

After some processing on each block entity the game calls BlockEntityRenderDispatcher.instance.render() to render it (in which, each entity requests getBuffer on their required RenderTypes, translates the entity to be rendered into vertices and write them into that buffer).

Before passing on to root.gameRenderer.level.entities.destroyProgress, the game calls endBatch on

   RenderType.solid() // the only one in here not in fixedBuffers
   Sheets.solidBlockSheet()
   Sheets.cutoutBlockSheet()
   Sheets.bedSheet()
   Sheets.shulkerBoxSheet()
   Sheets.signSheet()
   Sheets.chestSheet() // <------------------------------------------------------

. The last four are self explanatory; from both entities and blockentities split (still from my AI)

  • RenderType.solid() contains piston heads and falling blocks
  • Sheets.solidBlockSheet() contains bells, outer layers of conduits, books on lecterns and enchantment tables,
    item frames, layers of shields and banners excluding banner patterns
  • Sheets.cutoutBlockSheet() contains the fire of entities on fire, block items
    (includes blocks in minecarts, held by enderman, flower held by iron golem,
    mushroom on mooshroom cows, held in hand and on the ground)

Notice how the vast majority of entities and some block entities are not forcefully uploaded by this point. They only rely on getBuffer calls of distinct RenderTypes to clear each other out. This has interesting effects…

(...).level.entities.destroyProgress & unspecified; (...).level.outline

      profilerFiller.popPush("destroyProgress"); // root.gameRenderer.level.entities.destroyProgress (dark orange)
      ObjectIterator var55 = this.destructionProgress.long2ObjectEntrySet().iterator();

      while (var55.hasNext()) {
         ...
               VertexConsumer vertexConsumer2 = new SheetedDecalTextureGenerator(
                  this.renderBuffers.crumblingBufferSource().getBuffer((RenderType)ModelBakery.DESTROY_TYPES.get(z)), pose2.pose(), pose2.normal()
               );
               this.minecraft.getBlockRenderer().renderBreakingTexture(this.level.getBlockState(blockPos3), blockPos3, this.level, poseStack, vertexConsumer2);
         ...
      }

      this.checkPoseStack(poseStack);
      profilerFiller.pop(); // root.gameRenderer.level.entities.unspecified (green)
      HitResult hitResult = this.minecraft.hitResult;
      if (bl && hitResult != null && hitResult.getType() == Type.BLOCK) {
         profilerFiller.popPush("outline"); // root.gameRenderer.level.outline (dark green)
         BlockPos blockPos4 = ((BlockHitResult)hitResult).getBlockPos();
         BlockState blockState = this.level.getBlockState(blockPos4);
         if (!blockState.isAir() && this.level.getWorldBorder().isWithinBounds(blockPos4)) {
            VertexConsumer vertexConsumer3 = bufferSource.getBuffer(RenderType.lines());
            this.renderHitOutline(poseStack, vertexConsumer3, camera.getEntity(), d, e, g, blockPos4, blockState);
         }
      }

      RenderSystem.pushMatrix();
      RenderSystem.multMatrix(poseStack.last().pose());
      this.minecraft.debugRenderer.render(poseStack, bufferSource, d, e, g); // where chunk borders are
      RenderSystem.popMatrix();
      ... // 8 different bufferSource.endBatch(RenderType) calls 
          // (mostly on translucent objects because their rendering order matters)
      this.renderBuffers.crumblingBufferSource().endBatch();
      bufferSource.endBatch(RenderType.lines());
      bufferSource.endBatch();

      ... // this one is short

      profilerFiller.popPush("translucent");

In root.gameRenderer.level.entities.destroyProgress, the game iterates through the destructionProgress set, which in a singleplayer world could only have either 0 or 1 element. For rendering that texture, the game uses a different BufferSource (that is this.renderBuffers.crumblingBufferSource().getBuffer((RenderType)ModelBakery.DESTROY_TYPES.get(z))), as a result, it does not affect the bufferSource we mainly care about. Also note that the result of that is that this split is barely ever affected by anything outside of it, be it more entities or block entities loaded or any potential code path warmup concerns.

Next the game pops out of root.gameRenderer.level.entities.destroyProgress back into root.gameRenderer.level.entities. It is time to quickly mention that unspecified in a profiler node simply subtracts the total time span of the parent node with all the specified children nodes. So in this case, the profiler is still sitting inside entities while the remaining operations run, causing everything following being counted into root.gameRenderer.level.entities.unspecified.

This is a bug. At this point, everything that is of or related to entities has been done processing, and the game should pop again to reach root.gameRenderer.level.unspecified instead of still staying in entities.

And immediately after that, there is another one. The game checks if the player has outlined blocks. If yes, the game leaves the if statement with profiler in root.gameRenderer.level.outline; otherwise it leaves with profiler in root.gameRenderer.level.entities. That is the reason why hovering blocks changes your spikes.

There is still a bit of cleanup work to do before the game finally moves on to another directory. That includes rendering certain debug information (normally only chunk borders here) and calling endBatch on everything. If you remember that the majority of entities and some block entities are not necessarily uploaded leaving root.gameRenderer.level.entities.blockentities, those uploads will be happening here.


Analyzing Cases

We have now finished tracking down the relevant main rendering logic. Now we should look at more examples as well as what RenderTypes each entity and block entity have.

RenderTypes of Each Entity & Block Entity

I will be noting:

  • z = zombie
  • s = spider
  • k = skeleton
  • v = silverfish
  • p = creeper
  • b = bat
  • capital letters are their corresponding spawner type
    • for example, Z will be a zombie spawner
  • C = chest

All six of the above entities share (in exact order):

  • entityCutoutNoCull(<unique texture location>) for rendering the main body model
  • Extra RenderTypes each entity added for itself
  • (optional) text() for name tags
  • (optional) Sheets.cutoutBlockSheet() for entities on fire
  • (optional) entityShadow(textures/misc/shadow.png)
  • (optional) lines() for hitboxes

Zombies have extra renderTypes (skipping over a few that don’t matter)

  • (optional) armorCutoutNoCull(<unique texture location>) for armor
    • (+ RenderType.armorEntityGlint() if enchanted)
  • (optional) Sheets.cutoutBlockSheet() for carved pumpkin / jack o’ lanterns
  • (optional) Sheets.translucentItemSheet() for items held in hand
    • (+ glintDirect() if enchanted)

Skeletons have the exact same extra renderTypes as zombies, but they always spawn with a bow in hand.

Spiders have extra renderType

  • eyes(entity/spider_eyes.png)

Creepers have extra renderType

  • (optional) energySwirl(entity/creeper/creeper_armor.png) when charged

Silverfish and bats don’t have any.

The spawner just calls render() on the corresponding entity type.
A slight caveat is that the entity shadow will almost never be rendered regardless
because the position is set to world origin
(and you need to be within a certain range with that shadow for it to be rendered).


The Cases

Simple Cases

Suppose we already know ahead of time what will be rendered and in what order, for example a zombie entity followed by a spider spawner, I will be writing that as [z, S].

Assuming entity shadows off, hitboxes off, nothing is outlined, nothing is on fire, chunk borders off:

Render Queue entities.entities: going through this.level.entitiesForRendering() entities.blockentities: going through this.renderChunks.iterator() entities.blockentities: translating and writing vertices to buffer in BlockEntityRenderDispatcher.instance.render entities.blockentities: uploading vertices to GPU entities.unspecified: uploading vertices to GPU Notes
∅ ≈ T_value * c_cow ≈ C_value * c_subchunk 0 0 0 -
[C] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('C') ≈ 1 * upload('C') 0 The chest is uploaded at bufferSource.endBatch(Sheets.chestSheet());
[C, C] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 2 * write('C') ≈ 1 * upload(2 * 'C') 0 The vertices of the two chests are uploaded in one batch, double the vertices count
[V] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('v') 0 ≈ 1 * upload('v') Nothing else calls getBuffer other than the time rendering that silverfish spawner
[C, V] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('C') + 1 * write('v') ≈ 1 * upload('C') ≈ 1 * upload('v') The silverfish’s getBuffer call does not upload the chest vertices for they are in fixedBuffers
[V, C] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('C') + 1 * write('v') ≈ 1 * upload('C') + 1 * upload('v') 0 The chest’s getBuffer call uploads the silverfish vertices for they are not in fixedBuffers
[C, V, C] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 2 * write('C') + 1 * write('v') ≈ 1 * upload(2 * 'C') + 1 * upload('v') 0 -
[V, C, C] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 2 * write('C') + 1 * write('v') ≈ 1 * upload(2 * 'C') + 1 * upload('v') 0 -

That is hopefully enough explanation for why and how chest spikes, pure spikes, chest in front spikes, chest behind spikes are and differ from each other.

Any level.entities.unspecified spike would indicate something that isn’t a chest has been rendered because chest vertices are always uploaded in level.entities.blockentities. But a chest at the end would clean out the fallback buffer and make level.entities.unspecified tiny. As that happens, the order of the other chests does not matter.

Render Queue entities.entities: going through this.level.entitiesForRendering() entities.blockentities: going through this.renderChunks.iterator() entities.blockentities: translating and writing vertices to buffer in BlockEntityRenderDispatcher.instance.render entities.blockentities: uploading vertices to GPU entities.unspecified: uploading vertices to GPU Notes
[V] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('v') 0 ≈ 1 * upload('v') -
[Z] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('z') 0 ≈ 1 * upload('z') -
[K] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('k') + 1 * write('bow') ≈ 1 * upload('k') ≈ 1 * upload('bow') The bow triggers the upload of the skeleton vertices
[S] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('s') + 1 * write('spider eyes') ≈ 1 * upload('s') ≈ 1 * upload('spider eyes') The eyes trigger the upload of the spider vertices
[B]* ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('b') 0 ≈ 1 * upload('b') -
[P]* ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('p') 0 ≈ 1 * upload('p') -

*: Does not generate naturally in vanilla.

The fact that silverfish and zombies give very similar readings is really the rule rather than the exception here. Out of all the mobs that only have one RenderType I have tested, they all give pretty much the exact same spike. Skeletons and spiders are only possible to differentiate for the extra RenderType they have.

So what can we do in order to differentiate them? If you are following, you may be able to come up with the idea of adding in a silverfish/zombie so the group with the same entity type gets batched into one upload, therefore drastically and definitively changing the spikes.

Render Queue entities.entities: going through this.level.entitiesForRendering() entities.entities: translating and writing vertices to buffer in this.renderEntity() entities.entities: uploading vertices to GPU entities.blockentities: going through this.renderChunks.iterator() entities.blockentities: translating and writing vertices to buffer in BlockEntityRenderDispatcher.instance.render entities.blockentities: uploading vertices to GPU entities.unspecified: uploading vertices to GPU Notes
[v] ≈ T_value * c_cow ≈ 1 * upload('v') 0 ≈ C_value * c_subchunk 0 0 ≈ 1 * upload('v') -
[v, V] ≈ T_value * c_cow ≈ 1 * write('v') 0 ≈ C_value * c_subchunk ≈ 1 * write('v') 0 ≈ 1 * upload(2 * 'v') -
[v, Z] ≈ T_value * c_cow ≈ 1 * write('v') 0 ≈ C_value * c_subchunk ≈ 1 * write('z') ≈ 1 * upload('v') ≈ 1 * upload('z') The zombie triggers the upload of the silverfish vertices
[v, C, V] ≈ T_value * c_cow ≈ 1 * write('v') 0 ≈ C_value * c_subchunk ≈ 1 * write('C') + 1 * write('v') ≈ 1 * upload('v') + 1 * upload('C') ≈ 1 * upload('v') -
[v, C, Z] ≈ T_value * c_cow ≈ 1 * write('v') 0 ≈ C_value * c_subchunk ≈ 1 * write('C') + 1 * write('z') ≈ 1 * upload('v') + 1 * upload('C') ≈ 1 * upload('z') -

…Except it doesn’t work if there is a chest in the middle, whose getBuffer immediately uploads the silverfish entity’s verticies to GPU. Of course, anything else in the middle would also do the trick.

This could have been not the case if the game was implemented so that chests having a spot in fixedBuffers do not trigger the upload of the vertices of the fallback buffer but unfortunately we are stuck where we are now.

In fact, I think it is safe to say that, on this layer of abstraction, so long as you accept that both write('v') / write('z') and upload('v') / upload('z') are indistinguishable under noise, there is no other way than to add an entity that can differentiate the two spikes.


Next let’s look at hover hitboxes.

Render Queue entities.entities: going through this.level.entitiesForRendering() entities.blockentities: going through this.renderChunks.iterator() entities.blockentities: translating and writing vertices to buffer in BlockEntityRenderDispatcher.instance.render entities.blockentities: uploading vertices to GPU entities.unspecified: uploading vertices to GPU outline: uploading vertices to GPU Notes
[V] ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('v') 0 ≈ 1 * upload('v') 0 -
[V], hovering ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('v') 0 0 ≈ 1 * upload('v') -
[V], hitboxes ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('v') + 1 * write('hitbox') ≈ 1 * upload('v') ≈ 1 * upload('hitbox') 0 -
[V], hovering, hitboxes ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('v') + 1 * write('hitbox') ≈ 1 * upload('v') 0 ≈ 1 * upload('hitbox') -

By turning on hitboxes, the upload of the silverfish vertices is moved into entities.blockentities while leaving that hitbox out, turning it into a recognizable spike in our usual pie directory.

Render Queue entities.entities: going through this.level.entitiesForRendering() entities.blockentities: going through this.renderChunks.iterator() entities.blockentities: translating and writing vertices to buffer in BlockEntityRenderDispatcher.instance.render entities.blockentities: uploading vertices to GPU entities.unspecified: uploading vertices to GPU outline: uploading vertices to GPU Notes
[C, V], hovering, hitboxes ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('C') + 1 * write('v') + 1 * write('hitbox') ≈ 1 * upload('C') + 1 * upload('v') 0 ≈ 1 * upload('hitbox') -
[V, C], hovering, hitboxes ≈ T_value * c_cow ≈ C_value * c_subchunk ≈ 1 * write('C') + 1 * write('v') + 1 * write('hitbox') ≈ 1 * upload('v') + 1 * upload('hitbox') + 1 * upload('C') 0 0 -

Chest in front spikes are still different from chest behind spikes as the latter uploads hitboxes that are of RenderType RenderType.lines() in the entities.blockentities split.


this is technically part of the main quest to solve nav. 
however i have already done this part of the analysis for the silverfish/zombie entity spike preemptive post, in which i said i was going to hand the writing of the report to an ai but i got mad at what the models are spitting out so i had to do it myself and it just so happens i was experimenting with nav ai training config and that would be burning my pc for 48 hours in which i have nothing to do and can't even play minecraft but anyways this took much longer than 48 hours maybe i will write the rest when i am next training some model ok bye

oh i forgot i made this map to quickly setup and test spikes (greatest ui design of all time)

i was experimenting today and recognized that skeleton bow actually shows fixed buffer behavior, despite having a chest behind unspecified was spiking high. after inspecting the code it seems that in reality most item entities fall into Sheets.translucentCullBlockSheet(), including all tools and weapon, most materials and food, etc. (there are some strange edge cases like carrots are not in this list, which i think makes sense to put into the rest of the report.) only the rest fall into Sheets.translucentItemSheet().

i’ve heard people with certain gpus can’t seem to differentiate skeleton spawner and silverfish spawner with chests in front (which by the incomplete model i thought predicted to be happening for all devices, not sure why not yet). for those people this is good news, you should now be able to differentiate them with such item on the ground. better, you can hold it in hand and toggle third person, swap between an empty slot (slot with block item works too) and slot with that item, and see if there is meaningful change in unspecified.