mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
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:
1 parent
1e02316c27
commit
7ab188cf5a
5 files changed
+247
-9
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user