Compare commits

...
Author SHA1 Message Date
Ludovic Marie 1d161337d0 fix for #3355: audio and sound_engine into the render crate 2026-10-28 23:59:49 +01:00
Ludovic Marie 52011a52f0 union: #3417 2026-10-28 23:45:38 +01:00
Ludovic Marie 66fa994370 union: #3416 2026-10-28 23:45:38 +01:00
Ludovic Marie e3791b7931 union: #3355 2026-10-28 23:45:38 +01:00
Ludovic Marie ca7692cf15 union: #3203 2026-10-28 23:45:38 +01:00
Hector EcclesandOpenCode a1542cb263 Give a single-player world its icon from the first frame
GameRenderer.tryTakeScreenshotIfNeeded and takeAutoScreenshot: once a
second, a world with no icon.png takes the centre square of its first
fully meshed frame, resampled to 64x64. Only the window's own
single-player world qualifies; joining a server clears it.

The snapshot runs after the hand and screen effects and before the
entity outline and post chain, as vanilla does, so the icon is the
scene rather than the GUI.

Not bit-identical to vanilla: the resize is a block average rather than
STB's filtered one, the PNG is written by the png crate, and the alpha
channel is forced opaque. The crop, size, timing and "only if missing"
rule match, so the icons look the same.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-28 18:23:21 -04:00
d3grandoandClaude Opus 5.5 8b94f011c6 Name tags: draw text as Font.prepareText does (bold, tall glyphs)
Name tags had their own glyph loop: every glyph an 8x8 quad at the line
top and no style. accented.png's 9x12 glyphs (й, Й, Ё...) were squeezed
into the line, bold, italic, underline, strikethrough, obfuscated and the
font style were ignored, the line was centred at -trunc(width / 2) and the
background stopped 8 pixels below the line top.

Vanilla (SubmitNodeCollection.submitNameTag -> TextFeatureRenderer ->
Font.prepareText) draws them like any text, centred at -Font.width / 2,
on a background from y - 1 to y + 9 (PreparedTextBuilder.markBackground).
HudBatch::name_tag lays the line out with text_styled, and names.rs maps
its quads into the world. Fixes #3084.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 05:03:36 +07:00
Gitlab Main 4564f7f0e6 options: choose the audio output device in Music & Sounds (#1035)
Adds audio output device selection matching vanilla options.txt format:
- Bind soundDevice option in Settings with serialization roundtrip
- Enumerate output devices and support live stream switching in Audio
- Add SoundDevice option button to Music & Sounds page cycling devices
- Apply audio device changes dynamically on options save and startup
- Unit tests for settings parsing and options UI button cycling
2026-10-29 00:42:10 +05:30
SenjinandClaude Opus 5.5 380d5ebe9f HDR: its settings get a screen of their own
Paper White and Peak Brightness sat among the display settings, where players
without an HDR display took them for settings of their own display. Display now
has only HDR Settings..., greyed where the display path offers no HDR output (the
renderer tells the settings when it opens its surface, `graphics::offer_hdr`).
It opens Graphics: HDR: the HDR switch, then its sliders, greyed while it is off.

Where HDR isn't offered, `horizon-graphics.txt`'s HDR settings are left as saved
and not used: the renderer already keeps HDR off there, and the screen no longer
opens, so a game folder copied from another computer, or a display swapped for
an SDR one, shows nothing that does nothing; an HDR display brings them back.

The screen leaves room for a calibration step and an in-game shortcut to it
later. horizon-ctl opens it as `options graphics-hdr`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 13:58:55 +01:00
23 changed files with 603 additions and 125 deletions

No files matched your search

Generated
+2 -2
View File
@@ -1378,7 +1378,6 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"cpal",
"flate2",
"gilrs",
"glam",
@@ -1413,7 +1412,6 @@ dependencies = [
"serde_json",
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
@@ -1449,6 +1447,7 @@ dependencies = [
"ab_glyph",
"bytemuck",
"chrono",
"cpal",
"flate2",
"glam",
"horizon-assets",
@@ -1475,6 +1474,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"symphonia",
"unicode-bidi",
"ureq",
"wgpu",
+5
View File
@@ -7,6 +7,9 @@ rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
@@ -51,6 +54,8 @@ wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
# Sound output (horizon-client's `audio` feature).
audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
@@ -144,6 +144,7 @@ type Stream = cpal::Stream;
type Stream = std::convert::Infallible;
pub struct Audio {
_stream: Option<Stream>,
device_name: String,
pub engine: SoundEngine,
backend: MixerBackend,
/// Record `Heard` sounds (the subtitle overlay is listening).
@@ -543,15 +544,47 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
)?;
Ok(stream)
}
/// Available audio output device names.
#[cfg(feature = "audio")]
pub fn output_devices() -> Vec<String> {
if output_disabled(|name| std::env::var_os(name)).is_some() {
return Vec::new();
}
let host = cpal::default_host();
let mut names = Vec::new();
if let Ok(devices) = host.output_devices() {
for device in devices {
if let Ok(name) = device.name()
&& !names.contains(&name)
{
names.push(name);
}
}
}
names
}
#[cfg(not(feature = "audio"))]
pub fn output_devices() -> Vec<String> {
Vec::new()
}
impl Audio {
pub fn new(root: &Path) -> Self {
Self::with_output(root, true)
Self::with_device(root, "")
}
pub fn with_device(root: &Path, device_name: &str) -> Self {
Self::with_output(root, true, device_name)
}
/// No output device: the dedicated server.
pub fn silent(root: &Path) -> Self {
Self::with_output(root, false)
Self::with_output(root, false, "")
}
fn with_output(root: &Path, output: bool) -> Self {
/// Active audio device name, or empty for system default.
pub fn device(&self) -> &str {
&self.device_name
}
fn with_output(root: &Path, output: bool, target_device: &str) -> Self {
let registry = SoundRegistry::load(root);
if output && registry.is_empty() {
// The game then plays nothing on a working output (#1035, #1076).
@@ -564,23 +597,24 @@ impl Audio {
}
let mixer = Arc::new(Mutex::new(Mixer::default()));
let opened = if output {
Self::open(mixer.clone())
Self::open_named(mixer.clone(), target_device)
} else {
Err("no output on a dedicated server".into())
};
let (stream, live) = match opened {
Ok(v) => (Some(v), true),
let (stream, live, actual_device) = match opened {
Ok((v, dev)) => (Some(v), true, dev),
Err(e) => {
if output {
log::warn!("Horizon: no sound: {e}");
}
(None, false)
(None, false, String::new())
}
};
let mut engine = SoundEngine::new(registry);
engine.loaded = live;
Self {
_stream: stream,
device_name: actual_device,
engine,
backend: MixerBackend {
mixer,
@@ -602,6 +636,63 @@ impl Audio {
}
}
#[cfg(not(feature = "audio"))]
fn open_named(_: Arc<Mutex<Mixer>>, _: &str) -> Result<(Stream, String)> {
Err("this build has no sound output (built without the `audio` feature)".into())
}
#[cfg(feature = "audio")]
fn open_named(mixer: Arc<Mutex<Mixer>>, target: &str) -> Result<(Stream, String)> {
if let Some(reason) = output_disabled(|name| std::env::var_os(name)) {
return Err(reason.into());
}
let host = cpal::default_host();
if !target.is_empty()
&& let Ok(devices) = host.output_devices()
{
for device in devices {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if name == target {
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok((stream, target.to_owned()));
}
Err(e) => {
log::warn!(
"Horizon: could not open audio device '{target}': {e}; falling back to default"
);
}
}
break;
}
}
}
let stream = Self::open(mixer)?;
Ok((stream, String::new()))
}
#[cfg(feature = "audio")]
pub fn set_device(&mut self, name: &str) -> Result<()> {
if !self.backend.live && self._stream.is_none() && !self.engine.loaded {
self.device_name = name.to_owned();
return Ok(());
}
let (stream, actual) = Self::open_named(self.backend.mixer.clone(), name)?;
self._stream = Some(stream);
self.device_name = actual;
self.backend.live = true;
self.engine.loaded = true;
Ok(())
}
#[cfg(not(feature = "audio"))]
pub fn set_device(&mut self, name: &str) -> Result<()> {
self.device_name = name.to_owned();
Ok(())
}
#[cfg(not(feature = "audio"))]
fn open(_: Arc<Mutex<Mixer>>) -> Result<Stream> {
Err("this build has no sound output (built without the `audio` feature)".into())
}
+104 -11
View File
@@ -574,6 +574,9 @@ pub struct Renderer {
pub screenshot: Option<PathBuf>,
/// Outcome of the last screenshot: frame size, or the error.
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// Write the next frame's centre square as the world's 64x64 `icon.png`
/// (GameRenderer.takeAutoScreenshot).
pub world_icon: Option<PathBuf>,
/// Brightness option (lightmap gamma), 0..1.
pub gamma: f32,
/// Optional full-light lightmap (Horizon Extras).
@@ -1128,7 +1131,9 @@ impl Renderer {
));
}
surface.configure(&device, &config);
(config, caps.present_modes.clone(), hdr::output(&caps))
let output = hdr::output(&caps);
crate::graphics::offer_hdr(output.is_some());
(config, caps.present_modes.clone(), output)
} else {
let format = wgpu::TextureFormat::Bgra8Unorm;
let config = wgpu::SurfaceConfiguration {
@@ -2669,6 +2674,7 @@ impl Renderer {
started: Instant::now(),
screenshot: None,
screenshot_result: None,
world_icon: None,
gamma: 0.5,
fullbright: false,
animated_textures: true,
@@ -5113,6 +5119,18 @@ impl Renderer {
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
}
}
// GameRenderer.tryTakeScreenshotIfNeeded runs after the level (hand and
// screen effects included) and before the entity outline and the post
// chain: the icon is the plain scene, with no GUI either.
let eight_bit = self.config.format.block_copy_size(None) == Some(4);
let icon = self.world_icon.take().and_then(|path| {
if eight_bit {
Some((path, self.copy_frame(&mut encoder, &target_texture)))
} else {
log::warn!("Couldn't save auto screenshot: no 32-bit surface format");
None
}
});
// GameRenderer.render3dHud: the debug crosshair after the hand and
// the screen effects.
if crosshair > 0 {
@@ -5192,7 +5210,7 @@ impl Renderer {
let readback = self.screenshot.take().and_then(|path| {
// The copy and PNG writer both assume 8-bit pixels; a float surface would make
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
if eight_bit {
Some((path, self.copy_frame(&mut encoder, &target_texture)))
} else {
self.screenshot_result = Some(Err(format!(
@@ -5233,6 +5251,11 @@ impl Renderer {
.map_err(|e| e.to_string()),
);
}
if let Some((path, buffer)) = icon
&& let Err(e) = self.write_world_icon(&path, &buffer)
{
log::warn!("Couldn't save auto screenshot: {e}");
}
let present = Instant::now();
if let Some(surface_frame) = surface_frame {
// On Wayland this ties RedrawRequested to the compositor's frame
@@ -5313,6 +5336,16 @@ impl Renderer {
buffer
}
fn write_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<[u32; 2]> {
let (width, height, pixels) = self.read_frame(buffer)?;
write_png(path, width, height, &pixels)?;
Ok([width, height])
}
/// GameRenderer.takeAutoScreenshot: the centre square, scaled to 64x64.
fn write_world_icon(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
let (width, height, pixels) = self.read_frame(buffer)?;
write_png(path, 64, 64, &world_icon_pixels(&pixels, width, height))
}
fn read_frame(&self, buffer: &wgpu::Buffer) -> Result<(u32, u32, Vec<u8>)> {
if !self.config.usage.contains(wgpu::TextureUsages::COPY_SRC) {
return Err("this surface does not support frame readback".into());
}
@@ -5350,17 +5383,51 @@ impl Renderer {
}
}
buffer.unmap();
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(path)?),
width,
height,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header()?.write_image_data(&pixels)?;
Ok([width, height])
Ok((width, height, pixels))
}
}
fn write_png(path: &std::path::Path, width: u32, height: u32, pixels: &[u8]) -> Result<()> {
let mut encoder = png::Encoder::new(
std::io::BufWriter::new(std::fs::File::create(path)?),
width,
height,
);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder.write_header()?.write_image_data(pixels)?;
Ok(())
}
/// `NativeImage.resizeSubRectTo` from the screen's centre to 64x64. Vanilla
/// resamples with STB; this averages each destination texel's source block,
/// which differs by a texel where a block does not divide evenly.
fn world_icon_pixels(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
let side = width.min(height) as usize;
if side == 0 {
return vec![0; 64 * 64 * 4];
}
let (left, top) = ((width as usize - side) / 2, (height as usize - side) / 2);
let mut out = Vec::with_capacity(64 * 64 * 4);
for y in 0..64 {
let y0 = y * side / 64;
let y1 = ((y + 1) * side).div_ceil(64).max(y0 + 1).min(side);
for x in 0..64 {
let x0 = x * side / 64;
let x1 = ((x + 1) * side).div_ceil(64).max(x0 + 1).min(side);
let mut sum = [0u32; 4];
for sy in y0..y1 {
for sx in x0..x1 {
let i = ((top + sy) * width as usize + left + sx) * 4;
for c in 0..4 {
sum[c] += u32::from(pixels[i + c]);
}
}
}
let n = ((y1 - y0) * (x1 - x0)) as u32;
out.extend(sum.map(|c| (c / n) as u8));
}
}
out
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -5452,6 +5519,32 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
#[test]
fn world_icon_crops_the_centre_and_scales_to_64() {
use super::world_icon_pixels;
// 128x64, red left half and blue right half: the centre square is
// 64x64 at x=32, so it keeps half of each colour.
let mut pixels = Vec::with_capacity(128 * 64 * 4);
for _ in 0..128 * 64 {
pixels.extend_from_slice(&[255, 0, 0, 255]);
}
for y in 0..64 {
for x in 64..128 {
let i = (y * 128 + x) * 4;
pixels[i..i + 4].copy_from_slice(&[0, 0, 255, 255]);
}
}
let icon = world_icon_pixels(&pixels, 128, 64);
assert_eq!(icon.len(), 64 * 64 * 4);
assert_eq!(&icon[..4], [255, 0, 0, 255]);
let last = (63 * 64 + 63) * 4;
assert_eq!(&icon[last..last + 4], [0, 0, 255, 255]);
// A source smaller than the icon repeats its texels rather than
// indexing past them.
let tiny = world_icon_pixels(&[1, 2, 3, 255], 1, 1);
assert!(tiny.as_chunks::<4>().0.iter().all(|p| p == &[1, 2, 3, 255]));
}
#[test]
fn surface_is_8_bit_and_not_srgb() {
use super::surface_format;
@@ -210,6 +210,16 @@ pub fn reduced_far_detail_suggested() -> bool {
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
}
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
pub fn offer_hdr(on: bool) {
HDR_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn hdr_offered() -> bool {
HDR_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -130,8 +130,9 @@ impl Style {
label: &Text,
value: f32,
hover: bool,
active: bool,
) {
ui::slider(batch, atlas, x, y, w, label, value, hover, true);
ui::slider(batch, atlas, x, y, w, label, value, hover && active, active);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
+107 -19
View File
@@ -1,4 +1,4 @@
//! Horizon's graphics settings screens, Simple and Advanced: which settings
//! Horizon's graphics settings screens, Simple, Advanced and HDR: which settings
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
@@ -14,6 +14,8 @@ use crate::{
pub enum Level {
Simple,
Advanced,
/// HDR output's own settings, opened from Advanced while HDR is on.
Hdr,
}
/// Horizon's own settings (`graphics::Graphics`).
@@ -37,6 +39,8 @@ pub enum H {
ResolutionScale,
Antialiasing,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
HdrPaperWhite,
HdrPeak,
BatteryMode,
@@ -119,6 +123,24 @@ pub fn rows(level: Level) -> Vec<Row> {
);
let mut rows = match level {
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
Level::Hdr => vec![
Row::Header("HDR"),
h(
H::Hdr,
None,
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
),
h(
H::HdrPaperWhite,
None,
"How bright white paper and menus look. Match your desktop.",
),
h(
H::HdrPeak,
None,
"How bright the brightest highlights get. Your display's peak.",
),
],
Level::Advanced => vec![
Row::Header("Rendering"),
render,
@@ -224,20 +246,10 @@ pub fn rows(level: Level) -> Vec<Row> {
"FXAA smooths jagged edges of the world; slightly softer image.",
),
h(
H::Hdr,
H::HdrSettings,
None,
"HDR output on an HDR display: lava, fire and sunlight shine brighter.",
),
h(
H::HdrPaperWhite,
None,
"HDR: how bright white paper and menus look. Match your desktop.",
),
h(
H::HdrPeak,
None,
"HDR: how bright the brightest highlights get. Your display's peak.",
),
h(
H::PresentMode,
None,
@@ -385,6 +397,7 @@ impl H {
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal("HDR Settings..."),
H::HdrPaperWhite => labelled("Paper White", format!("{} nits", g.hdr_paper_white)),
H::HdrPeak => labelled("Peak Brightness", format!("{} nits", g.hdr_peak)),
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
@@ -471,6 +484,25 @@ impl Item {
fn available(self) -> bool {
!matches!(self, Item::Vanilla(o) if !o.available())
}
/// Why a control can't be used now, when it can't: HDR's screen opens only where
/// the display offers HDR output, whatever `horizon-graphics.txt` says (a copied game
/// folder, a display swapped for an SDR one: the renderer leaves HDR off there, and
/// the saved values wait for an HDR display), and its sliders do something only
/// while it is on.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
}
match self {
Item::Horizon(H::HdrSettings | H::Hdr) if !crate::graphics::hdr_offered() => {
Some("Your display or system doesn't offer HDR output.")
}
Item::Horizon(H::HdrPaperWhite | H::HdrPeak) if !s.horizon.hdr => {
Some("Turn HDR on to use it.")
}
_ => None,
}
}
/// On/off settings and their value.
pub fn switch(self, s: &Settings) -> Option<bool> {
let g = &s.horizon;
@@ -572,6 +604,7 @@ pub enum Action {
Changed,
OpenAdvanced,
OpenSimple,
OpenHdr,
Done,
}
@@ -649,6 +682,17 @@ impl GraphicsUi {
let other = match level {
Level::Simple => Action::OpenAdvanced,
Level::Advanced => Action::OpenSimple,
// One Done, back to Advanced.
Level::Hdr => {
widgets.push(Widget {
x: cx - 100.0,
y: by,
w: 200.0,
target: Target::Button(Action::Done),
list: false,
});
return (widgets, headers, content);
}
};
widgets.push(Widget {
x: cx - 155.0,
@@ -710,12 +754,10 @@ impl GraphicsUi {
continue;
}
let label = item.label(s);
let live = item.available();
if hover && !live {
tooltip = Some(atlas.font.split(
&Text::literal("This feature is not yet available in Horizon."),
170.0,
));
let blocked = item.blocked(s);
let live = blocked.is_none();
if hover && let Some(why) = blocked {
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
}
match item.slider(s) {
Some(value) => STYLE.slider(
@@ -725,6 +767,7 @@ impl GraphicsUi {
&label,
value,
hover || self.dragging == Some(item),
live,
),
None => STYLE.button(
batch,
@@ -774,6 +817,7 @@ impl GraphicsUi {
let title = match level {
Level::Simple => "Graphics",
Level::Advanced => "Graphics: Advanced",
Level::Hdr => "Graphics: HDR",
};
STYLE.title(batch, atlas, &Text::literal(title));
if let Some(lines) = tooltip {
@@ -817,7 +861,10 @@ impl GraphicsUi {
return match wd.target {
Target::Button(action) => vec![Action::ButtonClick, action],
// A disabled control does nothing: no sound, no preset change.
Target::Item(item) if !item.available() => Vec::new(),
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
Target::Item(Item::Horizon(H::HdrSettings)) => {
vec![Action::ButtonClick, Action::OpenHdr]
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
@@ -847,6 +894,47 @@ impl GraphicsUi {
mod tests {
use super::*;
#[test]
fn hdr_settings_open_where_hdr_is_offered() {
let mut s = Settings::default();
let mut ui = GraphicsUi::default();
let size = (480.0, 2000.0);
let at = |ui: &mut GraphicsUi, level, item| {
let (widgets, _, _) = ui.layout(level, size.0, size.1);
let w = widgets
.iter()
.find(|w| matches!(w.target, Target::Item(i) if i == item))
.unwrap();
(w.x + 1.0, w.y + 1.0)
};
let button = at(&mut ui, Level::Advanced, Item::Horizon(H::HdrSettings));
let click = |ui: &mut GraphicsUi, s: &mut Settings, level, point| {
ui.click(level, s, point, size, [480, 2000], false, false)
};
crate::graphics::offer_hdr(false);
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
// Not even with HDR saved on, from another computer.
s.horizon.hdr = true;
assert!(click(&mut ui, &mut s, Level::Advanced, button).is_empty());
s.horizon.hdr = false;
crate::graphics::offer_hdr(true);
assert_eq!(
click(&mut ui, &mut s, Level::Advanced, button),
[Action::ButtonClick, Action::OpenHdr]
);
// On HDR's screen, the sliders wait for the switch above them.
let peak = at(&mut ui, Level::Hdr, Item::Horizon(H::HdrPeak));
assert!(click(&mut ui, &mut s, Level::Hdr, peak).is_empty());
s.horizon.hdr = true;
assert_eq!(click(&mut ui, &mut s, Level::Hdr, peak), [Action::Changed]);
let on = |level, item| {
extra_rows(level)
.iter()
.any(|r| matches!(r, Row::Setting(x) if x.item == Item::Horizon(item)))
};
assert!(on(Level::Hdr, H::Hdr) && !on(Level::Advanced, H::Hdr));
assert!(on(Level::Hdr, H::HdrPeak) && !on(Level::Advanced, H::HdrPeak));
}
#[test]
fn graphics_buttons_sound_on_press_and_sliders_on_release() {
let mut s = Settings::default();
+86
View File
@@ -1107,6 +1107,33 @@ impl HudBatch {
}
cursor - x
}
/// SubmitNodeCollection.submitNameTag's text in its own space (x right,
/// y down, in GUI pixels): centred at `-Font.width / 2`, drawn as
/// Font.prepareText draws any text (styles, fonts and glyph boxes; no
/// shadow), over `background` (RGBA) unless its alpha is 0. Returns how
/// many vertices at the start of the batch are the background.
pub fn name_tag(
&mut self,
atlas: &HudAtlas,
text: &crate::text::Text,
y: f32,
color: u32,
background: u32,
) -> usize {
let x = -atlas.font.width_text(text) / 2.0;
let start = self.vertices.len();
let advance = self.text_styled(atlas, text, x, y, color, false);
if background >> 24 == 0 {
return 0;
}
// PreparedTextBuilder.markBackground: one pixel round the advances,
// from a pixel above the line to 9 below its top.
let glyphs = self.vertices.split_off(start);
self.fill(atlas, x - 1.0, y - 1.0, advance + 1.0, 10.0, background);
let behind = self.vertices.len() - start;
self.vertices.extend(glyphs);
behind
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
@@ -1822,6 +1849,65 @@ mod tests {
assert!(missing.rect.is_none() && missing.right == 5.0);
}
#[test]
fn name_tags_draw_bold_text_and_tall_glyphs_like_any_text() {
// `ascii.png`'s 8x8 'a' (4 pixels wide) and `accented.png`'s 9x12
// 'й' (height 12, ascent 10: the breve above the line), 7 wide.
let root = std::env::temp_dir().join(format!("horizon-name-tag-{}", std::process::id()));
let textures = root.join("assets/minecraft/textures/font");
fs::create_dir_all(root.join("assets/minecraft/font")).unwrap();
fs::create_dir_all(&textures).unwrap();
fs::write(
root.join("assets/minecraft/font/default.json"),
r#"{"providers":[
{"type":"bitmap","file":"minecraft:font/a.png","ascent":7,"chars":["a"]},
{"type":"bitmap","file":"minecraft:font/tall.png","height":12,"ascent":10,"chars":["й"]}]}"#,
)
.unwrap();
for (name, (w, h), used) in [("a", (8, 8), 4), ("tall", (9, 12), 7)] {
let pixels: Vec<u8> = (0..w * h)
.flat_map(|i| [255, 255, 255, if i % w < used { 255 } else { 0 }])
.collect();
let file = fs::File::create(textures.join(format!("{name}.png"))).unwrap();
let mut encoder = png::Encoder::new(file, w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&pixels).unwrap();
}
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let text = crate::text::Text::literal("aй").bold();
let mut batch = HudBatch::new([1, 1]);
let behind = batch.name_tag(&atlas, &text, 0.0, u32::MAX, 0x3F00_0000);
// Each quad's box: x0, y0, x1, y1.
let boxes: Vec<[f32; 4]> = batch
.vertices
.as_chunks::<6>()
.0
.iter()
.map(|q| [q[0].pos[0], q[0].pos[1], q[2].pos[0], q[2].pos[1]])
.collect();
// Advances 5 and 8, plus 1 each for bold: Font.width 15, centred.
let x = -7.5;
// Each glyph twice (bold), the copy a pixel right; 'й' in its 9x12
// box from 3 pixels above the line, each bold quad 0.1 larger.
let near = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-4);
let expected = [
[x - 0.1, -0.1, x + 8.1, 8.1],
[x + 0.9, -0.1, x + 9.1, 8.1],
[x + 5.9, -3.1, x + 15.1, 9.1],
[x + 6.9, -3.1, x + 16.1, 9.1],
];
for (got, want) in boxes[1..].iter().zip(expected) {
assert!(near(*got, want), "{got:?} != {want:?}");
}
assert_eq!(boxes.len(), 1 + expected.len());
// The background first: a pixel round the advances, 10 pixels tall.
assert_eq!(behind, 6);
assert_eq!(boxes[0], [x - 1.0, -1.0, x + 15.0, 9.0]);
}
#[test]
fn effect_icons_are_painted_from_the_mob_effect_directory() {
use crate::{effects::EffectInstance, hud_layout, hud_overlays, inventory_effects};
+2
View File
@@ -3,6 +3,7 @@
#![forbid(unsafe_code)]
pub mod assets;
pub mod atlas_sources;
pub mod audio;
pub mod auth;
pub mod bidi;
pub mod biome;
@@ -65,6 +66,7 @@ pub mod settings;
pub mod shader_pack_list;
pub mod signs;
pub mod skins;
pub mod sound_engine;
pub mod sound_events;
pub mod spans;
pub mod sprites;
+60 -2
View File
@@ -128,6 +128,7 @@ impl Opt {
Opt::RotateWithMinecarts => "rotateWithMinecart",
Opt::HighContrastBlockOutlines => "highContrastBlockOutline",
Opt::MusicFrequency => "musicFrequency",
Opt::SoundDevice => "soundDevice",
_ => return None,
})
}
@@ -144,6 +145,7 @@ impl Opt {
Opt::DiscreteScroll => "options.discrete_mouse_scroll",
Opt::AllowCursorChanges => "options.allowCursorChanges",
Opt::Volume(_) => "soundCategory.master",
Opt::SoundDevice => "options.audioDevice",
Opt::GuiScale => "options.guiScale",
Opt::ViewBobbing => "options.viewBobbing",
Opt::Gamma => "options.gamma",
@@ -527,6 +529,13 @@ impl Opt {
Opt::ReducedDebugInfo => on_off(s.reduced_debug_info),
Opt::HighContrast => on_off(s.high_contrast),
Opt::ShowSubtitles => on_off(s.show_subtitles),
Opt::SoundDevice => {
if s.sound_device.is_empty() {
tr("options.audioDevice.default")
} else {
Text::literal(&s.sound_device)
}
}
Opt::MusicFrequency => tr([
"options.music_frequency.default",
"options.music_frequency.frequent",
@@ -713,6 +722,23 @@ impl Opt {
Opt::ReducedDebugInfo => s.reduced_debug_info = !s.reduced_debug_info,
Opt::HighContrast => s.high_contrast = !s.high_contrast,
Opt::ShowSubtitles => s.show_subtitles = !s.show_subtitles,
Opt::SoundDevice => {
let devices = crate::audio::output_devices();
let mut all = vec![String::new()];
for d in devices {
if !all.contains(&d) {
all.push(d);
}
}
let current_idx = all.iter().position(|d| d == &s.sound_device).unwrap_or(0);
let count = all.len();
let next_idx = if back {
(current_idx + count - 1) % count
} else {
(current_idx + 1) % count
};
s.sound_device = all[next_idx].clone();
}
Opt::MusicFrequency => {
let n = next(s.file.int("musicFrequency").clamp(0, 2) as u8, 3);
s.file
@@ -844,6 +870,7 @@ pub enum Page {
/// Horizon's graphics settings (`graphics_ui`).
Graphics,
GraphicsAdvanced,
GraphicsHdr,
/// Horizon's controller settings and button binds.
Controller,
PadBinds,
@@ -1156,7 +1183,10 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if matches!(page, Page::Graphics | Page::GraphicsAdvanced) {
if matches!(
page,
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
) {
self.graphics.reset();
}
}
@@ -1165,6 +1195,7 @@ impl OptionsUi {
match page {
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
_ => None,
}
}
@@ -1184,7 +1215,7 @@ impl OptionsUi {
Page::Mods => "Mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "Shader Packs",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
Page::Controller => "Controller Settings",
Page::PadBinds => "Controller Binds",
Page::Extras => "Horizon Extras",
@@ -1439,6 +1470,7 @@ impl OptionsUi {
// SoundOptionsScreen: Music Frequency starts the next row (with
// Music Toast, not in Horizon yet).
rows.extend(pairs(&[Opt::MusicFrequency]));
rows.push(Row::Big(Opt::SoundDevice));
rows
}
Page::Controls => {
@@ -2428,6 +2460,7 @@ impl OptionsUi {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
@@ -4272,4 +4305,29 @@ mod tests {
ui.stack = vec![Page::Main, Page::Controls];
assert!(pushes(&mut ui).contains(&Page::Controller));
}
#[test]
fn sound_device_button_in_sound_page_and_cycles() {
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Sound);
let rows = ui.list_rows(&s);
assert!(
rows.iter().any(|r| matches!(r, Row::Big(Opt::SoundDevice))),
"Sound page should contain Row::Big(Opt::SoundDevice)"
);
assert_eq!(s.sound_device, "");
let devices = crate::audio::output_devices();
let count = 1 + devices.len();
// Cycling forward 'count' times wraps around back to the initial selection ("")
for _ in 0..count {
Opt::SoundDevice.cycle(&mut s, false, [854, 480]);
}
assert_eq!(s.sound_device, "");
// Cycling backward 'count' times also wraps around back to ""
for _ in 0..count {
Opt::SoundDevice.cycle(&mut s, true, [854, 480]);
}
assert_eq!(s.sound_device, "");
}
}
@@ -100,6 +100,8 @@ pub struct Settings {
pub master_volume: f32,
/// Volumes of every other sound source, indexed like SOUND_SOURCES.
pub volumes: [f32; 11],
/// Empty for system default, or a specific audio device name.
pub sound_device: String,
/// Requested scale (0 = auto); rendering caps it to the current framebuffer.
pub gui_scale: u32,
pub view_bobbing: bool,
@@ -194,6 +196,7 @@ impl Default for Settings {
sensitivity: 0.5,
master_volume: 1.0,
volumes: [1.0; 11],
sound_device: String::new(),
gui_scale: 0,
view_bobbing: true,
gamma: 0.5,
@@ -382,6 +385,7 @@ impl Settings {
sensitivity: f.double("mouseSensitivity") as f32,
master_volume: volumes[0],
volumes,
sound_device: f.string("soundDevice"),
gui_scale: f.int("guiScale").max(0) as u32,
view_bobbing: f.bool("bobView"),
gamma: f.double("gamma") as f32,
@@ -464,6 +468,7 @@ impl Settings {
};
put_f(&mut f, &format!("soundCategory_{source}"), v);
}
put_s(&mut f, "soundDevice", &self.sound_device);
put_i(&mut f, "guiScale", self.gui_scale as i32);
put_b(&mut f, "bobView", self.view_bobbing);
put_f(&mut f, "gamma", self.gamma);
@@ -626,6 +631,7 @@ pub enum Opt {
InvertY,
AllowCursorChanges,
Volume(usize),
SoundDevice,
GuiScale,
ViewBobbing,
Gamma,
@@ -800,4 +806,15 @@ mod tests {
1.0
);
}
#[test]
fn sound_device_option() {
let s = Settings::parse("soundDevice:\"Headphones\"\n");
assert_eq!(s.sound_device, "Headphones");
let serialized = s.serialize();
assert!(serialized.contains("soundDevice:\"Headphones\""));
let again = Settings::parse(&serialized);
assert_eq!(again.sound_device, "Headphones");
let empty = Settings::parse("");
assert_eq!(empty.sound_device, "");
}
}
@@ -1231,7 +1231,7 @@ impl<B: Backend> Sounds for Bound<'_, B> {
.update_category_volume(source, gain, self.backend);
}
}
#[cfg(test)]
#[cfg(any(test, feature = "test-support"))]
pub mod test_backend {
use super::{Backend, ChannelId};
use crate::sound_events::Sound;
+1 -4
View File
@@ -44,9 +44,6 @@ serde_json = { version = "1.0.151", features = ["raw_value"] }
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
ab_glyph = "0.2.32"
log = "0.4"
@@ -91,7 +88,7 @@ winresource = "0.1"
default = ["audio", "controllers"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["dep:cpal"]
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Runs code mods (wasmtime and cranelift: a quarter of a clean build). On in every release build
@@ -11,5 +11,7 @@
"horizon.options.look_deadzone": "Look Deadzone",
"horizon.options.button_glyphs": "Button Glyphs",
"horizon.options.menu_navigation": "Menu Navigation",
"horizon.options.on_screen_keyboard": "On-Screen Keyboard"
"horizon.options.on_screen_keyboard": "On-Screen Keyboard",
"options.audioDevice": "Output Device",
"options.audioDevice.default": "System Default"
}
@@ -11,5 +11,7 @@
"horizon.options.look_deadzone": "Мёртвая зона обзора",
"horizon.options.button_glyphs": "Символы кнопок",
"horizon.options.menu_navigation": "Навигация по меню",
"horizon.options.on_screen_keyboard": "Экранная клавиатура"
"horizon.options.on_screen_keyboard": "Экранная клавиатура",
"options.audioDevice": "Устройство вывода",
"options.audioDevice.default": "По умолчанию"
}
@@ -146,6 +146,7 @@ const PAGES: Ty = Ty::Enum(&[
"shader-packs",
"graphics",
"graphics-advanced",
"graphics-hdr",
"extras",
"horizon",
"agent",
+52 -2
View File
@@ -1677,6 +1677,11 @@ struct Game<'a> {
sleep_seen: (i64, i64),
dim: Dim,
world_root: PathBuf,
/// The world's auto `icon.png` until it exists (GameRenderer's
/// tryTakeScreenshotIfNeeded). Single player only.
world_icon: Option<PathBuf>,
/// When a world icon was last attempted; at most once a second.
world_icon_attempt: Option<Instant>,
/// The world is superflat (`ServerLevel.isFlat`, for every dimension).
world_flat: bool,
/// A player carried through a portal, re-placed once the destination loads.
@@ -9034,6 +9039,13 @@ impl Game<'_> {
return Ok(());
}
self.join_world_rest()?;
// A single-player world takes its icon from its first good frame.
let icon = std::env::current_dir()?
.join("saves")
.join(folder)
.join("icon.png");
self.world_icon = (!icon.is_file()).then_some(icon);
self.world_icon_attempt = None;
if self
.options
.execution_role
@@ -9498,6 +9510,8 @@ impl Game<'_> {
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
self.player = None;
self.world_icon = None;
self.world_icon_attempt = None;
self.sleeping = None;
self.sleep_timer = 0;
self.clear_entities();
@@ -10824,6 +10838,9 @@ impl Game<'_> {
self.all_sections_changed();
}
self.audio.master = self.settings.master_volume;
if self.settings.sound_device != self.audio.device() {
let _ = self.audio.set_device(&self.settings.sound_device);
}
horizon_client::hud::GUI_SCALE.store(
self.settings.gui_scale,
std::sync::atomic::Ordering::Relaxed,
@@ -11582,6 +11599,36 @@ impl Game<'_> {
}
Ok(())
}
/// `GameRenderer.tryTakeScreenshotIfNeeded`: the first frame drawn in a
/// single-player world after its sections are meshed becomes `icon.png`.
fn try_take_world_icon(&mut self) {
let Some(path) = self.world_icon.clone() else {
return;
};
if self.title.is_some() || self.player.is_none() {
return;
}
let now = Instant::now();
if self
.world_icon_attempt
.is_some_and(|last| now.duration_since(last) < Duration::from_secs(1))
{
return;
}
self.world_icon_attempt = Some(now);
if path.is_file() {
self.world_icon = None;
return;
}
let busy = self.streaming.busy();
let Some(renderer) = &mut self.renderer else {
return;
};
if renderer.world_icon.is_some() || renderer.drawn_sections <= 10 || busy {
return;
}
renderer.world_icon = Some(path);
}
fn render(&mut self) -> Result<bool> {
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
// server starts. Its chunk map and progress are the server's, which
@@ -11656,6 +11703,7 @@ impl Game<'_> {
else {
return Ok(false);
};
self.try_take_world_icon();
let alpha = self.clock.fraction();
// Camera.getCameraEntityPartialTicks: the view, the hand and the
// player's own model stay smooth while the level is frozen.
@@ -13987,6 +14035,8 @@ impl<'a> Game<'a> {
sleep_seen: (0, 0),
dim: Dim::Overworld,
world_root: PathBuf::new(),
world_icon: None,
world_icon_attempt: None,
world_flat: false,
travelling: None,
server_trips: Vec::new(),
@@ -15323,7 +15373,7 @@ impl Loading {
let audio = if !options.execution_role.is_client() {
Audio::silent(&options.resources)
} else {
Audio::new(&options.resources)
Audio::with_device(&options.resources, &self.settings.sound_device)
};
let pack_error = loaded.pack_error.or_else(|| self.pack_error.take());
// The game borrows its shared resources for the process's lifetime,
@@ -15574,7 +15624,7 @@ pub fn run() -> Result<()> {
let audio = if !options.execution_role.is_client() {
Audio::silent(&options.resources)
} else {
Audio::new(&options.resources)
Audio::with_device(&options.resources, &early_settings.sound_device)
};
let resources = loaded.resources;
let game = start_game(
+2 -1
View File
@@ -34,7 +34,7 @@ const HELP: &[&str] = &[
"chat TEXT send a chat line or /command as if typed",
"type TEXT type into the open chat box (key enter tap sends)",
"chat-log [N] the newest N chat messages (default 10)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced)",
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)",
"stats [reset] frame-time summary since the last reset",
"bench SECONDS reset stats, wait, then reply with the summary",
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
@@ -677,6 +677,7 @@ impl Game<'_> {
"shader-packs" => Page::ShaderPacks,
"graphics" => Page::Graphics,
"graphics-advanced" => Page::GraphicsAdvanced,
"graphics-hdr" => Page::GraphicsHdr,
"extras" => Page::Extras,
"credits" => Page::Credits,
"horizon" => Page::Horizon,
+36 -66
View File
@@ -7,7 +7,7 @@
//! or a server) show whenever `LivingEntityRenderer.shouldShowName` allows.
use super::{Game, view_vector};
use horizon_client::{
block_entity_render::vertex_light, mesher::Vertex, mobs::Mob, save::uuid_text,
block_entity_render::vertex_light, hud::HudBatch, mesher::Vertex, mobs::Mob, save::uuid_text,
scoreboard as sb, text::Text,
};
@@ -289,7 +289,7 @@ impl Game<'_> {
/// faded, on its background; any other in full over the world, its faded
/// copy and background in `see_through`, over everything.
pub(super) fn name_tag_geometry(
&self,
&mut self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
@@ -298,26 +298,31 @@ impl Game<'_> {
// The text alpha that goes with getBackgroundOpacity(0.25):
// 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();
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
// background, blends.
if tag.discrete {
self.mark_translucent_entity(out.len(), "minecraft:name_tag");
self.name_tag_quads(o, &tag.lines, FADED, true, 0, vertex_light(tag.light), out);
let light = vertex_light(tag.light);
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 0, light, out));
continue;
}
// LightCoordsUtil.lightCoordsWithEmission(lightCoords, 2).
let lit = (tag.light >> 4).max(2) << 4 | (tag.light & 15).max(2);
self.mark_entity(out.len(), "minecraft:name_tag");
self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, vertex_light(lit), out);
let light = vertex_light(lit);
patches.extend(self.name_tag_quads(o, &tag.lines, 0xFF, false, 0, light, out));
// RenderPipelines.TEXT_SEE_THROUGH: no depth test, no lightmap.
self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through);
patches.extend(self.name_tag_quads(o, &tag.lines, FADED, true, 2, 0, see_through));
}
self.map_patches.extend(patches);
}
/// The lines of one name tag, `o` from the camera: the text at
/// `text_alpha` in its layer, on the translucent black background
/// (in `text_layer` like the glyphs, a hair behind) when `background`.
/// Returns the glyph pixels the atlas still needs.
#[allow(clippy::too_many_arguments)]
fn name_tag_quads(
&self,
@@ -328,7 +333,7 @@ impl Game<'_> {
text_layer: u32,
light: u32,
out: &mut Vec<Vertex>,
) {
) -> Vec<horizon_client::font::Patch> {
// Options.getBackgroundOpacity(0.25) black, as ARGB.color.
const BACKGROUND: u32 = 0x3F00_0000;
// The camera's facing (Camera.rotation()), turned round in third-person
@@ -342,85 +347,50 @@ impl Game<'_> {
let forward = glam::DVec3::from_array(view_vector(view_yaw, view_pitch));
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
let up = right.cross(forward).normalize_or_zero();
let white = self.gui.atlas.font.white();
let away = forward.as_vec3() * 0.002;
let mut batch = HudBatch::new([1, 1]);
for (text, lift) in lines {
let o = (o + glam::DVec3::new(0.0, *lift, 0.0)).as_vec3();
let (r, u) = (right.as_vec3() * 0.025, up.as_vec3() * 0.025);
// Text space: x right, y down (scale 0.025, -0.025).
let at = |x: f32, y: f32| o + r * x - u * y;
// Component.getVisualOrderText.
let shown = horizon_client::bidi::visual(text);
let chars: Vec<(char, u32)> = shown
.spans
.iter()
.flat_map(|s| {
let rgb = s.style.color.unwrap_or(0xFF_FF_FF);
// RGBA8, alpha in the top byte.
let color = text_alpha << 24
| ((rgb & 0xFF) << 16)
| (rgb & 0xFF00)
| ((rgb >> 16) & 0xFF);
s.text.chars().map(move |c| (c, color))
})
.collect();
let width: f32 = chars
.iter()
.map(|(c, _)| self.gui.atlas.glyph(*c).map_or(6.0, |g| g.1))
.sum();
let x0 = -(width / 2.0).trunc();
let y0 = if text.plain() == "deadmau5" {
-10.0
} else {
0.0
};
let mut quad = |a: [f32; 4], uv: [f32; 4], color: u32, behind: bool, layer: u32| {
let corners = [(a[0], a[3]), (a[2], a[3]), (a[2], a[1]), (a[0], a[1])];
let uvs = [
[uv[0], uv[3]],
[uv[2], uv[3]],
[uv[2], uv[1]],
[uv[0], uv[1]],
];
for ((x, y), uv) in corners.into_iter().zip(uvs) {
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 },
);
for (i, quad) in batch.vertices.as_chunks::<6>().0.iter().enumerate() {
let shift = if i * 6 < behind {
away
} else {
glam::Vec3::ZERO
};
// The corners from the bottom left, as the world draws quads.
for v in [quad[5], quad[2], quad[1], quad[0]] {
out.push(Vertex {
normal: [0; 4],
pos: (at(x, y) + if behind { away } else { glam::Vec3::ZERO }).to_array(),
uv,
layer,
color,
pos: (at(v.pos[0], v.pos[1]) + shift).to_array(),
uv: v.uv,
layer: text_layer,
color: v.color,
light,
glint_uv: [0.0, 0.0],
});
}
};
if background {
quad(
[x0 - 1.0, y0 - 1.0, x0 + width, y0 + 8.0],
[white.u0, white.v0, white.u1, white.v1],
BACKGROUND,
true,
text_layer,
);
}
let mut x = x0;
for (ch, color) in chars {
let Some((g, advance)) = self.gui.atlas.glyph(ch) else {
x += 6.0;
continue;
};
if ch != ' ' {
quad(
[x, y0, x + 8.0, y0 + 8.0],
[g.u0, g.v0, g.u1, g.v1],
color,
false,
text_layer,
);
}
x += advance;
}
}
// Glyphs rasterized on first use (Unifont's) upload with the frame.
batch.patches
}
}
+5
View File
@@ -353,6 +353,9 @@ impl Game<'_> {
let lang = Arc::new(horizon_client::net::load_language(&root));
let view = client_view_distance(self.settings.render_distance);
if local.is_none() {
// Only a single-player world gets an auto icon.
self.world_icon = None;
self.world_icon_attempt = None;
horizon_client::crash::set_detail(
"Server",
horizon_client::net::shown_address(address),
@@ -572,6 +575,8 @@ impl Game<'_> {
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
self.player = None;
self.world_icon = None;
self.world_icon_attempt = None;
self.mobs.clear();
self.clear_remote_objects();
self.clear_remote_projectiles();
+2 -4
View File
@@ -6,20 +6,19 @@ pub mod ambience;
pub mod animate_tick;
pub mod arrows;
pub use horizon_client_render::{
assets, atlas_sources, auth, bidi, biome, block_entities, block_entity_anim,
assets, atlas_sources, audio, auth, bidi, biome, block_entities, block_entity_anim,
block_entity_render, border_render, breaking, brewing, camera, chat, chunk, chunk_map, clouds,
collisions, command_block, containers, crash, dynamic_light, effects, environment, extras,
fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui, hud, hud_layout,
hud_overlays, inventory_effects, java_text, level_loading, light, lod, logging, menu_blur,
mesher, minimap, mipmap, mod_list, model_library, models, options_file, options_fixes,
options_ui, overlay, pack_screen, packs, par, particles, poi_sections, providers, screen_draw,
screen_effects, sculk, settings, shader_pack_list, signs, skins, sound_events, spans, sprites,
screen_effects, sculk, settings, shader_pack_list, signs, skins, sound_engine, sound_events, spans, sprites,
streaming, tab_list, telemetry, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
pub mod audio;
pub mod auto_jump;
pub mod banner;
pub mod beehive;
@@ -101,7 +100,6 @@ pub mod screen_layout;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod shadows;
pub mod sound_engine;
pub mod spear;
pub mod spectator_menu;
pub mod sprint;
+3 -2
View File
@@ -21,7 +21,8 @@ Every row has a one-line description and its cost in frame time (none, low, medi
| Quality | smooth lighting, mipmap levels, biome blend, clouds, cloud distance, weather radius, particles, entity shadows, cutout leaves | `options.txt` |
| | sky, fog, weather, animated textures | `horizon-graphics.txt` |
| Display | FOV, max framerate, VSync, fullscreen, GUI scale | `options.txt` |
| | resolution scale, anti-aliasing (FXAA), HDR, paper white, peak brightness, present mode, frame pacing | `horizon-graphics.txt` |
| | resolution scale, anti-aliasing (FXAA), present mode, frame pacing | `horizon-graphics.txt` |
| HDR (its own screen, from Display) | HDR, paper white, peak brightness | `horizon-graphics.txt` |
| Performance | battery mode, worker threads, upload budget per frame | `horizon-graphics.txt` |
| Overlay | performance overlay: FPS, frame/CPU/GPU times, frame-time graph | `horizon-graphics.txt` |
@@ -29,7 +30,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi
**Battery mode** (Performance, off by default) is for laptops and handhelds: at most 60 frames per second in a world, 30 in menus without a world or while another window has focus, and half the automatic worker threads (chunks load slower). It changes only when frames are drawn: what they show and the game's 20 ticks per second stay as they are.
**HDR** (Display, off by default) presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. KWin maps the content's reference white onto the desktop's SDR brightness, so on Linux the default Paper White matches SDR mode. `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain.
**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. KWin maps the content's reference white onto the desktop's SDR brightness, so on Linux the default Paper White matches SDR mode. `HORIZON_HDR_DEBUG=1` paints lifted pixels magenta. Not lifted yet: glowing particles, distant LOD terrain.
Which settings each screen shows, their descriptions and costs are in `crates/horizon-client-render/src/graphics_ui.rs` (`rows`); `graphics_style.rs` draws them.
+1 -1
View File
@@ -94,7 +94,7 @@ Every command `horizon-ctl` takes, its MCP tool in `horizon-ctl mcp` (the launch
| `respawn` | `respawn` | Respawn from the death screen | none |
| `pause` | `pause` | Open the pause menu | none |
| `resume` | `resume` | Leave the pause menu | none |
| `options [PAGE]` | `options` | Open the options screen, or one of its pages (Horizon's too) | `page` (main, video, sound, controls, keybinds, controller, controller-binds, mouse, chat, accessibility, language, font, packs, mods, shader-packs, graphics, graphics-advanced, extras, horizon, agent): the page (default: the main options) |
| `options [PAGE]` | `options` | Open the options screen, or one of its pages (Horizon's too) | `page` (main, video, sound, controls, keybinds, controller, controller-binds, mouse, chat, accessibility, language, font, packs, mods, shader-packs, graphics, graphics-advanced, graphics-hdr, extras, horizon, agent): the page (default: the main options) |
| `book [next\|back\|take\|done]` | `book` | The open book or lectern: its page text, or press one of its buttons | `action` (next, back, take, done): a button to press |
| `signedit X Y Z [front\|back]` | `signedit` | Open the sign editor of the sign at X Y Z (then type and close) | `x` (integer, required): block X<br>`y` (integer, required): block Y<br>`z` (integer, required): block Z<br>`side` (front, back): which side |
| `mount-menu` | `mount_menu` | Open the inventory of the horse, donkey, mule or llama being ridden | none |