Options > Horizon Settings > Shader Packs: pick a pack of either kind

The page lists the packs in shaderpacks/: Horizon's own, marked (Horizon) and
named by their manifest, then packs in the shaders/ format. One runs at a
time: clicking a pack picks it, clicking the picked one turns it off, and
Open Pack Folder opens the folder. The pick is saved in horizon-graphics.txt
under a new key (pickedShaderPack), so the shaderPack value older versions
saved stays ignored. A new pick drops the running pack and loads the new one
in the next frame; HORIZON_SHADER_PACK still wins, for development.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-26 06:18:24 +01:00
parent c3763ff1b3
commit 70b832d658
14 changed files with 281 additions and 37 deletions
@@ -143,6 +143,7 @@ const PAGES: Ty = Ty::Enum(&[
"font",
"packs",
"mods",
"shader-packs",
"graphics",
"graphics-advanced",
"extras",
@@ -612,6 +612,7 @@ impl Game<'_> {
"font" => Page::Font,
"packs" => Page::Packs,
"mods" => Page::Mods,
"shader-packs" => Page::ShaderPacks,
"graphics" => Page::Graphics,
"graphics-advanced" => Page::GraphicsAdvanced,
"extras" => Page::Extras,
@@ -625,6 +626,10 @@ impl Game<'_> {
}
self.open_options(self.title.is_some());
self.ui.options.show(page);
if page == Page::ShaderPacks {
self.settings.shader_packs =
horizon_client::shader_pack_list::scan(&Self::shader_packs_dir());
}
ok()
}
"chat-log" => {
@@ -1,7 +1,6 @@
//! Horizon's own shader packs in the game (docs/shaders.md), for development until the Shader
//! Packs page exists: `HORIZON_SHADER_PACK=<name>` naming one in `shaderpacks/` (a folder with
//! `horizon-shaders.toml`, or a `.hshader`) runs it once it passes `horizon_shader_api::check`.
//! The pack is looked at twice a second: a saved file is checked and the pack rebuilt, and a
//! Horizon's own shader packs in the game (docs/shaders.md): the pick in `shaderpacks/` (a
//! folder with `horizon-shaders.toml`, or a `.hshader`; `game/shader_packs.rs`) runs once it
//! passes `horizon_shader_api::check`. The pack is looked at twice a second: a saved file is checked and the pack rebuilt, and a
//! pack that fails is refused with its problems in the chat while the last good one keeps
//! drawing.
@@ -21,11 +20,22 @@ const RED: u32 = 0xFF5555;
#[derive(Default)]
pub(super) struct NativeShaders {
/// The pack's name in `shaderpacks/`, of either kind (`game/shader_packs.rs`); empty: none.
pub picked: String,
/// The pack's files when it was last read (`fingerprint`).
stamp: Option<u64>,
looked: Option<Instant>,
}
impl NativeShaders {
pub fn picking(name: &str) -> Self {
Self {
picked: name.to_owned(),
..Self::default()
}
}
}
impl Game<'_> {
/// Builds the pack at `path` when its files changed since it was last read.
pub(super) fn tick_native_shader_pack(&mut self, path: &Path) {
+15
View File
@@ -85,6 +85,10 @@ impl Game<'_> {
.unwrap_or_default()
.join("options.txt")
}
/// `shaderpacks/` beside `options.txt`.
pub(super) fn shader_packs_dir() -> std::path::PathBuf {
Self::options_path().with_file_name(horizon_client::shader_pack_list::DIR)
}
pub(super) fn gui_size(&self) -> (f32, f32) {
let size = self
.renderer
@@ -237,6 +241,17 @@ impl Game<'_> {
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::ListShaderPacks => {
self.settings.shader_packs =
horizon_client::shader_pack_list::scan(&Self::shader_packs_dir());
}
// Saved; the next frame's `shader_pack_inputs` loads the pick.
Event::ShaderPack => self.settings.save(&Self::options_path()),
Event::OpenShaderPackFolder => {
let dir = Self::shader_packs_dir();
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::ModSettings(i) => self.open_mod_screen(i, true),
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
}
+21 -11
View File
@@ -1,7 +1,8 @@
//! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=<name>`
//! runs `shaderpacks/<name>` (a folder or a zip beside `options.txt`) over the world; Horizon's
//! own packs go to `game/native_shaders.rs`. The
//! programs of the current dimension's folder are loaded when the dimension changes (spec 1.2).
//! Shader packs: the one picked on Options > Horizon Settings > Shader Packs, or
//! `HORIZON_SHADER_PACK=<name>` (development; it wins over the pick), runs `shaderpacks/<name>`
//! (a folder or a zip beside `options.txt`) over the world; Horizon's own packs go to
//! `game/native_shaders.rs`. The programs of the current dimension's folder are loaded when the
//! dimension changes (spec 1.2).
use horizon_client::camera::CameraFluid;
use horizon_client::gfx::pack_uniforms::PackInputs;
@@ -34,25 +35,34 @@ impl Game<'_> {
/// Loads the pack for the current dimension when it changed, and returns this frame's
/// inputs to the pack's uniforms.
pub(super) fn shader_pack_inputs(&mut self, camera: glam::DVec3, partial: f32) -> PackInputs {
let Some(name) = std::env::var_os("HORIZON_SHADER_PACK") else {
static DEV_PACK: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
let dev = DEV_PACK.get_or_init(|| std::env::var("HORIZON_SHADER_PACK").ok());
let picked = dev.as_deref().unwrap_or(&self.settings.horizon.shader_pack);
if self.native_shaders.picked != picked {
// Another pack, or none: drop what ran.
self.native_shaders = super::native_shaders::NativeShaders::picking(picked);
self.pack_ids = None;
if let Some(renderer) = &mut self.renderer {
renderer.drop_shader_packs();
}
}
if picked.is_empty() {
return PackInputs::default();
};
}
let name = picked.to_owned();
let dimension = match self.dim {
Dim::Overworld => 0,
Dim::Nether => -1,
Dim::End => 1,
};
let path = std::env::current_dir()
.unwrap_or_default()
.join("shaderpacks")
.join(&name);
let path = Self::shader_packs_dir().join(&name);
if horizon_shader_api::package::is_pack(&path) {
// One of Horizon's own packs (game/native_shaders.rs).
self.tick_native_shader_pack(&path);
} else if let Some(renderer) = &mut self.renderer
&& renderer.shader_pack_dimension() != Some(dimension)
{
let name = name.to_string_lossy();
let name = name.as_str();
let started = std::time::Instant::now();
match Pack::open(&path) {
Ok(pack) => {
@@ -366,6 +366,16 @@ impl Loading {
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::ListShaderPacks => {
self.settings.shader_packs =
horizon_client::shader_pack_list::scan(&Game::shader_packs_dir());
}
Event::ShaderPack => self.save_menu_settings(),
Event::OpenShaderPackFolder => {
let dir = Game::shader_packs_dir();
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::Credits => {
if let Some(gui) = &self.gui {
let name = self.options.as_ref().map_or_else(
+6
View File
@@ -2928,6 +2928,12 @@ impl Renderer {
}
report
}
/// Drops the running shader pack, of either kind.
pub fn drop_shader_packs(&mut self) {
self.pack_post = None;
self.native_post = None;
self.pack_atlas = None;
}
/// Runs the post passes of one of Horizon's own shader packs over the world from now on
/// (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
/// passes' labels; on an error the passes that ran keep running.
+9 -1
View File
@@ -261,6 +261,10 @@ pub struct Graphics {
pub battery: bool,
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// 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.
pub shader_pack: String,
}
impl Default for Graphics {
fn default() -> Self {
@@ -284,6 +288,7 @@ impl Default for Graphics {
fxaa: false,
battery: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
}
}
}
@@ -334,6 +339,7 @@ impl Graphics {
"fxaa" => g.fxaa = flag(g.fxaa),
"batteryMode" => g.battery = flag(g.battery),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
g.overlay = match value {
"fps" => Overlay::Fps,
@@ -348,7 +354,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -375,6 +381,7 @@ impl Graphics {
bool_text(self.fxaa),
bool_text(self.battery),
self.api.name(),
self.shader_pack,
)
}
pub fn save(&self, dir: &Path) {
@@ -471,6 +478,7 @@ mod tests {
fxaa: true,
battery: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
assert_eq!(Graphics::parse(""), Graphics::default());
+1
View File
@@ -167,6 +167,7 @@ pub mod sculk;
pub use horizon_server_files as server;
pub mod server_packs;
pub mod settings;
pub mod shader_pack_list;
pub mod shadows;
pub mod signs;
pub mod skins;
+77 -14
View File
@@ -863,6 +863,8 @@ pub enum Page {
Font,
Packs,
Mods,
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// Horizon's graphics settings (`graphics_ui`).
Graphics,
GraphicsAdvanced,
@@ -893,6 +895,9 @@ enum Act {
ResetPadKey(&'static str),
ResetPadKeys,
ToggleMod(usize),
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
PickShaderPack(usize),
OpenShaderPackFolder,
/// The installed mod's own settings page (capability `ui`), shown as a
/// screen: `Event::ModSettings` has the game open it.
ModSettings(usize),
@@ -947,6 +952,11 @@ pub enum Event {
Packs(Vec<String>),
OpenPackFolder,
OpenModFolder,
/// The Shader Packs page opened: list `shaderpacks/` into `shader_packs`.
ListShaderPacks,
/// A shader pack was picked or turned off: save and apply it.
ShaderPack,
OpenShaderPackFolder,
/// Open the mod's own settings page (capability `ui`): the installed
/// mod's index in `mods.installed`.
ModSettings(usize),
@@ -1092,6 +1102,9 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
"Optional additions, all off by default: the game stays vanilla until you turn one on."
}
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
Page::ShaderPacks => {
"Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time."
}
Page::Mods => {
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
}
@@ -1183,6 +1196,7 @@ impl OptionsUi {
Page::Font => "options.font.title",
Page::Packs => "resourcePack.title",
Page::Mods => "Mods",
Page::ShaderPacks => "Shader Packs",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Controller => "Controller Settings",
Page::PadBinds => "Controller Binds",
@@ -1299,23 +1313,30 @@ impl OptionsUi {
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
),
Row::Buttons(
open("Controller Settings...", Page::Controller),
Some(self.extra_button(s, crate::extras::Item::DiscordPresence)),
open("Shader Packs...", Page::ShaderPacks),
Some(open("Controller Settings...", Page::Controller)),
),
Row::Buttons(
open("Extras...", Page::Extras),
Some(open("Mods...", Page::Mods)),
),
// No AI hides the AI Agent page and everything AI.
if s.extras.no_ai {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
} else {
Row::Buttons(
open("AI Agent...", Page::Agent),
Some(self.extra_button(s, crate::extras::Item::NoAi)),
)
},
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
Some(if s.extras.no_ai {
self.extra_button(s, crate::extras::Item::NoAi)
} else {
open("AI Agent...", Page::Agent)
}),
),
]
.into_iter()
.chain(
(!s.extras.no_ai).then(|| {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
}),
)
.collect()
}
Page::Agent => {
let mut rows = vec![Row::Buttons(
@@ -1532,6 +1553,26 @@ impl OptionsUi {
})
.collect()
}
Page::ShaderPacks => {
if s.shader_packs.is_empty() {
return vec![Row::Header(
"No shader packs: put them in the shaderpacks folder",
)];
}
s.shader_packs
.iter()
.enumerate()
.map(|(i, pack)| {
let kind = match pack.kind {
crate::shader_pack_list::Kind::Horizon => " (Horizon)",
crate::shader_pack_list::Kind::Classic => "",
};
let label = Text::literal(format!("{}{kind}: ", pack.title))
.append(on_off(s.horizon.shader_pack == pack.file));
Row::Buttons(button(0.0, 0.0, 310.0, label, Act::PickShaderPack(i)), None)
})
.collect()
}
_ => {
let _ = s;
Vec::new()
@@ -2017,13 +2058,18 @@ impl OptionsUi {
}
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
if page == Page::Mods {
let folder = match page {
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("Open Pack Folder", Act::OpenShaderPackFolder)),
_ => None,
};
if let Some((label, act)) = folder {
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
Text::literal("Open Mod Folder"),
Act::OpenModFolder,
Text::literal(label),
act,
));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
} else {
@@ -2475,7 +2521,12 @@ impl OptionsUi {
} => {
events.push(Event::ButtonClick);
match act {
Act::Push(p) => self.push(p),
Act::Push(p) => {
if p == Page::ShaderPacks {
events.push(Event::ListShaderPacks);
}
self.push(p)
}
Act::Done => events.extend(self.back(s)),
Act::Cycle(opt) => {
opt.cycle(s, right, framebuffer);
@@ -2520,6 +2571,17 @@ impl OptionsUi {
events.push(Event::ModSettings(i));
}
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::PickShaderPack(i) => {
if let Some(pack) = s.shader_packs.get(i) {
s.horizon.shader_pack = if s.horizon.shader_pack == pack.file {
String::new()
} else {
pack.file.clone()
};
events.push(Event::ShaderPack);
}
}
Act::OpenShaderPackFolder => events.push(Event::OpenShaderPackFolder),
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -4064,6 +4126,7 @@ mod tests {
[
Page::Graphics,
Page::GraphicsAdvanced,
Page::ShaderPacks,
Page::Controller,
Page::Extras,
Page::Mods,
+5
View File
@@ -182,6 +182,9 @@ pub struct Settings {
pub extras: crate::extras::Extras,
/// The Horizon mods turned on (`horizon-mods.txt`), all off by default.
pub mods: crate::mod_list::ModList,
/// The packs in `shaderpacks/`, listed when the Shader Packs page opens; the pick is
/// `horizon.shader_pack`.
pub shader_packs: Vec<crate::shader_pack_list::Entry>,
}
impl Default for Settings {
fn default() -> Self {
@@ -255,6 +258,7 @@ impl Default for Settings {
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
shader_packs: Vec::new(),
}
}
}
@@ -415,6 +419,7 @@ impl Settings {
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
shader_packs: Vec::new(),
auto_jump: f.bool("autoJump"),
language: f.string("lang"),
force_unicode: f.bool("forceUnicodeFont"),
@@ -0,0 +1,109 @@
//! The shader packs in the `shaderpacks` folder beside `options.txt`, as Options > Horizon
//! Settings > Shader Packs lists them: Horizon's own (docs/shaders.md) and those in the
//! `shaders/` format, side by side.
use std::path::Path;
pub const DIR: &str = "shaderpacks";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// A folder with `horizon-shaders.toml`, or a `.hshader`.
Horizon,
/// A folder with a `shaders` folder, or a `.zip`.
Classic,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Entry {
/// The folder's or file's name in `shaderpacks`: what the pick saves.
pub file: String,
/// The pack's own name for Horizon's packs, else `file`.
pub title: String,
pub kind: Kind,
}
/// The packs in `dir`, Horizon's first, each kind by title. A missing folder has none.
pub fn scan(dir: &Path) -> Vec<Entry> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut packs: Vec<Entry> = entries.flatten().filter_map(|e| entry(&e.path())).collect();
packs.sort_by(|a, b| {
(a.kind == Kind::Classic, a.title.to_lowercase())
.cmp(&(b.kind == Kind::Classic, b.title.to_lowercase()))
});
packs
}
fn entry(path: &Path) -> Option<Entry> {
let file = path.file_name()?.to_str()?.to_owned();
if file.starts_with('.') {
return None;
}
if horizon_shader_api::package::is_pack(path) {
// Its manifest's name; a pack that can't be read is still listed, under its file
// name: picking it says why in the chat.
let manifest = if path.is_dir() {
std::fs::read_to_string(path.join(horizon_shader_api::MANIFEST))
.ok()
.and_then(|t| horizon_shader_api::Manifest::parse(&t).ok())
} else {
horizon_shader_api::Pack::read(path)
.ok()
.map(|p| p.manifest)
};
let title = manifest.map_or_else(|| file.clone(), |m| m.name);
return Some(Entry {
file,
title,
kind: Kind::Horizon,
});
}
let zip = path.is_file()
&& path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("zip"));
(zip || path.join("shaders").is_dir()).then(|| Entry {
title: file.clone(),
file,
kind: Kind::Classic,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lists_both_kinds_horizon_first_and_skips_the_rest() {
let dir = std::env::temp_dir().join(format!("horizon-shader-list-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let d = dir.as_path();
let manifest = "schema = 1\nid = \"warm\"\nname = \"Warm Light\"\nversion = \"1\"\n\
api = \"0.1\"\n[[post]]\nfile = \"p.wgsl\"\n";
std::fs::create_dir(d.join("warm")).unwrap();
std::fs::write(d.join("warm").join(horizon_shader_api::MANIFEST), manifest).unwrap();
std::fs::create_dir_all(d.join("Classic Folder/shaders")).unwrap();
std::fs::write(d.join("archive.zip"), b"").unwrap();
std::fs::write(d.join("notes.txt"), b"").unwrap();
std::fs::create_dir(d.join("empty")).unwrap();
std::fs::create_dir_all(d.join(".hidden/shaders")).unwrap();
let packs = scan(d);
let listed: Vec<(&str, &str, Kind)> = packs
.iter()
.map(|e| (e.file.as_str(), e.title.as_str(), e.kind))
.collect();
assert_eq!(
listed,
[
("warm", "Warm Light", Kind::Horizon),
("archive.zip", "archive.zip", Kind::Classic),
("Classic Folder", "Classic Folder", Kind::Classic),
]
);
assert!(scan(&d.join("missing")).is_empty());
std::fs::remove_dir_all(d).unwrap();
}
}
+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, 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, 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 |
+7 -6
View File
@@ -13,7 +13,7 @@ Horizon's own format.
| Authoring tool | `horizon-shader` (same crate) | `new`, `check`, `pack` |
| Templates | `crates/horizon-shader-api/templates/` | post, sky, material |
| Example pack | `crates/horizon-shader-api/packs/golden_hour` | a warm sky and two post passes |
| In the game: the post stage | `HORIZON_SHADER_PACK=<name>` (for development, until the Shader Packs page) | post passes, hot reload |
| In the game: the post stage | Options > Horizon Settings > Shader Packs | post passes, hot reload |
| In the game: sky and material; the launcher and the registry | | not yet |
## Quick start
@@ -46,9 +46,11 @@ my_pack 0.1.0 (shader API 0.1): FAILED
## Trying a pack in the game
Until the Shader Packs page lists them, put the pack folder (or its `.hshader`) in `shaderpacks/` beside `options.txt`
and start the game with `HORIZON_SHADER_PACK=<its folder or file name>`. The game checks the pack first, and refuses
one that fails, with its problems in the chat. While it runs, the game looks at the pack twice a second: save a file
Put the pack folder (or its `.hshader`) in `shaderpacks/` beside `options.txt` (Open Pack Folder opens it) and pick
it on Options > Horizon Settings > Shader Packs, which lists Horizon's packs, marked (Horizon), beside packs in the
`shaders/` format; one runs at a time, and clicking it again turns it off. `HORIZON_SHADER_PACK=<its folder or file
name>` runs a pack whatever the page says, for development. The game checks the pack first, and refuses one that
fails, with its problems in the chat. While it runs, the game looks at the pack twice a second: save a file
and the pack is checked and rebuilt in place ("Shader pack ... reloaded"); if the new version fails, the chat says
where and the last good version keeps drawing. The post passes run today; the sky and material stages are checked
but not drawn yet.
@@ -133,5 +135,4 @@ open, each pass is timed on the GPU and the overlay shows the pack's total and i
## Next
Options > Shader packs listing these packs, then the material and sky stages, and the registry's `shader` kind with
installs from the launcher.
The material and sky stages in the game, and the registry's `shader` kind with installs from the launcher.