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.blockentitiesreading is not additive
& whatlevel.entities.unspecifiedeven 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
- why the
- explains:
- (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
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 ofRenderType.entityCutout(TextureAtlas.LOCATION_BLOCKS) - nothing falls into
RenderType.entityCutoutNoCull(TextureAtlas.LOCATION_BLOCKS)
orRenderType.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 blocksSheets.solidBlockSheet()contains bells, outer layers of conduits, books on lecterns and enchantment tables,
item frames, layers of shields and banners excluding banner patternsSheets.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.