fix: render inline text sprite shadows like vanilla

Render inline atlas sprites and player heads in the text shadow pass instead
of skipping them. Use the text tint, shadow colour and one-pixel offset,
vanilla's sprite baseline, and foreground depth for world text.

Reference: Minecraft 26.3 Font.PreparedTextBuilder, PlainTextRenderable.render
and AtlasGlyphProvider.Instance.renderSprite, inspected from the pinned jar.

Add a vanilla oracle and Rust probe covering 96 sprite geometry/colour cases,
plus regression coverage for inline player head shadows.

Validation: inline_object parity passes three consecutive runs; 27 HUD tests
pass; renderer/client probe Clippy passes with -D warnings and no default
features; rustfmt and public-tree checks pass.

Full pr_ready --quick remains blocked by missing ALSA development libraries,
mixed system Rust/Clippy toolchains, and an existing Python fixture missing
Entities.has_mods (test_mod_block_entity_any_template).

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Maksymilian AdamkiewiczandCodex committed 2026-11-01 19:13:51 +01:00
1 parent 1e02316c27
commit 7ab188cf5a
5 files changed
+247 -9

No files matched your search

+21 -9
View File
@@ -1368,7 +1368,7 @@ impl HudBatch {
right: 0.0,
bottom: 8.0,
advance: 8.0,
offset: 0.0,
offset: 1.0,
}
} else {
font.glyph_styled(ch, style)
@@ -1430,12 +1430,6 @@ impl HudBatch {
object,
) in &glyphs
{
if let Some(object) = object {
if !is_shadow {
self.draw_inline_object(atlas, object, *gx, y, alpha);
}
continue;
}
let shadow_color = if let Some(argb) = custom_shadow {
let text_alpha = (c >> 24) as u8 as u16;
let shadow_alpha = (argb >> 24) as u8 as u16;
@@ -1456,6 +1450,18 @@ impl HudBatch {
(0.0, *c)
};
let (px, py) = (*gx + dx, y + dx);
if let Some(object) = object {
// `PlainTextRenderable.render`: sprite glyphs use the same
// shadow colour and offset as text, with the front at SHADOW_DEPTH.
let start = self.vertices.len();
self.draw_inline_object(atlas, object, px, py - 1.0, col);
if world && has_shadow && !is_shadow {
for v in &mut self.vertices[start..] {
v.depth = 0.03;
}
}
continue;
}
let shear = |v: f32| if *italic { 1.0 - 0.25 * v } else { 0.0 };
let thick = if *bold { 0.1 } else { 0.0 };
let draw = |batch: &mut Self, ox: f32| match g.rect {
@@ -1533,10 +1539,9 @@ impl HudBatch {
object: &crate::text::InlineObject,
x: f32,
y: f32,
alpha: u8,
tint: u32,
) {
use crate::text::InlineObject;
let tint = rgba(255, 255, 255, alpha);
match object {
InlineObject::Player { profile, hat, .. } => {
let id = crate::skins::parse_profile(profile).id;
@@ -2725,6 +2730,13 @@ mod tests {
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, false);
let skin = atlas.sprites["entity/player/wide/steve"];
assert_eq!(batch.vertices[0].uv[0], skin.u0 + (skin.u1 - skin.u0) / 8.0);
batch.vertices.clear();
batch.text_styled(&atlas, &named, 10.0, 10.0, u32::MAX, true);
assert_eq!(batch.vertices.len(), 24); // shadow and front, each with face and hat
assert_eq!(batch.vertices[0].pos, [11.0, 10.0]);
assert_eq!(batch.vertices[0].color, rgba(63, 63, 63, 255));
assert_eq!(batch.vertices[12].pos, [10.0, 9.0]);
assert_eq!(batch.vertices[12].color, u32::MAX);
fs::remove_dir_all(root).unwrap();
}
@@ -0,0 +1,69 @@
//! Inline atlas glyph geometry for `tools/test_inline_object_parity.py`.
use horizon_client::{
component,
hud::{HudAtlas, HudBatch, Rect},
text::Text,
};
use serde_json::Value;
use std::{error::Error, fs, io::Write};
pub fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<_> = crate::args().collect();
let mut atlas = HudAtlas::boot();
atlas.sprites.insert(
"item/test".into(),
Rect {
u0: 0.125,
v0: 0.25,
u1: 0.375,
v1: 0.5,
w: 16,
h: 16,
},
);
let mut out = fs::File::create(&args[2])?;
for line in fs::read_to_string(&args[1])?.lines() {
let row: Value = serde_json::from_str(line)?;
let mut input = serde_json::json!({"object":"atlas", "sprite":"minecraft:item/test"});
for key in ["shadow_color", "color", "bold", "italic"] {
if let Some(value) = row.get(key) {
input[key] = value.clone();
}
}
let text = component::to_text(&input, &mut |_| Text::empty());
let mut batch = HudBatch::new([320, 240]);
let (x, y) = (
row["x"].as_f64().unwrap() as f32,
row["y"].as_f64().unwrap() as f32,
);
let argb = row["base"].as_u64().unwrap() as u32;
let color = horizon_client::hud::rgba(
(argb >> 16) as u8,
(argb >> 8) as u8,
argb as u8,
(argb >> 24) as u8,
);
let shadow = row["shadow"].as_bool().unwrap();
if row["world"].as_bool().unwrap() {
batch.text_styled_world(&atlas, &text, x, y, color, shadow);
} else {
batch.text_styled(&atlas, &text, x, y, color, shadow);
}
writeln!(out, "{}", batch.vertices.len() / 6)?;
for quad in batch.vertices.as_chunks::<6>().0 {
let v = quad[0];
let [r, g, b, a] = v.color.to_le_bytes().map(u32::from);
writeln!(
out,
"{:.3} {:.3} {:.3} {:.3} {:.3} {:08x}",
v.pos[0],
v.pos[1],
quad[2].pos[0],
quad[2].pos[1],
v.depth,
a << 24 | r << 16 | g << 8 | b
)?;
}
}
Ok(())
}
@@ -51,6 +51,7 @@ mod held_drop_fixture;
mod held_item_stack_probe;
mod hotbar_fixture;
mod hud_fixture;
mod inline_object_fixture;
mod input_probe;
mod interaction_input_fixture;
mod lan_probe;
@@ -150,6 +151,7 @@ probes! {
hotbar_fixture,
hud_fixture,
input_probe,
inline_object_fixture,
interaction_input_fixture,
lan_probe,
light_parity,
@@ -0,0 +1,91 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import com.mojang.blaze3d.font.GlyphInfo;
import com.mojang.blaze3d.vertex.VertexConsumer;
import java.lang.reflect.*;
import java.nio.file.*;
import java.util.*;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GlyphSource;
import net.minecraft.client.gui.font.TextRenderable;
import net.minecraft.client.gui.font.glyphs.BakedGlyph;
import net.minecraft.client.gui.font.glyphs.EffectGlyph;
import net.minecraft.client.renderer.texture.TextureAtlasSprite;
import net.minecraft.network.chat.*;
import net.minecraft.util.FormattedCharSequence;
import net.minecraft.util.RandomSource;
import org.joml.Matrix4f;
/** Real font colour selection and atlas glyph rendering, with GPU handles left unused. */
public final class VanillaInlineObjectOracle {
public static void main(String[] args) throws Exception {
ParityData.init();
var field = Class.forName("sun.misc.Unsafe").getDeclaredField("theUnsafe");
field.setAccessible(true);
Object unsafe = field.get(null);
var allocate = unsafe.getClass().getMethod("allocateInstance", Class.class);
var sprite = (TextureAtlasSprite) allocate.invoke(unsafe, TextureAtlasSprite.class);
for (var entry : Map.of("u0", .125f, "v0", .25f, "u1", .375f, "v1", .5f).entrySet()) {
var f = TextureAtlasSprite.class.getDeclaredField(entry.getKey());
f.setAccessible(true);
f.setFloat(sprite, entry.getValue());
}
var constructor = Class.forName("net.minecraft.client.gui.font.AtlasGlyphProvider$Instance")
.getDeclaredConstructors()[0];
constructor.setAccessible(true);
BakedGlyph glyph = new BakedGlyph() {
public GlyphInfo info() { return GlyphInfo.simple(8); }
public TextRenderable.Styled createGlyph(float x, float y, int color, int shadow,
Style style, float bold, float offset) {
try {
return (TextRenderable.Styled) constructor.newInstance(
null, null, sprite, x, y, color, shadow, offset, style);
} catch (ReflectiveOperationException e) { throw new RuntimeException(e); }
}
};
GlyphSource source = new GlyphSource() {
public BakedGlyph getGlyph(int c) { return glyph; }
public BakedGlyph getRandomGlyph(RandomSource random, int width) { return glyph; }
};
Font font = new Font(new Font.Provider() {
public GlyphSource glyphs(FontDescription description) { return source; }
public EffectGlyph effect() { throw new UnsupportedOperationException(); }
});
var output = new StringBuilder();
for (String line : Files.readAllLines(Path.of(args[0]))) {
var row = JsonParser.parseString(line).getAsJsonObject();
Style style = Style.EMPTY.withBold(true).withItalic(true);
if (row.has("color")) style = style.withColor(TextColor.parseColor(row.get("color").getAsString()).getOrThrow());
if (row.has("shadow_color")) style = style.withShadowColor(row.get("shadow_color").getAsInt());
var vertices = new ArrayList<float[]>();
var colors = new ArrayList<Integer>();
VertexConsumer recorder = (VertexConsumer) Proxy.newProxyInstance(
VertexConsumer.class.getClassLoader(), new Class<?>[] {VertexConsumer.class},
(proxy, method, values) -> {
if (method.isDefault()) return InvocationHandler.invokeDefault(proxy, method, values);
if (method.getName().equals("addVertex"))
vertices.add(new float[] {(float) values[0], (float) values[1], (float) values[2]});
if (method.getName().equals("setColor")) colors.add((int) values[0]);
return proxy;
});
boolean world = row.get("world").getAsBoolean();
var prepared = font.prepareText(FormattedCharSequence.forward("\ufffc", style),
row.get("x").getAsFloat(), row.get("y").getAsFloat(),
(int) row.get("base").getAsLong(), row.get("shadow").getAsBoolean(), false, 0);
prepared.visit(new Font.GlyphVisitor() {
public void acceptGlyph(TextRenderable.Styled renderable) {
renderable.render(new Matrix4f(), recorder, 0, !world);
}
});
output.append(vertices.size() / 4).append('\n');
for (int i = 0; i < vertices.size(); i += 4) {
var a = vertices.get(i);
var b = vertices.get(i + 2);
output.append(String.format(Locale.ROOT, "%.3f %.3f %.3f %.3f %.3f %08x%n",
a[0], a[1], b[0], b[1], a[2], colors.get(i)));
}
}
Files.writeString(Path.of(args[1]), output);
}
}
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Compare inline atlas sprite shadows and bounds with vanilla's real font renderer."""
import itertools
import json
try:
from . import lab, oracle_harness as harness, test_rust_parity as base, test_model_baking_parity as baking
except ImportError:
import lab, oracle_harness as harness, test_rust_parity as base, test_model_baking_parity as baking
def make_input(build):
rows = []
for world, shadow, base, custom, color in itertools.product(
(False, True), (False, True), (0xFFFFFFFF, 0x80123456, 0x04000000),
(None, 0, -65536, -2147418368), (None, '#4080c0')):
row = dict(world=world, shadow=shadow, base=base, x=-3.25, y=10.5,
bold=True, italic=True)
if custom is not None:
row['shadow_color'] = custom
if color is not None:
row['color'] = color
rows.append(row)
path = build / 'inputs.jsonl'
path.write_text(''.join(json.dumps(row) + '\n' for row in rows))
return path, len(rows)
def main():
lock, jars, _ = base.verify_artifacts()
build = lab.LOCAL / 'inline-object-parity'
build.mkdir(exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
sources = [lab.ROOT / path for path in (
'tools/test_inline_object_parity.py',
'crates/horizon-client-render/src/hud.rs',
'crates/horizon-client/examples/probe/inline_object_fixture.rs',
'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaInlineObjectOracle.java')]
tested = base.source_hashes(sources)
inputs, count = make_input(build)
classes, dependencies, _ = harness.compile_oracle(
build, 'VanillaInlineObjectOracle', jars, baking.client_libraries(), lock['minecraft']['java_major'])
harness.java_run(build, classes, dependencies, 'VanillaInlineObjectOracle',
[inputs, build / 'client.txt'], 'client')
# Text geometry needs neither an audio device nor controller system libraries.
harness.run_logged(['cargo', 'build', '--locked', '--no-default-features',
'-p', 'horizon-client', '--example', 'client_probe'], build, 'compile-rust')
harness.run_logged([lab.cargo_target() / 'debug/examples/client_probe', 'inline_object_fixture',
inputs, build / 'rust.txt'], build, 'rust')
matched = base.compare(build / 'client.txt', build / 'rust.txt')
base.ensure_sources_unchanged(sources, tested)
lab.write_json(report, dict(
schema=1, minecraft=lock['minecraft']['version'], cases=count,
client_jar_sha256=lab.digest(jars['client'], 'sha256'), sources_sha256=tested,
matched_output_lines=matched,
scope='Actual Font.prepareText and AtlasGlyphProvider.Instance/PlainTextRenderable.render '
'with a synthetic atlas region. Sprite bounds, shadow offset, tint, alpha, suppression '
'and world depth; excludes atlas loading, player skin lookup and GPU sampling.'))
print(f'{count} inline object cases match vanilla ({matched} lines): {report}')
if __name__ == '__main__':
main()