Merge pull request #10328 from rison1337/perf/cache-name-tag-layout

Cache unchanged entity name-tag glyph layouts
This commit is contained in:
Kacper authored and GitHub committed 2026-11-06 09:14:45 +01:00
commit fe8b0c622f
4 files changed
+348 -9

No files matched your search

+2
View File
@@ -2955,6 +2955,7 @@ struct Game<'a> {
dragon_model: Option<horizon_client::mobs::Part>,
/// Map textures waiting for the next HUD batch to upload them.
map_patches: Vec<horizon_client::font::Patch>,
name_tag_layouts: names::NameTagLayouts,
text_displays: std::collections::HashMap<i32, text_displays::RemoteTextDisplay>,
interactions: std::collections::HashMap<i32, interactions::RemoteInteraction>,
/// The fence knots a server tracks to this client, by entity id, at their
@@ -20054,6 +20055,7 @@ impl<'a> Game<'a> {
breath_clouds: Vec::new(),
dragon_model: prepared.dragon_model,
map_patches: Vec::new(),
name_tag_layouts: Default::default(),
text_displays: Default::default(),
interactions: Default::default(),
remote_knots: Default::default(),
+256 -9
View File
@@ -8,13 +8,112 @@
//! and the player's own in third person with the `ownNameTag` extra.
use super::{Game, view_vector};
use horizon_client::{
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, mod_api,
save::uuid_text, scoreboard as sb, text::Text,
block_entity_render::vertex_light,
hud::{HudAtlas, HudBatch, HudVertex},
mesher::Vertex,
mobs::Mob,
mod_api,
save::uuid_text,
scoreboard as sb,
text::Text,
};
/// The world size of one text pixel in a name tag: `Matrix4f.scale(0.025F, -0.025F, 0.025F)`.
pub(super) const NAME_TAG_UNIT: f32 = 0.025;
/// Local glyph quads only: camera transforms, lighting and cosmetic marks stay per frame.
/// Slots follow submission order; comparing the whole styled text also handles entity reorder,
/// team/score changes and native name hooks without keeping entity IDs alive.
#[derive(Default)]
pub(super) struct NameTagLayouts {
epoch: (u64, u64, bool),
next: usize,
lines: Vec<Option<NameTagLine>>,
}
struct NameTagLine {
text: Text,
alpha: u32,
background: bool,
y: f32,
behind: usize,
vertices: Vec<HudVertex>,
}
impl NameTagLayouts {
fn begin_frame(&mut self, atlas: &HudAtlas) {
let epoch = (
atlas.revision,
atlas.font.epoch(),
horizon_client::lang::bidirectional(),
);
if self.epoch != epoch {
self.lines.clear();
self.epoch = epoch;
}
self.next = 0;
}
fn end_frame(&mut self) {
self.lines.truncate(self.next);
}
fn name_tag(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
text: &Text,
y: f32,
alpha: u32,
background: bool,
) -> usize {
// At most 512 * 512 HUD vertices (~10 MiB), plus short text keys. Longer tags and
// excess lines still render normally. Animated/random glyphs and live inline skins
// must go through the font each time, including legacy formatting supplied by hooks.
const MAX_LINES: usize = 512;
const MAX_VERTICES: usize = 512;
let slot = self.next;
self.next += 1;
let stable =
text.resolved().spans.iter().all(|span| {
span.style.obfuscated != Some(true) && span.style.inline_object.is_none()
}) && text.spans.iter().map(|s| s.text.len()).sum::<usize>() <= 1024;
if slot < MAX_LINES
&& stable
&& let Some(Some(line)) = self.lines.get(slot)
&& line.text == *text
&& line.alpha == alpha
&& line.background == background
&& line.y == y
{
batch.vertices.extend_from_slice(&line.vertices);
return line.behind;
}
let behind = batch.name_tag(
atlas,
text,
y,
alpha << 24 | 0xFF_FFFF,
if background { 0x3F00_0000 } else { 0 },
);
if slot < MAX_LINES {
if self.lines.len() <= slot {
self.lines.resize_with(slot + 1, || None);
}
self.lines[slot] =
(stable && batch.vertices.len() <= MAX_VERTICES).then(|| NameTagLine {
text: text.clone(),
alpha,
background,
y,
behind,
vertices: batch.vertices.clone(),
});
}
behind
}
}
impl Game<'_> {
/// A named name tag names the mob under the crosshair.
pub(super) fn name_mob(&mut self, index: usize) -> bool {
@@ -469,6 +568,8 @@ impl Game<'_> {
// max((0.25 + 0.75) / 2, 0.5), as ARGB.color (the floor of 255 times it).
const FADED: u32 = 0x7F;
let mut patches = Vec::new();
let mut layouts = std::mem::take(&mut self.name_tag_layouts);
layouts.begin_frame(&self.gui.atlas);
for tag in self.name_tags(camera, alpha) {
let o = tag.at - camera;
// `gbuffers_entities` draws name tag text (2.3); a sneaking player's faded tag, on its
@@ -486,6 +587,7 @@ impl Game<'_> {
NAME_TAG_UNIT,
out,
tag.marks,
Some(&mut layouts),
));
continue;
}
@@ -503,6 +605,7 @@ impl Game<'_> {
NAME_TAG_UNIT,
out,
tag.marks,
Some(&mut layouts),
));
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
patches.extend(self.styled_name_tag_quads(
@@ -515,8 +618,11 @@ impl Game<'_> {
NAME_TAG_UNIT,
see_through,
tag.marks,
Some(&mut layouts),
));
}
layouts.end_frame();
self.name_tag_layouts = layouts;
self.map_patches.extend(patches);
}
/// The lines of one name tag, `o` from the camera: the text at
@@ -545,6 +651,7 @@ impl Game<'_> {
unit,
out,
Marks::default(),
None,
)
}
@@ -563,6 +670,7 @@ impl Game<'_> {
unit: f32,
out: &mut Vec<Vertex>,
marks: Marks,
mut layouts: Option<&mut NameTagLayouts>,
) -> Vec<horizon_client::font::Patch> {
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
const BACKGROUND: u32 = 0x3F00_0000;
@@ -595,13 +703,23 @@ impl Game<'_> {
batch.vertices.clear();
// Font.prepareText, as any text: bold, italic, fonts and each
// glyph's own box (accented.png's are 12 pixels tall).
let behind = batch.name_tag(
&self.gui.atlas,
text,
y0,
text_alpha << 24 | 0xFF_FFFF,
if background { BACKGROUND } else { 0 },
);
let behind = match layouts.as_deref_mut() {
Some(layouts) => layouts.name_tag(
&mut batch,
&self.gui.atlas,
text,
y0,
text_alpha,
background,
),
None => batch.name_tag(
&self.gui.atlas,
text,
y0,
text_alpha << 24 | 0xFF_FFFF,
if background { BACKGROUND } else { 0 },
),
};
if n == last && marks.horizon {
let x = -self.gui.atlas.font.width_text(text) / 2.0 - 10.0;
// 8 text pixels square, a pixel left of the background.
@@ -777,6 +895,135 @@ mod tests {
use horizon_core::geometry::Vec3;
use std::sync::Arc;
fn tag_font() -> HudAtlas {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"horizon-tag-cache-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
let textures = root.join("assets/minecraft/textures/font");
std::fs::create_dir_all(root.join("assets/minecraft/font")).unwrap();
std::fs::create_dir_all(&textures).unwrap();
std::fs::write(root.join("assets/minecraft/font/default.json"),
r#"{"providers":[{"type":"bitmap","file":"minecraft:font/cache.png","ascent":7,"chars":["ab"]}]}"#).unwrap();
let file = std::fs::File::create(textures.join("cache.png")).unwrap();
let mut encoder = png::Encoder::new(file, 16, 8);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
let pixels: Vec<u8> = (0..128)
.flat_map(|i| [255, 255, 255, if i % 8 < 4 { 255 } else { 0 }])
.collect();
writer.write_image_data(&pixels).unwrap();
drop(writer);
let mut atlas = HudAtlas::boot();
atlas.font = horizon_client::font::Font::load(
&root,
&Default::default(),
&[],
(1024, 1024),
(512, 512),
atlas.sprites["horizon/white"],
);
std::fs::remove_dir_all(root).unwrap();
atlas
}
fn tag_bytes(
cache: &mut NameTagLayouts,
atlas: &HudAtlas,
text: &Text,
alpha: u32,
background: bool,
) -> Vec<u8> {
cache.begin_frame(atlas);
let mut batch = HudBatch::new([1, 1]);
let behind = cache.name_tag(&mut batch, atlas, text, 0.0, alpha, background);
cache.end_frame();
let mut fresh = HudBatch::new([1, 1]);
assert_eq!(
behind,
fresh.name_tag(
atlas,
text,
0.0,
alpha << 24 | 0xFF_FFFF,
if background { 0x3F00_0000 } else { 0 }
)
);
let bytes: &[u8] = bytemuck::cast_slice(&batch.vertices);
assert_eq!(bytes, bytemuck::cast_slice::<_, u8>(&fresh.vertices));
bytes.to_vec()
}
#[test]
fn cached_tag_quads_follow_text_style_pass_and_font_changes() {
let mut atlas = tag_font();
let mut cache = NameTagLayouts::default();
let a = Text::literal("a");
let first = tag_bytes(&mut cache, &atlas, &a, 255, false);
assert!(!first.is_empty());
assert_eq!(tag_bytes(&mut cache, &atlas, &a, 255, false), first);
for text in [
Text::literal("b"),
a.clone().bold(),
Text::colored("a", 0x12AB34),
] {
assert_ne!(tag_bytes(&mut cache, &atlas, &text, 255, false), first);
tag_bytes(&mut cache, &atlas, &text, 255, false);
}
assert_ne!(tag_bytes(&mut cache, &atlas, &a, 127, true), first);
tag_bytes(&mut cache, &atlas, &a, 127, true);
tag_bytes(&mut cache, &atlas, &a, 255, false);
// Reset and occupy the first glyph slot with b: a's old UV is now wrong.
atlas.font.reset_cache();
atlas.font.glyph('b');
assert_ne!(tag_bytes(&mut cache, &atlas, &a, 255, false), first);
// Font options may change without replacing the atlas.
atlas.font.uniform = true;
tag_bytes(&mut cache, &atlas, &a, 255, false);
}
#[test]
fn obfuscated_tag_glyphs_keep_animating_after_cache_warmup() {
let atlas = tag_font();
let mut cache = NameTagLayouts::default();
let text = Text::legacy("§kaaaaaaaaaaaaaaaa");
let mut images = std::collections::HashSet::new();
for seed in 0..8 {
cache.begin_frame(&atlas);
atlas.font.reseed(seed);
let mut batch = HudBatch::new([1, 1]);
cache.name_tag(&mut batch, &atlas, &text, 0.0, 255, false);
cache.end_frame();
let bytes = bytemuck::cast_slice::<_, u8>(&batch.vertices).to_vec();
atlas.font.reseed(seed);
let mut fresh = HudBatch::new([1, 1]);
fresh.name_tag(&atlas, &text, 0.0, u32::MAX, 0);
assert_eq!(bytes, bytemuck::cast_slice::<_, u8>(&fresh.vertices));
images.insert(bytes);
}
assert!(images.len() > 1, "the fixture must actually animate");
}
#[test]
fn inline_tag_objects_follow_live_sprite_updates() {
let mut atlas = HudAtlas::boot();
let mut sprite = atlas.sprites["horizon/white"];
atlas.sprites.insert("item/probe".into(), sprite);
let text = horizon_client::component::to_text(
&serde_json::json!({"object":"atlas", "sprite":"minecraft:item/probe"}),
&mut |_| Text::empty(),
);
let mut cache = NameTagLayouts::default();
let first = tag_bytes(&mut cache, &atlas, &text, 255, false);
assert!(!first.is_empty());
sprite.u0 += 0.1;
atlas.sprites.insert("item/probe".into(), sprite);
assert_ne!(tag_bytes(&mut cache, &atlas, &text, 255, false), first);
}
/// The player model `perspective::player_type` builds from the prepared
/// resources, without them: its box only needs the id and the pointer.
fn player_mob() -> Mob {
@@ -0,0 +1,32 @@
{
"base": "2b74332fb",
"binaries": {"baseline":"6997749681fa69634449dd688906200fbce8e748352962bb3f4ef8d9e311d1c3","candidate":"7ea4dc7f73e83c0980df8d0bad7081ffe0cae48935663689b5f31ee4b9d7ac0a"},
"quality": [{"scene":"initial","changed_pixels":0},{"scene":"rename","changed_pixels":0},{"scene":"score_team","changed_pixels":0},{"scene":"move","changed_pixels":0},{"scene":"font_options","changed_pixels":0}],
"samples": [
{"cycle":0,"pair":0,"variant":"baseline","scene":"front","fps":1051.7966220784283,"cpu_ms":0.883081021403224,"gpu_ms":0.206471023594761,"prepare_ms":0.7426633483563068,"low_1pct_fps":353.7721737039538},
{"cycle":0,"pair":0,"variant":"baseline","scene":"behind","fps":1058.3068019790742,"cpu_ms":0.8673469352249216,"gpu_ms":0.1895598093656891,"prepare_ms":0.7250369851121037,"low_1pct_fps":330.5956813188397},
{"cycle":0,"pair":0,"variant":"baseline","scene":"empty","fps":4292.971633366724,"cpu_ms":0.09846154169018746,"gpu_ms":0.2041446953867861,"prepare_ms":0.09032092565794698,"low_1pct_fps":690.9605932265464},
{"cycle":1,"pair":0,"variant":"candidate","scene":"front","fps":1791.2371609529432,"cpu_ms":0.4996271979017739,"gpu_ms":0.20476298797860049,"prepare_ms":0.36100063756483647,"low_1pct_fps":604.4971584684645},
{"cycle":1,"pair":0,"variant":"candidate","scene":"behind","fps":2069.60366352984,"cpu_ms":0.42620941923499495,"gpu_ms":0.18699220776309555,"prepare_ms":0.27871038342104654,"low_1pct_fps":577.4636859002027},
{"cycle":1,"pair":0,"variant":"candidate","scene":"empty","fps":4432.343371510415,"cpu_ms":0.10048993249552,"gpu_ms":0.19595694637201425,"prepare_ms":0.09090468674631716,"low_1pct_fps":694.9883639147564},
{"cycle":2,"pair":1,"variant":"candidate","scene":"front","fps":1783.7215267668012,"cpu_ms":0.4981431789931168,"gpu_ms":0.2059234568286627,"prepare_ms":0.3616274793914923,"low_1pct_fps":576.8883860205156},
{"cycle":2,"pair":1,"variant":"candidate","scene":"behind","fps":2120.353605541252,"cpu_ms":0.41953523691758543,"gpu_ms":0.1869485721587643,"prepare_ms":0.27214778206787477,"low_1pct_fps":652.8169593351126},
{"cycle":2,"pair":1,"variant":"candidate","scene":"empty","fps":4352.362053494388,"cpu_ms":0.09945434413161312,"gpu_ms":0.20217791819652903,"prepare_ms":0.09065125569217614,"low_1pct_fps":694.8098453359895},
{"cycle":3,"pair":1,"variant":"baseline","scene":"front","fps":1064.1458890478812,"cpu_ms":0.8740313865266264,"gpu_ms":0.2054239459276311,"prepare_ms":0.7312424486620036,"low_1pct_fps":381.13598484312047},
{"cycle":3,"pair":1,"variant":"baseline","scene":"behind","fps":1021.3711707677252,"cpu_ms":0.9035914639109252,"gpu_ms":0.1912185329518226,"prepare_ms":0.755900545932957,"low_1pct_fps":311.87492774414187},
{"cycle":3,"pair":1,"variant":"baseline","scene":"empty","fps":4293.604458624059,"cpu_ms":0.1007658596148652,"gpu_ms":0.1992767261892394,"prepare_ms":0.09101183841879054,"low_1pct_fps":613.41926178891},
{"cycle":4,"pair":2,"variant":"candidate","scene":"front","fps":1708.9184872076082,"cpu_ms":0.5145837150904827,"gpu_ms":0.20600104381666184,"prepare_ms":0.3764380921278598,"low_1pct_fps":466.519853250343},
{"cycle":4,"pair":2,"variant":"candidate","scene":"behind","fps":1971.3836386165208,"cpu_ms":0.4405532517455539,"gpu_ms":0.18877758733201755,"prepare_ms":0.29067299283630893,"low_1pct_fps":516.055351824928},
{"cycle":4,"pair":2,"variant":"candidate","scene":"empty","fps":4039.969424796982,"cpu_ms":0.1029075857445524,"gpu_ms":0.20901548113134363,"prepare_ms":0.09179950450249662,"low_1pct_fps":584.4091087025574},
{"cycle":5,"pair":2,"variant":"baseline","scene":"front","fps":1186.5142344638175,"cpu_ms":0.7795670113175297,"gpu_ms":0.2049857092726036,"prepare_ms":0.6376724056954481,"low_1pct_fps":483.5010731703909},
{"cycle":5,"pair":2,"variant":"baseline","scene":"behind","fps":1285.7991642799304,"cpu_ms":0.7128664263254052,"gpu_ms":0.18684411437678083,"prepare_ms":0.568760129517643,"low_1pct_fps":479.14198527216934},
{"cycle":5,"pair":2,"variant":"baseline","scene":"empty","fps":4345.309129680775,"cpu_ms":0.1030133317635822,"gpu_ms":0.1945606774064114,"prepare_ms":0.0914824880992008,"low_1pct_fps":611.6066635319316},
{"cycle":6,"pair":3,"variant":"baseline","scene":"front","fps":1126.8745909323516,"cpu_ms":0.8168730579296783,"gpu_ms":0.20928317886721856,"prepare_ms":0.6715726909007027,"low_1pct_fps":402.68750812736306},
{"cycle":6,"pair":3,"variant":"baseline","scene":"behind","fps":1328.1012170271017,"cpu_ms":0.6963829583336325,"gpu_ms":0.1864167663419948,"prepare_ms":0.5539404330337748,"low_1pct_fps":521.6764535451866},
{"cycle":6,"pair":3,"variant":"baseline","scene":"empty","fps":4391.61504574128,"cpu_ms":0.10059389554385516,"gpu_ms":0.195875587951098,"prepare_ms":0.09059730297340954,"low_1pct_fps":632.1743658852401},
{"cycle":7,"pair":3,"variant":"candidate","scene":"front","fps":1629.907260557841,"cpu_ms":0.5466166728409472,"gpu_ms":0.2040921990116931,"prepare_ms":0.4020578124224546,"low_1pct_fps":471.930871438953},
{"cycle":7,"pair":3,"variant":"candidate","scene":"behind","fps":1898.8692399125523,"cpu_ms":0.4603509318971386,"gpu_ms":0.18630747471286943,"prepare_ms":0.3132662786677051,"low_1pct_fps":503.5847635901019},
{"cycle":7,"pair":3,"variant":"candidate","scene":"empty","fps":4417.477650634551,"cpu_ms":0.10235459178565988,"gpu_ms":0.1942967347426569,"prepare_ms":0.09195623963719245,"low_1pct_fps":644.5895240254}
],
"summary": {"behind":{"variants":{"baseline":{"fps_median":1172.0529831295023,"cpu_ms_median":0.7901066807751633,"gpu_ms_median":0.18820196187123497,"prepare_ms_median":0.6468985573148733,"low_1pct_fps_median":404.8688332955045},"candidate":{"fps_median":2020.4936510731804,"cpu_ms_median":0.4333813354902744,"gpu_ms_median":0.1869703899609299,"prepare_ms_median":0.28469168812867773,"low_1pct_fps_median":546.7595188625653}},"paired_gain_pct":72.71917187176544,"ci95_approx_pct":[29.334446655428092,130.65713043599604],"pixels":[{"pair":0,"different_pixels":0,"max_channel_diff":0},{"pair":1,"different_pixels":0,"max_channel_diff":0},{"pair":2,"different_pixels":0,"max_channel_diff":0},{"pair":3,"different_pixels":0,"max_channel_diff":0}]},"empty":{"variants":{"baseline":{"fps_median":4319.4567941524165,"cpu_ms_median":0.10067987757936017,"gpu_ms_median":0.19757615707016868,"prepare_ms_median":0.09080457069610004,"low_1pct_fps_median":622.7968138370751},"candidate":{"fps_median":4384.9198520644695,"cpu_ms_median":0.10142226214058994,"gpu_ms_median":0.19906743228427165,"prepare_ms_median":0.09135209562440688,"low_1pct_fps_median":669.6996846806948}},"paired_gain_pct":-0.5347303327741403,"ci95_approx_pct":[-7.599617963294701,7.070335120951254],"pixels":[{"pair":0,"different_pixels":0,"max_channel_diff":0},{"pair":1,"different_pixels":0,"max_channel_diff":0},{"pair":2,"different_pixels":0,"max_channel_diff":0},{"pair":3,"different_pixels":0,"max_channel_diff":0}]},"front":{"variants":{"baseline":{"fps_median":1095.5102399901164,"cpu_ms_median":0.8454522222281524,"gpu_ms_median":0.20594748476119606,"prepare_ms_median":0.7014075697813531,"low_1pct_fps_median":391.9117464852418},"candidate":{"fps_median":1746.3200069872046,"cpu_ms_median":0.5071054564961284,"gpu_ms_median":0.2053432224036316,"prepare_ms_median":0.369032785759676,"low_1pct_fps_median":524.4096287297343}},"paired_gain_pct":56.16033284896831,"ci95_approx_pct":[35.07186954292121,80.54128989272277],"pixels":[{"pair":0,"different_pixels":1377,"max_channel_diff":190},{"pair":1,"different_pixels":0,"max_channel_diff":0},{"pair":2,"different_pixels":1377,"max_channel_diff":190},{"pair":3,"different_pixels":1377,"max_channel_diff":190}]}}
}
+58
View File
@@ -0,0 +1,58 @@
# Entity name-tag layout cache
Unchanged entity names used to repeat font width, style/bidi processing and glyph expansion
on every frame, separately for the normal and see-through passes. Retain only their local
HUD quads. Text/style, pass alpha/background, vertical text offset, atlas revision, font epoch
and bidi direction determine reuse. World placement, camera orientation, lighting, cosmetic
marks and visibility/hooks still update each frame. This does not change TextDisplay's existing
line cache or the player-list/skin paths.
Obfuscated text (including legacy §k) and inline objects always use the original preparation.
Keep at most 512 lines, each at most 512 HUD vertices and 1024 text bytes; larger text and
excess lines still draw through the original path. Slots follow submission order and compare
complete styled text, so reordered/removed entities, scores and teams cannot reuse another
name. Unused slots are dropped at the frame end. No quality setting is needed.
## Measurement
Base `2b74332fb`, Linux/Vulkan/X11, Ryzen 5 7500F, RTX 4060, 1920×1080,
render distance 4, VSync off/unlimited FPS, OIT off. Fresh isolated homes and game folders,
same copied save, frozen server ticks, fixed time/weather and camera. No other game or
build ran during the measurements; existing desktop apps stayed running and power settings
were unchanged. A/B/B/A/B/A/A/B launch order, 3 s warmup then 8 s measured per scene:
four pairs, 24 samples including the empty control. This isolates rendering of static names;
it is not a general gameplay or low-FPS/GPU-bound benchmark.
100 invisible marker armor stands, unique gold bold `Rank NNN Player` custom names,
10×10 grid with 1.5-block spacing at y=81. Camera feet (0.5,80,-9.5), pitch 8°;
yaw 0° faces them, yaw 180° faces away. Chat visibility is 2. Normal HUD remains enabled,
since hiding it also hides name tags. The empty control removes only entity regions from
a scratch copy of the same save. Values below are medians of four runs.
| Scene | Baseline FPS | Cached FPS | Paired gain (approx. 95% t interval) | Prepare ms, before → after |
|---|---:|---:|---:|---:|
| front | 1096 | 1746 | +56.2% [+35.1, +80.5] | 0.701 → 0.369 |
| behind | 1172 | 2020 | +72.7% [+29.3, +130.7] | 0.647 → 0.285 |
| empty | 4319 | 4385 | -0.5% [-7.6, +7.1] | 0.091 → 0.091 |
The interval uses paired log FPS ratios (four pairs, t critical 3.182), not individual frames
as independent samples. The empty control does not establish an FPS improvement. Gains
apply to many unchanged names; changing/animated text and GPU-bound scenes may benefit less.
[Raw samples and summaries](name-tag-layout-cache-samples.json) include CPU/GPU and 1% lows.
## Correctness
Five independent 1280×720 image comparisons against the baseline had zero changed pixels:
plain/styled/legacy/Arabic names with below-name scores and a team; live rename/style change;
score/team update; camera translation; force-Unicode font option. These were four separated
invisible marker stands, not a pile of overlapping models. Unit tests additionally compare
cached/fresh vertex bytes after text/style/pass changes and a font reset that moves glyph UVs,
check that §k keeps animating with seeded fresh-path comparisons, and update a live inline
sprite without replacing the atlas. The ordinary rendering path and its visibility remain
the reference; this changes preparation reuse, not vanilla name rules.
The dense front-view stress fixture has two draw orders when entity chunks arrive in a
different order. Three paired images differ in 1377 pixels where names overlap. An additional
unmodified-baseline launch reproduced the candidate's alternate image exactly (0 differences),
while two baseline launches reproduced the first order. This is existing overlap compositing,
not a pixel oracle; the five separated-name scenes above avoid it.