Merge pull request #8684 from NikitaMGrimm/hdr-16-bit-frame

🤖 Agent: HDR off or Smooth Gradients off keeps main's frame format at every site (checked each one); format selection now in tested functions; fmt, clippy and the touched crates' tests pass on the desktop at b750f4d7e0.
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-11-04 03:40:38 +01:00
commit eaa7da4fb1
15 files changed
+410 -76

No files matched your search

@@ -64,6 +64,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
@@ -222,6 +222,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.graphics_api(),
settings.translucent_batching,
false,
false,
))?;
let state = &scene.renderer;
let [width, height] = o.size.unwrap_or(state.size);
@@ -121,6 +121,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
None,
GraphicsApi::Vulkan,
false,
false,
true,
))?;
renderer.resize(320, 180);
+173 -46
View File
@@ -1174,6 +1174,11 @@ pub struct Renderer {
emission_stats: hdr_stats::EmissionStats,
/// The HDR output the window's surface can present.
hdr_output: Option<hdr::Output>,
/// What every pipeline that draws the frame targets: the swapchain's 8-bit format, or
/// `Rgba16Unorm` when the renderer was made with HDR output on (`wide_frame`) and the
/// device draws and blends into it. A 16-bit frame with HDR off since reaches the
/// swapchain through `readback`.
frame_format: wgpu::TextureFormat,
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
@@ -1732,6 +1737,45 @@ fn surface_format(
.or_else(|| formats.iter().copied().find(|f| !f.is_srgb()))
.unwrap_or(default)
}
/// The 16-bit frame HDR output draws into where it can (`Renderer::frame_format`). Unorm
/// rather than float: it clips at white after every pass as the 8-bit frame does, so light
/// added past white (the sun on the sky) and then blended over (glass) looks the same.
const WIDE_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Unorm;
/// Whether a device with `features` can draw, blend and filter `WIDE_FRAME`, whose
/// format features (`format`) the adapter reports. Drawing into it is an adapter-specific
/// format feature, beyond the format itself.
fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeatures) -> bool {
features.contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) && format.allowed_usages.contains(
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
) && format.flags.contains(
wgpu::TextureFormatFeatureFlags::BLENDABLE | wgpu::TextureFormatFeatureFlags::FILTERABLE,
)
}
/// What a renderer's frame is drawn in: `WIDE_FRAME` when it is made with HDR on and Smooth
/// Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
/// (`hdr_offered`) and the device drawing it (`draws_wide_frame`): an 8-bit frame's steps band
/// in dark gradients, and HDR shows them. Else the swapchain's 8-bit format, as without HDR.
fn frame_format(
wide_frame: bool,
hdr_offered: bool,
device_draws_it: bool,
swapchain: wgpu::TextureFormat,
) -> wgpu::TextureFormat {
if wide_frame && hdr_offered && device_draws_it {
WIDE_FRAME
} else {
swapchain.remove_srgb_suffix()
}
}
/// Opens a device on the first of `backend_order`'s backends that gives one.
/// Offscreen rendering needs no surface, so it can use a GPU the window's
/// display cannot present from (a headless compositor).
@@ -2163,6 +2207,7 @@ impl Renderer {
previous: Option<&Self>,
api: GraphicsApi,
translucent_batching: bool,
wide_frame: bool,
shader_pack: bool,
) -> Result<Self> {
let Opened {
@@ -2280,6 +2325,12 @@ impl Renderer {
let offscreen = surface
.is_none()
.then(|| Self::offscreen_target(&device, &config));
let wide_ok = draws_wide_frame(
device.features(),
adapter.get_texture_format_features(WIDE_FRAME),
);
crate::graphics::offer_smooth_gradients(wide_ok);
let frame_format = frame_format(wide_frame, hdr_output.is_some(), wide_ok, config.format);
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
@@ -2443,7 +2494,7 @@ impl Renderer {
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
format: frame_format,
blend,
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -2635,7 +2686,7 @@ impl Renderer {
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
format: frame_format,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
@@ -2995,7 +3046,7 @@ impl Renderer {
let glint_armor_view = glint_armor_texture.create_view(&Default::default());
let mut hud = HudRenderer::new(
&device,
config.format.remove_srgb_suffix(),
frame_format,
&atlas_view,
&glint_view,
&glint_sampler,
@@ -3088,12 +3139,8 @@ impl Renderer {
},
)],
});
let weather = weather::Weather::new(
&device,
config.format.remove_srgb_suffix(),
&entity_bindings,
&entity_shader,
);
let weather =
weather::Weather::new(&device, frame_format, &entity_bindings, &entity_shader);
let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Atlas"),
..Default::default()
@@ -3297,7 +3344,7 @@ impl Renderer {
entry_point: Some(fragment),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
format: frame_format,
blend: Some(blend),
write_mask,
})],
@@ -3477,7 +3524,7 @@ impl Renderer {
entry_point: Some("fs_entity"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
format: frame_format,
// fs_entity's discard handles binary cutout alpha. Any
// sprite with fractional alpha stays on the blended
// dynamic entity path.
@@ -3569,7 +3616,7 @@ impl Renderer {
entry_point: Some("fs_celestial"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
format: frame_format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
@@ -3606,7 +3653,7 @@ impl Renderer {
// HDR output's mark on lightning bolts: fs_hdr_mark over the bolts' own vertex layout,
// as hdr_mark_pipeline marks entities and particles (only alpha written).
let mark_targets = [Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
format: frame_format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALPHA,
})];
@@ -3661,7 +3708,7 @@ impl Renderer {
entry_point: Some("fs_sky_celestial"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
format: frame_format,
// RenderPipelines.CELESTIAL: additive (src alpha, one), with
// the colour premultiplied in fs_sky_celestial. Its alpha is
// how much a texel adds: HDR output (hdr.wgsl) lets the sun's
@@ -3835,7 +3882,7 @@ impl Renderer {
entry_point: Some("fs_border"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
format: frame_format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
@@ -3913,7 +3960,7 @@ impl Renderer {
entry_point: Some(entry),
compilation_options,
targets: &[Some(wgpu::ColorTargetState {
format: config.format.remove_srgb_suffix(),
format: frame_format,
blend: Some(blend),
// 8 and up: colour only, for a tint of the world in HDR output.
write_mask: if i & 8 == 0 {
@@ -4064,13 +4111,7 @@ impl Renderer {
log::info!(
"Horizon: solid terrain through a visibility buffer (HORIZON_VISBUFFER)"
);
visbuffer::Visbuffer::new(
&device,
&bindings,
f,
config.format.remove_srgb_suffix(),
&sprite_constants,
)
visbuffer::Visbuffer::new(&device, &bindings, f, frame_format, &sprite_constants)
});
let multi_draw = device
.features()
@@ -4132,8 +4173,7 @@ impl Renderer {
_ => depth_test,
})
.map(|depth_test| {
let mut cull =
cull::Cull::new(&device, config.format.remove_srgb_suffix(), depth_test);
let mut cull = cull::Cull::new(&device, frame_format, depth_test);
cull.always = cull_env.as_deref() == Ok("all");
if !cull.always && adapter_info.backend == wgpu::Backend::Metal {
cull.min_sections = cull::TILED_MIN_SECTIONS;
@@ -4141,17 +4181,16 @@ impl Renderer {
cull
});
let depth = Self::depth(&device, &config);
let world_post = world_post::Post::new(&device, config.format.remove_srgb_suffix());
let world_post = world_post::Post::new(&device, frame_format);
let uploads = upload::Uploads::new(&device);
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
let stars = stars::Stars::new(&device, config.format.remove_srgb_suffix());
let stars = stars::Stars::new(&device, frame_format);
let entity_outline = entity_outline::EntityOutline::new(
&device,
(&entity_shader, &entity_layout, &entity_vertex),
config.format.remove_srgb_suffix(),
frame_format,
);
let panorama =
panorama::Panorama::new(&device, config.format.remove_srgb_suffix(), panorama_faces);
let panorama = panorama::Panorama::new(&device, frame_format, panorama_faces);
drop(pipelines_step);
reuse.finish(generation);
if previous.is_some() {
@@ -4372,6 +4411,7 @@ impl Renderer {
hdr: None,
emission_stats,
hdr_output,
frame_format,
hdr_marker: None,
pack_post: None,
native: None,
@@ -4471,7 +4511,7 @@ impl Renderer {
/// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block
/// outline, or the built-in one.
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
let format = self.config.format.remove_srgb_suffix();
let format = self.frame_format;
self.core_lines =
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
}
@@ -5826,7 +5866,7 @@ impl Renderer {
} else {
DisplayWatch::default()
};
let format = self.config.format.remove_srgb_suffix();
let format = self.frame_format;
self.hdr =
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
self.configure_surface();
@@ -5982,7 +6022,7 @@ impl Renderer {
&self.queue,
&self.adapter,
post,
self.config.format.remove_srgb_suffix(),
self.frame_format,
world,
)
});
@@ -6033,7 +6073,7 @@ impl Renderer {
globals: &self.terrain_layout,
flat: self.flat.as_ref().map(|f| &f.draw_layout),
sprites: self.terrain_sprites,
format: self.config.format.remove_srgb_suffix(),
format: self.frame_format,
sprite_tags: &sprite_tags,
sprite_materials: &sprite_materials,
entities: &self.entity_bindings,
@@ -6422,6 +6462,12 @@ impl Renderer {
let mut config = self.config.clone();
[config.width, config.height] = size;
config.usage |= wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
if self.frame_format != config.format.remove_srgb_suffix() {
// A 16-bit frame, read back through an 8-bit copy (`take_readback`).
config.format = self.frame_format;
config.view_formats = vec![self.frame_format];
config.usage |= wgpu::TextureUsages::TEXTURE_BINDING;
}
// Out of video memory (an invalid texture's view is a validation error):
// tried again at half the size next frame; the window keeps its last one.
let checks = GpuChecks::push(&self.device);
@@ -6523,10 +6569,11 @@ impl Renderer {
laps.lap("r_prepare");
// Frame generation: its present thread acquires and presents, this frame draws
// into frames of its own. Not over menus that blur the frame, a shader pack's
// own post passes, HDR or the title screen.
// own post passes, HDR (or the 16-bit frame it was started with) or the title screen.
let fg = self.frame_gen.on
&& !self.frame_gen.paused
&& self.hdr.is_none()
&& self.frame_format == self.config.format.remove_srgb_suffix()
&& !frame.title
&& frame.menu_blur.is_none()
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
@@ -6602,11 +6649,16 @@ impl Renderer {
let target_texture = match (&surface_frame, &mut self.hdr) {
_ if let Some((real, _)) = &fg_frames => real.clone(),
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
// A screenshot of a swapchain without COPY_SRC (OpenGL) draws
// into a texture that has it, then onto the swapchain.
// A screenshot of a swapchain without COPY_SRC (OpenGL), and a 16-bit frame
// with HDR off, draw into a texture of their own, then onto the swapchain.
(Some(f), None) => self
.readback
.target(&self.device, &f.texture, self.screenshot.is_some())
.target(
&self.device,
&f.texture,
self.frame_format,
self.screenshot.is_some(),
)
.unwrap_or_else(|| f.texture.clone()),
(None, _) => self.offscreen.clone().ok_or("no offscreen target")?,
};
@@ -8168,7 +8220,7 @@ impl Renderer {
self.drawn_sections += gpu.records.live();
}
let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(self.config.format.remove_srgb_suffix()),
format: Some(self.frame_format),
..Default::default()
});
if let Some(timer) = &mut self.gpu_timer {
@@ -8274,7 +8326,7 @@ impl Renderer {
let (device, layout, sprites) =
(&self.device, &self.terrain_layout, self.terrain_sprites);
let clouds = [0, 1].map(|i| self.cloud_pipeline.get_bind_group_layout(i));
let format = self.config.format.remove_srgb_suffix();
let format = self.frame_format;
let flat = self
.flat
.as_ref()
@@ -8451,7 +8503,7 @@ impl Renderer {
&& let Some(sky) = &native.sky
{
if let Some(t) = &mut native.timing {
let format = self.config.format.remove_srgb_suffix();
let format = self.frame_format;
t.mark(&self.device, &mut encoder, (world_view, format));
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
@@ -8514,7 +8566,7 @@ impl Renderer {
size,
color: target(
"Water mirror",
self.config.format.remove_srgb_suffix(),
self.frame_format,
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
),
@@ -9737,7 +9789,7 @@ impl Renderer {
encoder: &mut encoder,
color: &target_texture,
color_view: &view_target,
color_format: self.config.format.remove_srgb_suffix(),
color_format: self.frame_format,
size,
depth,
depth_size,
@@ -9777,7 +9829,7 @@ impl Renderer {
timer.end(
&self.device,
&mut encoder,
(&view_target, self.config.format.remove_srgb_suffix()),
(&view_target, self.frame_format),
);
}
laps.lap("r_passes");
@@ -10075,7 +10127,7 @@ impl Renderer {
encoder: &mut *encoder,
color: target_texture,
color_view: view_target,
color_format: self.config.format.remove_srgb_suffix(),
color_format: self.frame_format,
size: [self.config.width, self.config.height],
hand: if overlay.hand_drawn { frame.hand } else { &[] },
view_proj: overlay.hand_view_proj,
@@ -10245,7 +10297,7 @@ impl Renderer {
timing.as_mut(),
);
if let Some(radius) = menu_blur {
let format = self.config.format;
let format = self.frame_format;
let device = &self.device;
let software = is_software(self.info.device_type);
self.menu_blur
@@ -10396,6 +10448,15 @@ impl Renderer {
// the row stride too small for the copy and abort in validation.
if self.config.format.block_copy_size(None) == Some(4) {
let size = [self.config.width, self.config.height];
// The 16-bit frame is read through an 8-bit copy in the swapchain's format.
let copy = (texture.format() != self.config.format.remove_srgb_suffix()
&& texture.format() == self.frame_format)
.then(|| {
let format = self.config.format.remove_srgb_suffix();
self.readback
.unorm_copy(&self.device, encoder, texture, format)
});
let texture = copy.as_ref().unwrap_or(texture);
Some((request, copy_frame(&self.device, encoder, texture, size)))
} else {
self.screenshot_result = Some(Err(format!(
@@ -11026,6 +11087,72 @@ mod tests {
];
assert_eq!(surface_format(&without_bgra, Rgba8UnormSrgb), Rgba8Unorm);
}
/// The 16-bit frame only with HDR on, Smooth Gradients on, an HDR surface and a device
/// that draws it; in every other case the swapchain's 8-bit format, as before it existed.
#[test]
fn the_frame_is_16_bit_only_with_hdr_and_a_device_that_draws_it() {
use super::frame_format;
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm, Rgba16Unorm};
assert_eq!(frame_format(true, true, true, Bgra8Unorm), Rgba16Unorm);
for (wide_frame, hdr_offered, draws) in [
(false, true, true),
(true, false, true),
(true, true, false),
(false, false, false),
] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb),
Bgra8Unorm
);
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm),
Rgba8Unorm
);
}
}
/// What `draws_wide_frame` needs of a device: both features, and the usages and flags
/// every pass takes of the frame. Missing any one of them keeps the 8-bit frame.
#[test]
fn the_16_bit_frame_needs_every_usage_and_flag_the_passes_take() {
use super::draws_wide_frame;
use wgpu::{Features as F, TextureFormatFeatureFlags as Fl, TextureUsages as U};
let features = F::TEXTURE_FORMAT_16BIT_NORM | F::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
let usages = U::RENDER_ATTACHMENT | U::TEXTURE_BINDING | U::COPY_SRC | U::COPY_DST;
let flags = Fl::BLENDABLE | Fl::FILTERABLE;
let format = |allowed_usages, flags| wgpu::TextureFormatFeatures {
allowed_usages,
flags,
};
// A desktop GPU: all of it, and more.
assert!(draws_wide_frame(
features | F::FLOAT32_FILTERABLE,
format(usages | U::STORAGE_BINDING, flags | Fl::MULTISAMPLE_X4),
));
assert!(!draws_wide_frame(
F::TEXTURE_FORMAT_16BIT_NORM,
format(usages, flags)
));
assert!(!draws_wide_frame(
F::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
format(usages, flags)
));
// A downlevel device reports no usages at all for a format it lacks.
assert!(!draws_wide_frame(
F::empty(),
format(U::empty(), Fl::empty())
));
for usage in [
U::RENDER_ATTACHMENT,
U::TEXTURE_BINDING,
U::COPY_SRC,
U::COPY_DST,
] {
assert!(!draws_wide_frame(features, format(usages - usage, flags)));
}
for flag in [Fl::BLENDABLE, Fl::FILTERABLE] {
assert!(!draws_wide_frame(features, format(usages, flags - flag)));
}
}
#[test]
fn special_material_batches_preserve_quad_order_and_offsets() {
let quad = |layer, id| {
+7 -5
View File
@@ -1,8 +1,10 @@
//! HDR output (Horizon setting, off by default): the frame renders exactly as
//! without it, into an 8-bit target in vanilla's gamma space, and one pass
//! (`hdr.wgsl`) writes it to an HDR10 or scRGB swapchain at the player's paper
//! white, expanding the world's highlights towards their peak brightness. The
//! world is copied aside before the hand and HUD so only it is expanded.
//! without it, in vanilla's gamma space, into a 16-bit target when HDR output
//! was on when the renderer was made, else an 8-bit one
//! (`Renderer::frame_format`), and one pass (`hdr.wgsl`) writes it to an HDR10
//! or scRGB swapchain at the player's paper white, expanding the world's
//! highlights towards their peak brightness. The world is copied aside before
//! the hand and HUD so only it is expanded.
/// The swapchain HDR output presents to.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -301,7 +303,7 @@ impl Hdr {
}
}
/// The 8-bit frame every pass draws into this frame, sized like the window.
/// The frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
let texture = |label, usage| {
+118 -16
View File
@@ -1,7 +1,8 @@
//! Screenshots of a swapchain that cannot be copied from. OpenGL's surfaces
//! offer `RENDER_ATTACHMENT` only, so a frame whose pixels are read back
//! (the screenshot key, a world's icon) is drawn into a texture that can be,
//! then drawn onto the swapchain image as a fullscreen triangle.
//! then drawn onto the swapchain image as a fullscreen triangle. A 16-bit frame
//! (`Renderer::frame_format`) with HDR output off goes the same way every frame.
/// The texture a frame to read back is drawn into, and the pass that puts it
/// on the swapchain afterwards.
@@ -15,18 +16,21 @@ pub struct Readback {
impl Readback {
/// The texture this frame draws into instead of the swapchain image
/// `surface`: `None` unless the frame is read back (`screenshot`) and the
/// image cannot be copied from.
/// `surface`, in the frame's `format`: `None` unless that differs from the
/// swapchain's, or the frame is read back (`screenshot`) and the image
/// cannot be copied from.
pub fn target(
&mut self,
device: &wgpu::Device,
surface: &wgpu::Texture,
format: wgpu::TextureFormat,
screenshot: bool,
) -> Option<wgpu::Texture> {
if !screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC) {
if format == surface.format().remove_srgb_suffix()
&& (!screenshot || surface.usage().contains(wgpu::TextureUsages::COPY_SRC))
{
return None;
}
let format = surface.format().remove_srgb_suffix();
if self
.texture
.as_ref()
@@ -60,22 +64,57 @@ impl Readback {
if !std::mem::take(&mut self.drawn) {
return;
}
let Some(texture) = &self.texture else {
let Some(texture) = self.texture.clone() else {
return;
};
// The frame's passes write the swapchain through its non-sRGB view
// (`view_formats`), so the copy goes there unconverted too.
let format = surface.format().remove_srgb_suffix();
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
}
let target = surface.create_view(&wgpu::TextureViewDescriptor {
format: Some(format),
..Default::default()
});
let source = texture.create_view(&Default::default());
self.blit(device, encoder, &texture, &target, format);
}
/// `frame` (a 16-bit frame) in an 8-bit texture of `format`, clamped to 0..1
/// as an 8-bit frame holds it, for a screenshot to read back.
pub fn unorm_copy(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
frame: &wgpu::Texture,
format: wgpu::TextureFormat,
) -> wgpu::Texture {
let copy = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon screenshot copy"),
size: frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let target = copy.create_view(&Default::default());
self.blit(device, encoder, frame, &target, format);
copy
}
fn blit(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
source: &wgpu::Texture,
target: &wgpu::TextureView,
format: wgpu::TextureFormat,
) {
if self.blitter.as_ref().is_none_or(|b| b.0 != format) {
self.blitter = Some((format, wgpu::util::TextureBlitter::new(device, format)));
}
let source = source.create_view(&Default::default());
if let Some((_, blitter)) = &self.blitter {
blitter.copy(device, encoder, &source, &target);
blitter.copy(device, encoder, &source, target);
}
}
}
@@ -84,6 +123,8 @@ impl Readback {
mod tests {
use super::*;
const F: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8Unorm;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
@@ -101,7 +142,7 @@ mod tests {
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Bgra8Unorm,
format: F,
usage,
view_formats: &[],
})
@@ -156,7 +197,7 @@ mod tests {
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let blitter = wgpu::util::TextureBlitter::new(&device, wgpu::TextureFormat::Bgra8Unorm);
let blitter = wgpu::util::TextureBlitter::new(&device, F);
let mut readback = Readback::default();
let colors = [
(1.0, [0, 0, 255, 255]),
@@ -173,7 +214,7 @@ mod tests {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let frame = readback
.target(&device, &surface, true)
.target(&device, &surface, F, true)
.unwrap_or_else(|| surface.clone());
clear(&mut encoder, &frame, color);
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &frame, [70, 3]);
@@ -205,9 +246,70 @@ mod tests {
&device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
assert!(readback.target(&device, &readable, true).is_none());
assert!(readback.target(&device, &readable, F, true).is_none());
let surface = texture(&device, wgpu::TextureUsages::RENDER_ATTACHMENT);
assert!(readback.target(&device, &surface, false).is_none());
assert!(readback.target(&device, &surface, F, false).is_none());
assert!(readback.texture.is_none() && !readback.drawn);
}
/// A 16-bit frame with HDR output off draws into a 16-bit texture of its own, and both
/// the swapchain and a screenshot's 8-bit copy get it clamped as an 8-bit frame holds it.
fn reaches_an_8_bit_swapchain(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
color: wgpu::Color,
expected: [u8; 4],
) {
let surface = texture(
device,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let mut readback = Readback::default();
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let mut encoder = device.create_command_encoder(&Default::default());
let frame = readback.target(device, &surface, format, false).unwrap();
assert_eq!(frame.format(), format);
clear(&mut encoder, &frame, color);
let copy = readback.unorm_copy(device, &mut encoder, &frame, F);
let screenshot = crate::gfx::copy_frame(device, &mut encoder, &copy, [70, 3]);
readback.present(device, &mut encoder, &surface);
let presented = crate::gfx::copy_frame(device, &mut encoder, &surface, [70, 3]);
queue.submit([encoder.finish()]);
if let Some(error) = pollster::block_on(scope.pop()) {
panic!("{error}");
}
assert_eq!(last_pixel(device, &screenshot), expected);
assert_eq!(last_pixel(device, &presented), expected);
}
/// `Rgba16Unorm`, the frame HDR output draws into where the device can
/// (`Renderer::frame_format`).
#[test]
fn a_16_bit_unorm_frame_reaches_an_8_bit_swapchain() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let wide = wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
if !adapter.features().contains(wide) {
return;
}
let descriptor = wgpu::DeviceDescriptor {
required_features: wide,
..Default::default()
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&descriptor)) else {
return;
};
let color = wgpu::Color {
r: 1.0,
g: 0.2,
b: 0.0,
a: 1.0,
};
let unorm = wgpu::TextureFormat::Rgba16Unorm;
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
}
}
+30 -1
View File
@@ -521,6 +521,17 @@ pub fn translucent_batching_offered() -> bool {
TRANSLUCENT_BATCHING_OFFERED.load(std::sync::atomic::Ordering::Relaxed)
}
static SMOOTH_GRADIENTS_OFFERED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
/// Whether the device can draw, blend and filter the 16-bit frame (`Graphics::hdr_smooth_gradients`):
/// set when the renderer starts. Where it can't, the settings grey its switch out.
pub fn offer_smooth_gradients(on: bool) {
SMOOTH_GRADIENTS_OFFERED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn smooth_gradients_offered() -> bool {
SMOOTH_GRADIENTS_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.
@@ -684,6 +695,11 @@ pub struct Graphics {
/// `batchTranslucent`: `translucentBatching`, which every save wrote as false while it was
/// off by default and had no switch, stays ignored.
pub translucent_batching: bool,
/// Smooth Gradients (HDR page, on by default): with HDR output on, the frame is drawn in
/// 16 bits, so dark gradients don't band (`Renderer::frame_format`). Read when a renderer
/// is made (start, resource-pack reload), like `translucent_batching`, where it is offered
/// (`smooth_gradients_offered`).
pub hdr_smooth_gradients: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
@@ -736,6 +752,7 @@ impl Default for Graphics {
shadow_cache: false,
api: GraphicsApi::Auto,
translucent_batching: true,
hdr_smooth_gradients: true,
shader_pack: String::new(),
}
}
@@ -867,6 +884,7 @@ impl Graphics {
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
"hdrSmoothGradients" => g.hdr_smooth_gradients = flag(g.hdr_smooth_gradients),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -883,7 +901,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nparticleCulling:{}\nleafCulling:{}\nblockEntityCacheShulkerBoxes:{}\nblockEntityCacheSigns:{}\nblockEntityCacheDecoratedPots:{}\nblockEntityCacheBells:{}\nblockEntityCacheChests:{}\nblockEntityCacheBanners:{}\nblockEntityCacheCopperGolemStatues:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\nbatchTranslucent:{}\nhdrSmoothGradients:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -934,6 +952,7 @@ impl Graphics {
self.api.name(),
self.shader_pack,
bool_text(self.translucent_batching),
bool_text(self.hdr_smooth_gradients),
);
text += &self.blue_map.serialize();
text
@@ -949,6 +968,11 @@ impl Graphics {
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
/// Whether a renderer made now draws its frame in 16 bits (`Renderer::new`'s `wide_frame`):
/// HDR on, with Smooth Gradients.
pub fn wide_frame(&self) -> bool {
self.hdr && self.hdr_smooth_gradients
}
/// Paper white, in nits: the player's, or on Auto the desktop's SDR white as the
/// system reports it, else 203 (BT.2408's reference white).
pub fn paper_white_nits(&self) -> u32 {
@@ -1105,6 +1129,7 @@ mod tests {
shadow_cache: true,
api: GraphicsApi::Dx12,
translucent_batching: false,
hdr_smooth_gradients: false,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
@@ -1146,6 +1171,10 @@ mod tests {
// off by default, not a choice.
assert!(Graphics::default().translucent_batching);
assert!(!Graphics::parse("batchTranslucent:false\n").translucent_batching);
// Smooth Gradients is on, and draws a 16-bit frame only with HDR on.
assert!(Graphics::default().hdr_smooth_gradients && !Graphics::default().wide_frame());
assert!(Graphics::parse("hdr:true\n").wide_frame());
assert!(!Graphics::parse("hdr:true\nhdrSmoothGradients:false\n").wide_frame());
assert!(Graphics::parse("translucentBatching:false\n").translucent_batching);
// 0 is Auto; anything else is a peak the slider reaches.
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
@@ -104,6 +104,7 @@ pub enum H {
ShadowCache,
GraphicsApi,
TranslucentBatching,
HdrSmoothGradients,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -496,6 +497,11 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
None,
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
),
h(
H::HdrSmoothGradients,
Low,
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.",
),
h(
H::HdrCalibrate,
None,
@@ -824,12 +830,16 @@ impl H {
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
// Greyed out where the device can't (`Item::blocked`): Off, as it is there,
// while the saved value waits for a device that can.
// These two are greyed out where the device can't (`Item::blocked`): Off, as
// they are there, while the saved value waits for a device that can.
H::TranslucentBatching => labelled(
"Translucent Batching",
on_off(g.translucent_batching && crate::graphics::translucent_batching_offered()),
),
H::HdrSmoothGradients => labelled(
"Smooth Gradients",
on_off(g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()),
),
H::Overlay => labelled(
"Performance Overlay",
match g.overlay {
@@ -942,6 +952,7 @@ impl H {
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
H::TranslucentBatching => g.translucent_batching = !g.translucent_batching,
H::HdrSmoothGradients => g.hdr_smooth_gradients = !g.hdr_smooth_gradients,
H::Pacing => {
g.pacing = match g.pacing {
Pacing::Efficient => Pacing::Precise,
@@ -973,7 +984,8 @@ impl Item {
/// 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; Translucent Batching switches only where the device can batch
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can.
/// (`graphics::translucent_batching_offered`), its saved value waiting for one that can,
/// and Smooth Gradients only where it can draw the 16-bit frame, the same way.
fn blocked(self, s: &Settings) -> Option<&'static str> {
if !self.available() {
return Some("This feature is not yet available in Horizon.");
@@ -985,16 +997,23 @@ impl Item {
| H::HdrBlackLevel
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness,
| H::HdrUiBrightness
| H::HdrSmoothGradients,
) if !crate::graphics::hdr_offered() => {
Some("Your display or system doesn't offer HDR output.")
}
Item::Horizon(H::HdrSmoothGradients)
if !crate::graphics::smooth_gradients_offered() =>
{
Some("Your GPU or graphics API doesn't support it.")
}
Item::Horizon(
H::HdrCalibrate
| H::HdrBlackLevel
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness,
| H::HdrUiBrightness
| H::HdrSmoothGradients,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
@@ -1032,6 +1051,9 @@ impl Item {
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
Item::Horizon(H::HdrSmoothGradients) => {
g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()
}
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
Item::Vanilla(Opt::Vsync) => s.vsync,
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
@@ -1098,6 +1120,7 @@ impl Item {
| H::ShadowCache
| H::GraphicsApi
| H::TranslucentBatching
| H::HdrSmoothGradients
) | Item::Vanilla(
Opt::GuiScale
| Opt::Fullscreen
@@ -2613,6 +2636,31 @@ mod tests {
assert!(!ui.release());
}
#[test]
fn smooth_gradients_wait_for_a_device_that_draws_the_16_bit_frame() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
crate::graphics::offer_hdr(true);
let mut s = Settings::default();
s.horizon.hdr = true;
let item = Item::Horizon(H::HdrSmoothGradients);
// Where the device can't, the saved On reads Off.
crate::graphics::offer_smooth_gradients(false);
assert_eq!(
item.blocked(&s),
Some("Your GPU or graphics API doesn't support it.")
);
assert_eq!(item.label(&s).plain(), "Smooth Gradients: Off");
assert_eq!(item.switch(&s), Some(false));
assert!(s.horizon.hdr_smooth_gradients);
// Where it can, it waits only for HDR, like the rest of the page.
crate::graphics::offer_smooth_gradients(true);
assert_eq!(item.blocked(&s), None);
assert_eq!(item.label(&s).plain(), "Smooth Gradients: On");
assert_eq!(item.switch(&s), Some(true));
s.horizon.hdr = false;
assert_eq!(item.blocked(&s), Some("Turn HDR on to use it."));
}
#[test]
fn hdr_waits_for_a_display_that_offers_it_and_its_sliders_for_the_switch() {
let _offer = HDR_OFFER.lock().unwrap_or_else(|e| e.into_inner());
+10 -2
View File
@@ -58,6 +58,14 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
}
// A texel with its alpha as an 8-bit frame holds it. A 16-bit frame
// (Renderer::frame_format) keeps alpha between 8-bit steps, where the marks below
// would read differently (an alpha of 0.001 would count as the hand); its colour
// keeps the gradients 8 bits would band. On an 8-bit frame nothing changes.
fn frame_texel(v: vec4<f32>) -> vec4<f32> {
return vec4(saturate(v.rgb), round(saturate(v.a) * 255.0) / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
const BT709_TO_BT2020 = mat3x3<f32>(
vec3(0.6274, 0.0691, 0.0164),
@@ -152,7 +160,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
}
return vec4(nits / 80.0, 1.0);
}
let f = textureLoad(frame, at, 0);
let f = frame_texel(textureLoad(frame, at, 0));
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
// 1/255 (Hdr::mark_hand: drawn at the world's brightness), else the HUD or a
@@ -162,7 +170,7 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
let ui = select(f.a, 0.0, hand);
// Gamma 2.2, as SDR displays and the compositors' SDR paths decode it.
var light = pow(max(c, vec3(0.0)), vec3(2.2));
let w = textureLoad(world, at, 0);
let w = frame_texel(textureLoad(world, at, 0));
// The world's alpha is below 0.75 on light sources, the sun and the moon
// (terrain.wgsl, entity.wgsl): only those shine, and only where nothing was
// drawn over them since: grey text over the white sun still looks like the sun
@@ -114,6 +114,8 @@
"Peak Brightness": "Peak Brightness",
"Black Level": "Black Level",
"UI Brightness": "UI Brightness",
"Smooth Gradients": "Smooth Gradients",
"16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.": "16-bit frame: dark skies and shadows don't band. A bit more VRAM. Next start.",
"Battery Mode": "Battery Mode",
"Graphics API": "Graphics API",
"Translucent Batching": "Translucent Batching",
+9
View File
@@ -14221,6 +14221,7 @@ impl Game<'_> {
None,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
)?;
renderer.screenshot = screenshot;
@@ -14257,6 +14258,7 @@ impl Game<'_> {
None,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
)
}
@@ -20466,6 +20468,9 @@ fn build_renderer(
prepared: Option<horizon_client::gfx::PreparedTextures>,
api: horizon_client::graphics::GraphicsApi,
translucent_batching: bool,
// HDR with Smooth Gradients (`Graphics::wide_frame`): a 16-bit frame where HDR output is
// offered (`Renderer::new`).
wide_frame: bool,
shader_pack: bool,
) -> Result<Renderer> {
let assets = &pack.render;
@@ -20503,6 +20508,7 @@ fn build_renderer(
previous,
api,
translucent_batching,
wide_frame,
shader_pack,
))?;
renderer.hud.sprite_animations = atlas
@@ -20570,6 +20576,8 @@ fn loading_renderer(
api,
// It draws no terrain.
false,
// An 8-bit frame, which HDR output takes as well.
false,
false,
))?;
if let Some(atlas) = atlas {
@@ -21151,6 +21159,7 @@ impl Loading {
loaded.renderer_cpu,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
)
})?;
+1
View File
@@ -355,6 +355,7 @@ impl Game<'_> {
Some(textures),
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
super::shader_packs::picked_pack_draws_flat(&self.settings),
)
})
+2
View File
@@ -403,6 +403,7 @@ impl<'a> Split<'a> {
None,
api,
batching,
self.host.settings.horizon.wide_frame(),
shader_pack,
);
let renderer = match built {
@@ -480,6 +481,7 @@ impl<'a> Split<'a> {
None,
api,
batching,
self.host.settings.horizon.wide_frame(),
shader_pack,
);
let renderer = match built {
@@ -473,6 +473,7 @@ impl Split<'_> {
None,
host.settings.horizon.api,
host.settings.horizon.translucent_batching,
host.settings.horizon.wide_frame(),
// The pack its own game loads, from its profile.
crate::game::shader_packs::picked_pack_draws_flat(&guest.settings),
)?);
+1 -1
View File
@@ -42,7 +42,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
**Translucent batching** (Advanced, after Graphics API; on by default, `batchTranslucent` in `horizon-graphics.txt`, a new key so that the `translucentBatching:false` earlier builds saved for everyone doesn't keep it off; it applies at the next start or resource reload, and is no part of a preset) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same and frames come faster, most over water and at high render distances (RTX 4090 at 1080p: 4 to 16% more FPS at render distance 32; over the ocean at 64, 354 draw calls a frame instead of 3,832 and 43% more FPS); it costs a little video memory, as solid and translucent quads no longer share free space (terrain meshes take 1 to 15% more at render distance 32, 5 to 10% more at 64). The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it: there the switch is greyed out and reads Off, and macOS doesn't show it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
**HDR** (off by default) has its own page, HDR, greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); 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. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the HDR page asks the player to calibrate. **Black Level** (0 is Auto: what the system reports, else an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, and others that honour its ICC profile (reference white at the profile's white) a correct SDR picture with clipped highlights. Glowing items shine by their block's tier when dropped, framed, or held by anyone, your own hand included; the hand itself stays at the world's brightness. Conduits, the warden's glowing layers and light sources in distant LOD terrain shine too. Portals don't: they're the look of another place, the same in any light. The screen shows these as plain numbers: they are nits onlLine truncated
**HDR** (off by default) has its own page, HDR, greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); 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 (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start), 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. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the HDR page asks the player to calibrate. **Black Level** (0 is Auto: what the system reports, else an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, and others that honour its ICC profile (referencLine truncated
HDR also lights the world past paper white: surfaces at block or sky light levels 11 to 15 rise above it, up to 4 times for the strongest light, sky light by how a face meets the sun, easing into the peak from 60% of it so bright textures keep their detail. The frame's alpha between 0.76 and 1 carries it to the last pass, where it amounts to the lightmap going past 1. Darker light stays as in SDR. It's off while a shader pack's post or material stage runs, and `HORIZON_HDR_LIGHTING=0` turns it off to compare.