Files
benilla-octo/crates/benilla-assets/src/gpu_blp.rs
T
SamandClaude Fable 5 1a1d7e50dc the world redraws on resize, and terrain takes its DXT blocks
- the world froze on every resize or graphics change: the backdrop was resized by writing a fresh image through the existing handle, and a mutated `Image` is a whole new GPU texture while a material's bind group is captured once and never re-captured — so the camera drew into a texture the UI never sampled, and the last frame stretched over the window forever; a resized world target is now a NEW asset (publish, retire the old one, and the UI pass forgets materials for removed images so a window drag does not leak a 46 MB target per frame), and a resized capture now matches the native render bit for bit
- terrain tile layer arrays take the DXT blocks when the tile's whole layer set allows it — one array has one format, so eligibility is the tile's, not a texture's — and the install's own census says narrow is enough: 87 % of Azeroth's tiles and 92 % of Kalimdor's pass, layer bytes 1.57 GB → 0.63 and 2.23 → 0.76; the decode-tax comment is corrected on the way past — decoding costs 8x DXT1's bytes and 4x DXT3/DXT5's, not 4x and 2x
- Environment Detail is the reference's slider, not a dropdown — row 27 of 1.12's own slider table, with the readout keeping the stop names because a bare "1" means nothing to a player — and the shipped ground cover comes off High to Medium: the x3 clutter density had been matched to a configured install, and Medium is the nearest stop no sparser than what a fresh install draws on capable hardware
- Display Mode is modern Classic's two-entry dropdown — Windowed (Fullscreen) / Windowed, a boolean at Blizzard's own source, where the three-mode list is pre-8.0.1 folklore — riding `gxWindow` so saved configs keep meaning, with the label-against-value polarity pinned by the row's first test; the Multisampling row comes off the Graphics page for now (the CVar, the device clamp and the bindings all stay): with alpha-to-coverage set nowhere, MSAA touches polygon silhouettes only, and render scale on the same page measures four times the visual change

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:16:01 +07:00

222 lines
11 KiB
Rust

//! Where a BLP's stored form meets the GPU: **can we hand the blocks over untouched?**
//!
//! WoW ships almost all world art as S3TC (DXT1/DXT3/DXT5) blocks, and the reference client uploads
//! those blocks verbatim — `glCompressedTexImage2DARB`, with the software DXT decoder existing only
//! as a 16-bit-device fallback (wow-re `system/image/image.md`: *"raw passthrough — device eats
//! DXT"*, and `system/models/scratch/wmo-texture-sampling.md` for the per-level upload formats).
//! benilla used to decode every one of them to `Rgba8Unorm` on the CPU and upload that: **8x the
//! bytes of DXT1, 4x of DXT3/DXT5**, on the whole resident working set and on every texture fetch
//! that misses the cache. On a bandwidth-starved integrated GPU that is the frame's dominant cost,
//! and it is the divergence Liho's Steam Deck reading exposed (decision 1626).
//!
//! Two things have to be true before the blocks can go straight over, and this module owns both:
//!
//! 1. **The device must accept BC.** [`bc_supported`] is a process-wide fact published once from
//! the render device (see [`publish_bc_support`]). Not a guess: `wgpu-hal`'s Metal backend gates
//! `TEXTURE_COMPRESSION_BC` on `format_bc: os_is_mac` and the Vulkan backend on the device's
//! `textureCompressionBC`, and bevy's `WgpuSettingsPriority::Functionality` (the default) requests
//! every feature the adapter reports. Every desktop target we ship has it — but "every target we
//! ship" is not "every device", so the fallback stays.
//! 2. **The dimensions must be whole 4x4 blocks.** A BC texture is addressed in blocks; a BLP whose
//! mip 0 is not a multiple of 4 on both axes cannot be uploaded as one. Real 1.12 world art is
//! power-of-two, so this practically never fires — it exists so that when it does, the texture
//! renders decoded instead of failing.
//!
//! When either is false the caller decodes, exactly as before. That is why the fallback lives here
//! as one function and not as a flag threaded through two loaders: there is one rule, and both the
//! async `AssetLoader` lane ([`crate::blp`]) and the synchronous `WorldAssets` lane
//! ([`crate::world_assets`]) ask it the same question.
use std::sync::OnceLock;
use benilla_formats::{BlpMipChain, BlpTexels};
use bevy::image::{CompressedImageFormatSupport, CompressedImageFormats};
use bevy::prelude::*;
use bevy::render::render_resource::TextureFormat;
static BC_SUPPORTED: OnceLock<bool> = OnceLock::new();
/// Publish whether this run's GPU accepts BC textures. Called once, from the app's plugin
/// `finish()`, after `RenderPlugin` has inserted `CompressedImageFormatSupport` into the main world.
///
/// **Ordering is why this is a `OnceLock` and not a resource.** An `AssetLoader` runs on the async
/// task pool with no world access, and the synchronous `WorldAssets` lane is called from ordinary
/// systems — neither can read a resource the other can. But every plugin's `finish()` completes
/// before the runner's first update, and no BLP is loaded before then, so a single process-wide
/// publish is both sufficient and race-free. Absent a render device (headless tests), nothing
/// publishes and [`bc_supported`] reports `false` — the decoded path, which is what a test that
/// inspects pixels wants anyway.
pub fn publish_bc_support(supported: bool) {
let _ = BC_SUPPORTED.set(supported);
}
/// Does this run's GPU accept BC? `false` until [`publish_bc_support`] says otherwise.
pub fn bc_supported() -> bool {
*BC_SUPPORTED.get().unwrap_or(&false)
}
/// Publishes [`bc_supported`] from the live render device. Added by
/// [`crate::register_asset_loaders`], so both the client and the world viewer get it.
///
/// **All the work is in `finish`, deliberately.** `RenderPlugin::finish` is what inserts
/// `CompressedImageFormatSupport` into the main world (bevy_render `lib.rs`, the
/// `FutureRenderResources` block) — it does not exist at `build` time, and it never exists at all
/// in a headless app. Reading it here, one plugin later, is the earliest honest moment.
pub struct BlpGpuSupportPlugin;
impl Plugin for BlpGpuSupportPlugin {
fn build(&self, _app: &mut App) {}
fn finish(&self, app: &mut App) {
// `WOW_NO_BC=1` — force the decoded path on a device that could take blocks. The A/B lever
// for this whole change: it is the only way to price the passthrough against what it
// replaced on one machine in one session, and the first thing to try if a texture ever
// looks wrong (hardware block decode is not bit-identical to `texpresso`'s — see 1626).
let forced_off = std::env::var("WOW_NO_BC").as_deref() == Ok("1");
let device_can = app
.world()
.get_resource::<CompressedImageFormatSupport>()
.is_some_and(|s| s.0.contains(CompressedImageFormats::BC));
let supported = device_can && !forced_off;
publish_bc_support(supported);
// At `info`, not `debug`: "which texture lane am I on?" is the first question any perf or
// look report about this change has to answer, and a line that only exists in a debug build
// cannot answer it from a player's log.
if supported {
info!("blp: DXT blocks upload natively (BC)");
} else if forced_off {
info!("blp: WOW_NO_BC=1 — decoding textures to RGBA8");
} else {
info!("blp: device reports no BC support — decoding textures to RGBA8");
}
}
}
/// Can `chain` be uploaded in the form it is already in?
fn uploadable_as_blocks(chain: &BlpMipChain) -> bool {
chain.texels.is_block_compressed()
&& bc_supported()
&& chain.width.is_multiple_of(4)
&& chain.height.is_multiple_of(4)
}
/// A chain and the `TextureFormat` it uploads as — **decided together, returned together.**
///
/// This pairing is the whole point of the type. The first cut of this module had two functions, one
/// that picked the chain and one that picked the format, and a caller reached the second without
/// going through the first: it uploaded DXT3 blocks under an `Rgba8Unorm` descriptor and wgpu
/// panicked slicing 262144 bytes out of an 87392-byte chain. There is no way to hold one of these
/// without the other, so that class of mistake cannot recur.
pub struct UploadChain {
pub chain: BlpMipChain,
pub format: TextureFormat,
}
/// A BLP's authored mip chain in the best form this GPU can take, with the format that matches it:
/// **blocks if it can, pixels if it cannot.**
///
/// The format is always in the **gamma-byte (non-sRGB) lane** every world/model albedo lives in —
/// the RE'd invariant that the GPU must not linearize albedo on sample
/// (`wow-5875-re/system/lighting`). `Bc1RgbaUnorm` and friends carry that rule exactly as
/// `Rgba8Unorm` does, which is what makes the passthrough look-neutral.
pub fn for_upload(chain: BlpMipChain) -> UploadChain {
if uploadable_as_blocks(&chain) {
let format = match chain.texels {
BlpTexels::Bc1 => TextureFormat::Bc1RgbaUnorm,
BlpTexels::Bc2 => TextureFormat::Bc2RgbaUnorm,
BlpTexels::Bc3 => TextureFormat::Bc3RgbaUnorm,
// `uploadable_as_blocks` already excluded this arm.
BlpTexels::Rgba8Unorm => TextureFormat::Rgba8Unorm,
};
return UploadChain { chain, format };
}
// Either it was never blocks, or this device/texture cannot take them. `into_rgba8` is a no-op
// in the first case and the decode we were trying to avoid in the second — and it cannot fail,
// which is why the format below is unconditionally right.
UploadChain {
chain: chain.into_rgba8(),
format: TextureFormat::Rgba8Unorm,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chain(texels: BlpTexels, w: u32, h: u32) -> BlpMipChain {
let mips = vec![vec![0u8; texels.level_bytes(w, h)]];
BlpMipChain {
width: w,
height: h,
texels,
mips,
}
}
/// Without a published device fact we decode. A headless test that reads pixels depends on
/// this, and so does any run whose GPU genuinely lacks BC.
#[test]
fn no_published_support_means_the_decoded_path() {
// `publish_bc_support` is never called in this test binary, so the lock stays empty.
assert!(!bc_supported());
let up = for_upload(chain(BlpTexels::Bc1, 16, 16));
assert_eq!(up.format, TextureFormat::Rgba8Unorm);
assert!(up.chain.is_rgba8(), "blocks must have been decoded");
}
/// **The invariant this module exists to hold**: whatever comes back, the bytes and the format
/// agree about how big mip 0 is. This is the assertion whose absence let block bytes be
/// uploaded under an `Rgba8Unorm` descriptor.
#[test]
fn the_returned_format_always_matches_the_returned_bytes() {
for texels in [
BlpTexels::Rgba8Unorm,
BlpTexels::Bc1,
BlpTexels::Bc2,
BlpTexels::Bc3,
] {
for (w, h) in [(16u32, 16u32), (8, 32), (258, 256), (6, 6)] {
let up = for_upload(chain(texels, w, h));
let expected = match up.format {
TextureFormat::Rgba8Unorm => BlpTexels::Rgba8Unorm,
TextureFormat::Bc1RgbaUnorm => BlpTexels::Bc1,
TextureFormat::Bc2RgbaUnorm => BlpTexels::Bc2,
TextureFormat::Bc3RgbaUnorm => BlpTexels::Bc3,
other => panic!("unexpected albedo format {other:?}"),
};
assert_eq!(up.chain.texels, expected, "{texels:?} at {w}x{h}");
assert_eq!(
up.chain.mips[0].len(),
expected.level_bytes(w, h),
"mip 0 bytes must match what {:?} implies at {w}x{h}",
up.format
);
}
}
}
/// The block-alignment rule is on mip 0's dimensions, both axes. wgpu-core rejects a compressed
/// texture whose size is not a whole number of blocks (`NotMultipleOfBlockWidth/Height`), and
/// its default error handler makes that a process panic — so this is a hard gate, not a
/// preference.
#[test]
fn non_block_aligned_dimensions_are_refused() {
for (w, h) in [(258u32, 256u32), (256, 258), (6, 6), (1, 1)] {
assert!(
!uploadable_as_blocks(&chain(BlpTexels::Bc1, w, h)),
"{w}x{h} is not whole blocks"
);
}
}
/// An already-decoded chain is never re-encoded, and never copied through a decode path.
#[test]
fn an_rgba_chain_passes_through_untouched() {
let c = chain(BlpTexels::Rgba8Unorm, 8, 8);
let before = c.mips[0].len();
let up = for_upload(c);
assert_eq!(up.format, TextureFormat::Rgba8Unorm);
assert_eq!(up.chain.mips[0].len(), before);
}
}