Anisotropic filtering and trilinear apply in the session
The Options row shipped last commit said the setting needed a relaunch.
It does not have to. The reference's "enabled upon restart" is about its
TEXTURES, not its CVars — both register with `flags = 1`, live — because
its sampler state was baked inside each `TextureCreate` (`0x449ae0`) and
it kept no list of live textures. Ours can be re-stamped in place.
Two things make it possible, and neither is new work:
- A governed sampler now wears `POLICY_SAMPLER_LABEL`, written by the one
constructor `TexFilterSetting::descriptor`. That is the `applyGlobal`
split the reference draws, made legible at runtime: terrain, WMO/M2 and
the liquid sheets take the policy; weather sprites, the minimap, glue,
Lua widgets, the sky and decals keep their own mode. All four governed
lanes went through that constructor, so a lane cannot forget the label
and silently opt itself out of every later change.
- Extracting a `RENDER_WORLD`-only `Image` takes its *pixels* and leaves
the asset behind (`take_gpu_data` is an `Option::take` on `image.data`).
So every resident texture's own descriptor is still readable in the main
world, for nothing — there is no ledger to keep and none to go stale.
The policy itself stops being a `OnceLock` and becomes one atomic word
plus a revision, so it can be published again on every write and both
halves of the refresh can key off the same number in the same frame:
`refresh_policy_samplers` (render world, `ExtractSchedule`, before every
Prepare) swaps `GpuImage::sampler`, and `invalidate_materials::<M>` raises
`AssetEvent::Modified` so bind groups that captured the OLD sampler are
built again. `static_gx` carries two samplers of its own and rebuilds its
cached cell bind groups in place rather than dropping them — clearing the
cache would be one line, but a region with no GPU state does not draw, so
every building in view would blink out and trickle back under the bake
budget.
Measured, not asserted. `WOW_CVAR_LATE=<name>=<value>` is new here: it
writes CVars *after* the scene has settled, then re-settles the image
around them. A latched lever and a live one give the same boot picture, so
no boot-time A/B can tell them apart. On the Elwynn vista:
anisotropic 16 at boot vs 16 written late:
0 of 1,440,000 pixels differ
and the control that proves the harness is not doing the work (boot 16,
written 16 again late, so no refresh runs but the re-settle is still paid)
is byte-identical to a plain boot. The cost of the change is 873 samplers,
75 material bind groups and 264 static_gx cell bind groups, once; nothing
is re-uploaded, since the pixels never move.
The first run of that A/B was NOT identical — 6.7% of the frame, a dapple
of stale tree shadow on a road that was otherwise correct. That is
`CutoutShadowCasterMaterial`, which deliberately reuses the forward pass's
own leaf handle and so holds a governed sampler, and which lives in
`benilla-app` rather than the engine. Hence the shape the invalidation
took: a public `invalidate_materials::<M>` each crate registers beside its
own `MaterialPlugin`, published through the world-API wall with the record
that says why (the engine naming every material type in the program is the
inversion that wall exists to stop). Forgetting one is silent in exactly
that way — a bind group built after the change is right and one already
cached is wrong — so the capture A/B is written into `docs/GRAPHICS.md` as
the check for this feature, not a unit test pretending to be one.
The Options row keeps its deferral to Apply, now for the real reason: the
commit is cheap once and silly sixteen times, which is what a drag across
the groove would ask for.
Verified: ALL GATES GREEN (520 s); `benilla-app --lib` against the install
at the exact 269-failure baseline, set-identical by `comm`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
@@ -430,8 +430,7 @@
|
||||
.. "Off is what the original client shipped, which smears that texture to mush a few "
|
||||
.. "yards out. 16x costs about a millisecond of graphics time and nothing at all on "
|
||||
.. "the processor, so it is close to free on any modern card.\n\n"
|
||||
.. "Each texture is sampled the way it was when it loaded, so this takes effect the "
|
||||
.. "next time you start the game."
|
||||
.. "Applies when you click Apply."
|
||||
|
||||
-- 1.12 has no such checkbox.
|
||||
BENILLA_TOOLTIP_BACKGROUND_SOUND =
|
||||
@@ -2846,15 +2845,18 @@
|
||||
-- wrong one, which is the test `RowShadowResolution`
|
||||
-- fails and so takes a dropdown instead.
|
||||
OptionsSliderRow_Setup(self, 1, 16, 1, "aniso")
|
||||
-- DEFERRED, as the two rows below it are, because
|
||||
-- the write does not reach a texture that is
|
||||
-- already resident: a sampler is baked into each
|
||||
-- `Image` at load (`tex_filter.rs`), which is why
|
||||
-- the reference's own UI says "enabled upon
|
||||
-- restart". Apply is therefore the honest commit
|
||||
-- point, and a drag does not write a CVar on every
|
||||
-- step of the groove that only the next launch
|
||||
-- reads.
|
||||
-- DEFERRED to Apply, as Render Scale below it is,
|
||||
-- and for the same kind of reason: the commit
|
||||
-- re-samples every resident texture and rebuilds
|
||||
-- the bind group of every material that names one
|
||||
-- (`benilla_world::tex_filter_live`). That is
|
||||
-- cheap once and silly sixteen times, which is
|
||||
-- what a drag across this groove would ask for.
|
||||
--
|
||||
-- It does NOT need a restart, though the reference
|
||||
-- says so of its own row: its sampler state lived
|
||||
-- inside each TextureCreate and it kept no list of
|
||||
-- live textures. Ours re-stamps them in place.
|
||||
self.deferred = 1
|
||||
</OnLoad></Scripts>
|
||||
</Frame>
|
||||
|
||||
@@ -237,6 +237,21 @@ pub(crate) fn print_scenario_names() {
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_CVAR_LATE=<name>=<value>[;…]`: CVars written **after** the scene has settled, then the
|
||||
/// image re-settled around them.
|
||||
///
|
||||
/// `$WOW_CVAR` is the boot form, folded in before the first texture loads. This is the other half,
|
||||
/// and the only way to measure a setting that claims to apply *in the session*: a lever that is
|
||||
/// live and one that is latched produce the same boot image, so a boot-time A/B cannot tell them
|
||||
/// apart. Written here, the world is already built and its textures already resident, so the shot
|
||||
/// shows what a player dragging the slider would get — and `late == boot` is the whole proof that
|
||||
/// the live path reaches every texture the loader does.
|
||||
fn late_cvar_script() -> Option<String> {
|
||||
std::env::var("WOW_CVAR_LATE")
|
||||
.ok()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Consecutive byte-identical framebuffer readbacks that mean the scene is built: anything still
|
||||
/// arriving changes the image or does not affect the shot. `$WOW_CAPTURE_STABLE` overrides it.
|
||||
const STABLE_FRAMES: u32 = 30;
|
||||
@@ -357,6 +372,8 @@ struct CaptureCtx {
|
||||
frozen_clock: bool,
|
||||
/// `$WOW_RESIZE` is applied (once, at first settle).
|
||||
resized: bool,
|
||||
/// `$WOW_CVAR_LATE` is applied (once, at first settle).
|
||||
late_cvars: bool,
|
||||
probe_samples: Vec<f32>,
|
||||
/// Process CPU seconds at the first sample, the baseline for the probe line's `cpu_ms`.
|
||||
probe_cpu_start: Option<f64>,
|
||||
@@ -671,6 +688,7 @@ impl Plugin for CapturePlugin {
|
||||
probe_frames,
|
||||
frozen_clock: true,
|
||||
resized: false,
|
||||
late_cvars: false,
|
||||
probe_samples: Vec::new(),
|
||||
probe_cpu_start: None,
|
||||
started: Instant::now(),
|
||||
@@ -791,6 +809,8 @@ fn drive_capture(
|
||||
mut time_strategy: ResMut<TimeUpdateStrategy>,
|
||||
// `None` on a glue-screen capture, which builds no composite lane.
|
||||
backdrop: Option<Res<crate::world_backdrop::WorldBackdrop>>,
|
||||
// `$WOW_CVAR_LATE`, written at first settle: see [`late_cvar_script`].
|
||||
mut cvars: ResMut<crate::cvars::Cvars>,
|
||||
) {
|
||||
// The wall-clock ceiling covers every phase: a fixture may never attach, a readback may never
|
||||
// land ([`capture_deadline`]).
|
||||
@@ -867,6 +887,21 @@ fn drive_capture(
|
||||
watch.stable = 0;
|
||||
info!("capture: resized to {rw}x{rh}, re-settling");
|
||||
Phase::Building(0)
|
||||
} else if let (false, Some(script)) = (ctx.late_cvars, late_cvar_script()) {
|
||||
ctx.late_cvars = true;
|
||||
for step in script.split(';').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
match step.split_once('=') {
|
||||
Some((name, value)) => {
|
||||
let (name, value) = (name.trim(), value.trim());
|
||||
let outcome = cvars.set(name, value);
|
||||
info!("capture: WOW_CVAR_LATE set {name} = {value} ({outcome:?})");
|
||||
}
|
||||
None => warn!("capture: WOW_CVAR_LATE step {step:?} is not <name>=<value>"),
|
||||
}
|
||||
}
|
||||
watch.stable = 0;
|
||||
info!("capture: late CVars written, re-settling");
|
||||
Phase::Building(0)
|
||||
} else if fixture_age.is_some() {
|
||||
if let Some(state) = fx_state.as_deref_mut() {
|
||||
state.armed = true; // the fixture spawns now, its age clock clean
|
||||
|
||||
@@ -1122,8 +1122,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
|
||||
)
|
||||
.latched(),
|
||||
// `trilinear` and `anisotropic`, over `benilla_assets::TexFilterSetting` (`tex_filter.rs`
|
||||
// carries the derivation). A change applies at the next launch, since a sampler is baked into
|
||||
// each texture at load; the reference's UI says "enabled upon restart".
|
||||
// carries the derivation). Both apply IN THE SESSION, as the reference's own `flags = 1`
|
||||
// registration says they should: a write re-publishes the process policy, and
|
||||
// `benilla_world::tex_filter_live` re-stamps the sampler of every resident governed texture
|
||||
// and rebuilds the bind groups that name one. The reference's UI says "enabled upon restart"
|
||||
// because its sampler state was baked into each `TextureCreate` and it kept no list of live
|
||||
// textures; that was a limit of its renderer, not of the setting.
|
||||
// `$WOW_TRILINEAR`/`$WOW_ANISO` override for the session.
|
||||
//
|
||||
// `trilinear` registers "0", but `hwDetect` runs `DetectHardware 0x641260` and sets it from the
|
||||
@@ -2493,7 +2497,8 @@ pub(crate) const SEEDED_DEVIATIONS: &[(&str, &str, &str)] = &[
|
||||
"1",
|
||||
"owner decision: the reference ships anisotropic filtering off, which smears ground \
|
||||
texture at a grazing angle a few yards out — 34.1% of an outdoor frame, measured. High \
|
||||
takes 16 for +1.0 ms of GPU and no main thread; Classic and Low keep the reference's 1",
|
||||
takes 16 for +1.0 ms of GPU and no main thread; Classic and Low keep the reference's 1. \
|
||||
The Graphics page carries a slider over it, 1 to 16, which applies in the session",
|
||||
),
|
||||
];
|
||||
|
||||
@@ -2537,8 +2542,9 @@ pub(crate) const GRAPHICS_PRESETS: &[(&str, [&str; 5])] = &[
|
||||
// band where the ground recedes, for **+1.0 ms of GPU and no main thread at all** (gpu 0.8 ->
|
||||
// 1.8 ms, main unchanged). This client is main-thread bound with the GPU about two thirds idle
|
||||
// (`docs/GRAPHICS.md`, "The budget"), so that ms is one we are not spending. Classic and
|
||||
// Low keep the reference's value; the sampler is baked at load, so a rung change lands on the
|
||||
// next launch, as the reference's own UI says ("enabled upon restart").
|
||||
// Low keep the reference's value. A rung change lands at once, like every other row here:
|
||||
// `benilla_world::tex_filter_live` re-samples the resident textures rather than waiting for
|
||||
// a relaunch, which is what the reference's "enabled upon restart" was about.
|
||||
("anisotropic", ["1", "1", "4", "16", "16"]),
|
||||
];
|
||||
|
||||
@@ -4139,6 +4145,23 @@ mod tests {
|
||||
assert!(!res::<benilla_assets::TexFilterSetting>(&app).trilinear);
|
||||
apply(&mut app, "trilinear", "1");
|
||||
assert!(res::<benilla_assets::TexFilterSetting>(&app).trilinear);
|
||||
// GFX (anisotropic): the arm writes the resource AND republishes the process policy, which
|
||||
// is the only thing `benilla_world::tex_filter_live` can see — the async BLP loader has no
|
||||
// world access, so the resource alone would reach nothing. Checked here because an arm
|
||||
// that stopped publishing would leave both halves of the live refresh idle and every test
|
||||
// above still green.
|
||||
let before = benilla_assets::tex_filter_revision();
|
||||
apply(&mut app, "anisotropic", "8");
|
||||
assert_eq!(
|
||||
benilla_assets::tex_filter().aniso,
|
||||
8,
|
||||
"the write is published"
|
||||
);
|
||||
assert_ne!(
|
||||
benilla_assets::tex_filter_revision(),
|
||||
before,
|
||||
"a published move has to bump the revision, or nothing re-samples"
|
||||
);
|
||||
// Clamped to the device's ceiling too: this GPU offers 4, and wgpu refuses a count it
|
||||
// lacks.
|
||||
apply(&mut app, "gxmultisample", "99");
|
||||
|
||||
@@ -643,15 +643,28 @@ pub(crate) fn on_cvar(
|
||||
}
|
||||
msaa.samples = granted;
|
||||
}
|
||||
// Not applied until the next launch (the filter policy is published once at the end of
|
||||
// `CvarLoad`); the reference registers both with `flags = 1` and applies them live.
|
||||
// Applied in the session. The reference registers both with `flags = 1` — live — and its
|
||||
// "enabled upon restart" is about its TEXTURES, not its CVars: its sampler state was baked
|
||||
// into each `TextureCreate` and it kept no list of live ones. Ours re-stamps every
|
||||
// resident governed texture off the publish below (`benilla_world::tex_filter_live`), so
|
||||
// the row in Video Options means what it says.
|
||||
//
|
||||
// The publish is what carries it: the resource is this observer's, and the policy every
|
||||
// texture loader reads is a process global (`benilla_assets::tex_filter`) because the
|
||||
// async BLP loader has no world access. It is idempotent — a publish that does not move
|
||||
// the value bumps no revision and costs a refresh nothing — so each arm can just publish.
|
||||
//
|
||||
// `anisotropic` takes the reference's clamp to `[1, 16]` (`0x689110`).
|
||||
"trilinear" => tex_filter.trilinear = ev.flag(),
|
||||
"trilinear" => {
|
||||
tex_filter.trilinear = ev.flag();
|
||||
benilla_assets::publish_tex_filter(*tex_filter);
|
||||
}
|
||||
"anisotropic" => {
|
||||
tex_filter.aniso = (v as u32).clamp(
|
||||
*benilla_assets::ANISO_RANGE.start(),
|
||||
*benilla_assets::ANISO_RANGE.end(),
|
||||
)
|
||||
);
|
||||
benilla_assets::publish_tex_filter(*tex_filter);
|
||||
}
|
||||
// The panel's 0/1/2 is the density multiplier x1/x2/x3, clamped to the 1.12 slider's
|
||||
// range. `frillDensity` is the same knob, mirrored so `GetCVar` never answers two levels.
|
||||
|
||||
@@ -48,6 +48,16 @@ pub(crate) struct WorldShadowPlugin;
|
||||
impl Plugin for WorldShadowPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(MaterialPlugin::<CutoutShadowCasterMaterial>::default())
|
||||
// GFX (anisotropic): this material reuses the forward pass's own leaf handle, which is
|
||||
// a policy-governed texture, so its bind group has to be rebuilt when `trilinear` or
|
||||
// `anisotropic` moves — see `benilla_world::tex_filter_live::invalidate_materials`.
|
||||
// Without it the live refresh re-sampled the leaf sheet, the forward pass picked the
|
||||
// new sampler up and the shadow pass did not: a dapple of stale tree shadow on an
|
||||
// otherwise correct road, 6.7% of an Elwynn vista, measured.
|
||||
.add_systems(
|
||||
Last,
|
||||
benilla_world::tex_filter_live::invalidate_materials::<CutoutShadowCasterMaterial>,
|
||||
)
|
||||
.init_resource::<WorldLane>()
|
||||
// MONKEY (followups): refresh the static caster cache when streamed geometry arrives.
|
||||
// This used to live in the volumetric-fog plugin and so only ran with that cvar on.
|
||||
|
||||
@@ -159,6 +159,15 @@ const PUBLISHED: &[(&str, &str)] = &[
|
||||
("terrain_stream::SPAWN_XY", "wall"),
|
||||
("terrain_stream::ViewFocus", "record"),
|
||||
("terrain_stream::WorldLoadProgress", "wall"),
|
||||
// GFX (anisotropic): the registration hook of the live texture-filter policy. Published, not
|
||||
// filed as a leak, because its whole shape is "a material anywhere asks the engine to rebuild
|
||||
// its bind groups when `trilinear` or `anisotropic` moves" — the caller is necessarily outside
|
||||
// the engine whenever a material is, and `benilla-app`'s cutout shadow caster is one (it binds
|
||||
// the forward pass's own leaf handle, so it holds a governed sampler). Moving the material to
|
||||
// the engine would be the wrong close: it is a shadow-rig material, and it would drag the lane
|
||||
// with it. The alternative — the engine naming every material type in the program — is the
|
||||
// inversion this wall exists to stop.
|
||||
("tex_filter_live::invalidate_materials", "record"),
|
||||
("thread_qos::QosClass", "record"),
|
||||
("thread_qos::ThreadQosPlugin", "wall"),
|
||||
("thread_qos::promote_current_thread", "record"),
|
||||
|
||||
@@ -94,7 +94,7 @@ impl AssetLoader for BlpImageLoader {
|
||||
/// The gamma lanes' uncompressed format: non-sRGB, so the GPU does not linearize on sample.
|
||||
const GAMMA_BYTES: TextureFormat = TextureFormat::Rgba8Unorm;
|
||||
|
||||
fn world_art_image(upload: UploadChain, wrap: (bool, bool)) -> Image {
|
||||
pub(crate) fn world_art_image(upload: UploadChain, wrap: (bool, bool)) -> Image {
|
||||
let UploadChain { chain, format } = upload;
|
||||
let levels = chain.mips.len() as u32;
|
||||
let mut data = Vec::with_capacity(chain.mips.iter().map(Vec::len).sum());
|
||||
@@ -117,30 +117,15 @@ fn world_art_image(upload: UploadChain, wrap: (bool, bool)) -> Image {
|
||||
);
|
||||
image.data = Some(data);
|
||||
image.texture_descriptor.mip_level_count = levels;
|
||||
let mode = |repeat: bool| {
|
||||
if repeat {
|
||||
ImageAddressMode::Repeat
|
||||
} else {
|
||||
ImageAddressMode::ClampToEdge
|
||||
}
|
||||
};
|
||||
// The reference's terrain, WMO and M2 textures take the mip filter and anisotropy from the
|
||||
// process policy (`0x449ae0`, `crate::tex_filter`).
|
||||
let filter = crate::tex_filter::tex_filter();
|
||||
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
|
||||
address_mode_u: mode(wrap.0),
|
||||
address_mode_v: mode(wrap.1),
|
||||
mag_filter: ImageFilterMode::Linear,
|
||||
min_filter: ImageFilterMode::Linear,
|
||||
mipmap_filter: filter.mipmap_filter(),
|
||||
anisotropy_clamp: filter.anisotropy_clamp(),
|
||||
..Default::default()
|
||||
});
|
||||
// process policy (`0x449ae0`, `crate::tex_filter`), which can move in the session.
|
||||
image.sampler =
|
||||
ImageSampler::Descriptor(crate::tex_filter::tex_filter().descriptor(wrap.0, wrap.1));
|
||||
image
|
||||
}
|
||||
|
||||
/// Emissive billboard: sRGB, so an unlit pass returns the authored gamma bytes to screen.
|
||||
fn sprite_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
pub(crate) fn sprite_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
let mut image = Image::new(
|
||||
Extent3d {
|
||||
width,
|
||||
@@ -166,11 +151,11 @@ fn sprite_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
/// `GL_LINEAR` with no mips, anisotropy 1 and `GL_SKIP_DECODE_EXT`, so the hardware filters the
|
||||
/// gamma bytes. An sRGB upload would darken every alpha edge against black, so the tile uploads as
|
||||
/// [`GAMMA_BYTES`] and the shader converts after the filter.
|
||||
fn map_tile_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
pub(crate) fn map_tile_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
effect_image(width, height, rgba)
|
||||
}
|
||||
|
||||
fn effect_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
pub(crate) fn effect_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
let mut image = Image::new(
|
||||
Extent3d {
|
||||
width,
|
||||
@@ -193,7 +178,7 @@ fn effect_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
|
||||
}
|
||||
|
||||
/// No anisotropy: a point sprite is square and screen-aligned, with no anisotropic footprint.
|
||||
fn point_sprite_image(upload: UploadChain) -> Image {
|
||||
pub(crate) fn point_sprite_image(upload: UploadChain) -> Image {
|
||||
let UploadChain { chain, format } = upload;
|
||||
let levels = chain.mips.len() as u32;
|
||||
let mut data = Vec::with_capacity(chain.mips.iter().map(Vec::len).sum());
|
||||
|
||||
@@ -47,7 +47,10 @@ pub use blp::{BlpImageLoader, BlpLoaderSettings, BlpVariant};
|
||||
mod gpu_blp;
|
||||
pub use gpu_blp::{bc_supported, for_upload, publish_bc_support, BlpGpuSupportPlugin, UploadChain};
|
||||
mod tex_filter;
|
||||
pub use tex_filter::{publish_tex_filter, tex_filter, TexFilterSetting, ANISO_RANGE};
|
||||
pub use tex_filter::{
|
||||
governed_sampler, publish_tex_filter, tex_filter, tex_filter_revision, TexFilterSetting,
|
||||
ANISO_RANGE, POLICY_SAMPLER_LABEL,
|
||||
};
|
||||
mod m2;
|
||||
pub use m2::{
|
||||
EmitterBillboard,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
use benilla_formats::{BlpMipChain, BlpTexels};
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::image::{Image, ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor};
|
||||
use bevy::image::{Image, ImageSampler};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::{
|
||||
Extent3d, TextureDataOrder, TextureDimension, TextureFormat, TextureViewDescriptor,
|
||||
@@ -256,17 +256,10 @@ pub(crate) fn layer_array_image(size: u32, layer_count: u32, packed: PackedLayer
|
||||
image.texture_descriptor.mip_level_count = mip_count;
|
||||
image.data_order = TextureDataOrder::LayerMajor;
|
||||
// As in the reference, ground filtering comes from the process globals (`crate::tex_filter`):
|
||||
// trilinear with anisotropy off on a fresh install (`hwDetect`).
|
||||
let filter = crate::tex_filter::tex_filter();
|
||||
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
|
||||
address_mode_u: ImageAddressMode::Repeat,
|
||||
address_mode_v: ImageAddressMode::Repeat,
|
||||
mag_filter: ImageFilterMode::Linear,
|
||||
min_filter: ImageFilterMode::Linear,
|
||||
mipmap_filter: filter.mipmap_filter(),
|
||||
anisotropy_clamp: filter.anisotropy_clamp(),
|
||||
..default()
|
||||
});
|
||||
// trilinear with anisotropy off on a fresh install (`hwDetect`). Ground tiles repeat on both
|
||||
// axes, and this is the array anisotropy matters most on — it is what a road seen edge-on is.
|
||||
image.sampler =
|
||||
ImageSampler::Descriptor(crate::tex_filter::tex_filter().descriptor(true, true));
|
||||
image
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,20 @@
|
||||
//! one of the sixteen (`0xc7f2e4` is absent from `[0x639a60, 0x639b80)`), so anisotropy stays off.
|
||||
//!
|
||||
//! A process global because a sampler is baked into the [`Image`] at load, by an async loader
|
||||
//! ([`crate::blp`]) and by systems ([`crate::world_assets`]) that share no resource. Latched at
|
||||
//! boot like `gxMultisample`: a baked sampler cannot change without a rebuild, which is why the
|
||||
//! reference's UI says "enabled upon restart".
|
||||
//! ([`crate::blp`]) and by systems ([`crate::world_assets`]) that share no resource.
|
||||
//!
|
||||
//! It is NOT latched. The reference's UI says "enabled upon restart" because its sampler state
|
||||
//! lived inside each `TextureCreate` call and nothing kept a list of live textures; ours can be
|
||||
//! re-stamped in place, so [`crate::tex_filter_live`] re-samples every resident governed texture
|
||||
//! when the policy moves and the setting applies in the session. The one thing that makes that
|
||||
//! possible is [`POLICY_SAMPLER_LABEL`]: a governed sampler wears it, so the refresh can tell a
|
||||
//! texture that takes the policy from one of the six `applyGlobal = 0` subsystems that keeps its
|
||||
//! own mode.
|
||||
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use bevy::image::ImageFilterMode;
|
||||
use bevy::image::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::FilterMode;
|
||||
|
||||
@@ -33,6 +40,18 @@ use bevy::render::render_resource::FilterMode;
|
||||
/// (`0x689110`).
|
||||
pub const ANISO_RANGE: std::ops::RangeInclusive<u32> = 1..=16;
|
||||
|
||||
/// The label every policy-governed sampler wears, and the whole basis on which the live refresh
|
||||
/// decides what to re-sample.
|
||||
///
|
||||
/// The reference splits textures the same way, by the `applyGlobal` flag `TextureCreate`
|
||||
/// (`0x449d90`) hands `0x449ae0`: terrain, WMO, M2 and the liquid sheets pass 1 and take the two
|
||||
/// globals, and six subsystems pass 0 and keep a mode of their own (weather sprites, the minimap,
|
||||
/// glue and loading screens, Lua texture widgets, the sky, footprint decals). A `1` here is this
|
||||
/// label, written by [`TexFilterSetting::descriptor`]; a `0` is any other sampler, which the
|
||||
/// refresh leaves alone. It reaches the GPU as the `wgpu` sampler's own label too, so a capture
|
||||
/// names them.
|
||||
pub const POLICY_SAMPLER_LABEL: &str = "wow_tex_policy";
|
||||
|
||||
/// `trilinear` and `anisotropic` resolved into the policy every sampler takes. Aniso outranks
|
||||
/// trilinear in either callback order: `anisotropic >= 2` writes mode 5 (`0x68919f`), and
|
||||
/// `trilinear` writes mode 4 (`0x688d0f`) only while the aniso bit is clear (`0x688d0d`).
|
||||
@@ -92,6 +111,55 @@ impl TexFilterSetting {
|
||||
}
|
||||
}
|
||||
|
||||
/// The sampler an `applyGlobal = 1` subsystem takes: the texture's own authored address mode,
|
||||
/// linear magnification and minification, and this policy's mip filter and anisotropy. The one
|
||||
/// constructor for a governed sampler, so [`POLICY_SAMPLER_LABEL`] cannot be forgotten on a
|
||||
/// lane and silently opt that lane out of every later change.
|
||||
pub fn descriptor(self, repeat_u: bool, repeat_v: bool) -> ImageSamplerDescriptor {
|
||||
// The M2 texture record's own bits (`flags & 0x1/0x2`): a cutout card authors UVs past
|
||||
// `0..1` to clamp onto its transparent border, where repeat folds its opaque middle in.
|
||||
let mode = |repeat: bool| {
|
||||
if repeat {
|
||||
ImageAddressMode::Repeat
|
||||
} else {
|
||||
ImageAddressMode::ClampToEdge
|
||||
}
|
||||
};
|
||||
ImageSamplerDescriptor {
|
||||
label: Some(POLICY_SAMPLER_LABEL.to_owned()),
|
||||
address_mode_u: mode(repeat_u),
|
||||
address_mode_v: mode(repeat_v),
|
||||
mag_filter: ImageFilterMode::Linear,
|
||||
min_filter: ImageFilterMode::Linear,
|
||||
mipmap_filter: self.mipmap_filter(),
|
||||
anisotropy_clamp: self.anisotropy_clamp(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-stamp the two fields a policy owns onto a descriptor that already exists, leaving every
|
||||
/// other field — the address mode above all — exactly as its lane authored it. The live
|
||||
/// refresh's one edit: it re-reads each resident texture's own descriptor and changes only
|
||||
/// this much, so a lane that wants a different wrap or lod clamp keeps it.
|
||||
pub fn restamp(self, desc: &mut ImageSamplerDescriptor) {
|
||||
desc.mipmap_filter = self.mipmap_filter();
|
||||
desc.anisotropy_clamp = self.anisotropy_clamp();
|
||||
}
|
||||
|
||||
/// The policy as one non-zero word; bit 0 marks it published, so `0` can mean "nobody has".
|
||||
fn packed(self) -> u32 {
|
||||
let aniso = self.aniso.clamp(*ANISO_RANGE.start(), *ANISO_RANGE.end());
|
||||
1 | u32::from(self.trilinear) << 1 | aniso << 2
|
||||
}
|
||||
|
||||
/// [`Self::packed`] read back; `None` for the unpublished `0`.
|
||||
fn unpack(word: u32) -> Option<Self> {
|
||||
(word & 1 != 0).then_some(Self {
|
||||
trilinear: word & 2 != 0,
|
||||
aniso: word >> 2,
|
||||
})
|
||||
}
|
||||
|
||||
/// The max anisotropy, `(mode == 5 ? [0x835254] : 1)` (`0x449afc`). wgpu takes a clamp above 1
|
||||
/// only with every filter `Linear`, which mode 5 always has.
|
||||
pub fn anisotropy_clamp(self) -> u16 {
|
||||
@@ -103,18 +171,58 @@ impl TexFilterSetting {
|
||||
}
|
||||
}
|
||||
|
||||
static POLICY: OnceLock<TexFilterSetting> = OnceLock::new();
|
||||
/// The live policy as one word, so every loader lane reads it per texture without a lock. `0` is
|
||||
/// "nothing published yet", which is why [`TexFilterSetting::packed`] always sets bit 0.
|
||||
static POLICY: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Publish the resolved policy once, from the CVar load (`benilla_app::cvars::load_config`, the
|
||||
/// Bumped by [`publish_tex_filter`] whenever the published value actually moves. The live refresh
|
||||
/// keys off this rather than off change detection, so it does not matter which world, which
|
||||
/// schedule or which observer did the publishing.
|
||||
static REVISION: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// The unpublished fallback, resolved once: [`TexFilterSetting::default`] reads the environment,
|
||||
/// and this is on the path of every texture load.
|
||||
static UNPUBLISHED: OnceLock<TexFilterSetting> = OnceLock::new();
|
||||
|
||||
/// Publish the resolved policy, from the CVar load (`benilla_app::cvars::load_config`, the
|
||||
/// `CvarLoad` set): after `config.toml` is folded in, before any system can request a texture.
|
||||
///
|
||||
/// Called again on every `trilinear` / `anisotropic` write (`benilla_app::video::on_cvar`), which
|
||||
/// is what makes the setting live — see [`crate::tex_filter_live`].
|
||||
pub fn publish_tex_filter(setting: TexFilterSetting) {
|
||||
let _ = POLICY.set(setting);
|
||||
let word = setting.packed();
|
||||
// `swap`, not a load-then-store: two writers would otherwise agree on the value and disagree
|
||||
// on whether it moved, and a lost bump is a refresh that never runs.
|
||||
if POLICY.swap(word, Ordering::Relaxed) != word {
|
||||
REVISION.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// The policy every sampler takes; unpublished (the world viewer, a headless test), it is
|
||||
/// [`TexFilterSetting::default`], a fresh install's value.
|
||||
pub fn tex_filter() -> TexFilterSetting {
|
||||
*POLICY.get_or_init(TexFilterSetting::default)
|
||||
TexFilterSetting::unpack(POLICY.load(Ordering::Relaxed))
|
||||
.unwrap_or_else(|| *UNPUBLISHED.get_or_init(TexFilterSetting::default))
|
||||
}
|
||||
|
||||
/// This image's governed sampler descriptor, or `None` when it keeps a mode of its own.
|
||||
///
|
||||
/// The one place the `applyGlobal` split is decided at runtime, read by the live refresh
|
||||
/// (`benilla_world::tex_filter_live`) over every resident `Image`. A descriptor built anywhere but
|
||||
/// [`TexFilterSetting::descriptor`] is not governed, by construction: it has no label to match.
|
||||
pub fn governed_sampler(image: &Image) -> Option<&ImageSamplerDescriptor> {
|
||||
match &image.sampler {
|
||||
ImageSampler::Descriptor(desc) if desc.label.as_deref() == Some(POLICY_SAMPLER_LABEL) => {
|
||||
Some(desc)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// How many times the published policy has moved. Compared against a remembered copy; never
|
||||
/// interpreted, so the wrap at `u32::MAX` is only a missed refresh after four billion writes.
|
||||
pub fn tex_filter_revision() -> u32 {
|
||||
REVISION.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -188,6 +296,188 @@ mod tests {
|
||||
assert_eq!(s.mode(), 3);
|
||||
}
|
||||
|
||||
/// The word every lane reads, both ways, over the whole declared range — and `0` reserved,
|
||||
/// since that is the only thing standing between an unpublished process and a policy that
|
||||
/// claims trilinear is off.
|
||||
#[test]
|
||||
fn the_packed_word_round_trips_and_zero_stays_unpublished() {
|
||||
assert_eq!(TexFilterSetting::unpack(0), None);
|
||||
for trilinear in [false, true] {
|
||||
for aniso in ANISO_RANGE {
|
||||
let s = TexFilterSetting { trilinear, aniso };
|
||||
let word = s.packed();
|
||||
assert_ne!(word, 0, "{s:?} packed to the unpublished sentinel");
|
||||
assert_eq!(TexFilterSetting::unpack(word), Some(s));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An out-of-range `aniso` cannot pack into a word that reads back as a different setting;
|
||||
/// the clamp is the reference's own (`0x689110`).
|
||||
#[test]
|
||||
fn packing_clamps_anisotropy_into_the_references_range() {
|
||||
for (asked, want) in [(0, 1), (17, 16), (u32::MAX, 16)] {
|
||||
let s = TexFilterSetting {
|
||||
trilinear: true,
|
||||
aniso: asked,
|
||||
};
|
||||
assert_eq!(
|
||||
TexFilterSetting::unpack(s.packed()).map(|p| p.aniso),
|
||||
Some(want),
|
||||
"aniso {asked}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The governed sampler: the label is what the live refresh selects on, and the two policy
|
||||
/// fields are the only thing [`TexFilterSetting::restamp`] may move.
|
||||
#[test]
|
||||
fn a_governed_descriptor_wears_the_label_and_restamps_only_the_policy() {
|
||||
let off = TexFilterSetting {
|
||||
trilinear: true,
|
||||
aniso: 1,
|
||||
};
|
||||
let mut desc = off.descriptor(true, false);
|
||||
assert_eq!(desc.label.as_deref(), Some(POLICY_SAMPLER_LABEL));
|
||||
assert_eq!(desc.address_mode_u, ImageAddressMode::Repeat);
|
||||
assert_eq!(desc.address_mode_v, ImageAddressMode::ClampToEdge);
|
||||
assert_eq!(desc.anisotropy_clamp, 1);
|
||||
|
||||
let on = TexFilterSetting {
|
||||
trilinear: true,
|
||||
aniso: 16,
|
||||
};
|
||||
on.restamp(&mut desc);
|
||||
assert_eq!(desc.anisotropy_clamp, 16);
|
||||
assert_eq!(desc.mipmap_filter, ImageFilterMode::Linear);
|
||||
// Everything else is the lane's, above all the wrap: a re-stamp that reset the address
|
||||
// mode would fold a cutout card's transparent border into its opaque middle.
|
||||
assert_eq!(desc.label.as_deref(), Some(POLICY_SAMPLER_LABEL));
|
||||
assert_eq!(desc.address_mode_u, ImageAddressMode::Repeat);
|
||||
assert_eq!(desc.address_mode_v, ImageAddressMode::ClampToEdge);
|
||||
// And a re-stamp is exactly what the constructor would have built.
|
||||
assert_eq!(desc, on.descriptor(true, false));
|
||||
}
|
||||
|
||||
/// Publishing is live and idempotent — the two properties the refresh rests on. It moves
|
||||
/// process state, so it is one test rather than several, and it does not restore: no other
|
||||
/// test in this crate reads the published policy (the image lanes do, but no test asserts on
|
||||
/// what they produce).
|
||||
#[test]
|
||||
fn publishing_is_live_and_bumps_the_revision_only_on_a_real_move() {
|
||||
let a = TexFilterSetting {
|
||||
trilinear: true,
|
||||
aniso: 4,
|
||||
};
|
||||
publish_tex_filter(a);
|
||||
assert_eq!(tex_filter(), a, "a publish is readable at once");
|
||||
let after_first = tex_filter_revision();
|
||||
|
||||
publish_tex_filter(a);
|
||||
assert_eq!(
|
||||
tex_filter_revision(),
|
||||
after_first,
|
||||
"re-publishing the same value must not ask for a refresh"
|
||||
);
|
||||
|
||||
let b = TexFilterSetting {
|
||||
trilinear: false,
|
||||
aniso: 16,
|
||||
};
|
||||
publish_tex_filter(b);
|
||||
assert_eq!(
|
||||
tex_filter(),
|
||||
b,
|
||||
"a second publish wins — this is not a latch"
|
||||
);
|
||||
assert_ne!(
|
||||
tex_filter_revision(),
|
||||
after_first,
|
||||
"a move has to bump the revision, or nothing re-samples"
|
||||
);
|
||||
}
|
||||
|
||||
/// The `applyGlobal` split, over the real lanes rather than over a claim about them: every
|
||||
/// image this crate builds, sorted by whether the live refresh will re-sample it.
|
||||
///
|
||||
/// This is the test that catches the failure that would otherwise be silent — a lane that
|
||||
/// builds its sampler by hand and so wears no label, leaving the ground or every model holding
|
||||
/// a stale sampler with nothing to say so. It is also the statement of which textures the
|
||||
/// reference's own `0x449ae0` governs: terrain, WMO/M2, and the liquid sheets take the two
|
||||
/// globals; weather sprites, the minimap, glue, Lua widgets, the sky and decals do not.
|
||||
#[test]
|
||||
fn exactly_the_reference_s_apply_global_lanes_are_governed() {
|
||||
use crate::gpu_blp::{for_upload, UploadChain};
|
||||
use benilla_formats::{BlpMipChain, BlpTexels};
|
||||
use bevy::render::render_resource::TextureFormat;
|
||||
|
||||
let chain = |w: u32, h: u32| BlpMipChain {
|
||||
width: w,
|
||||
height: h,
|
||||
texels: BlpTexels::Rgba8Unorm,
|
||||
mips: vec![vec![0u8; (w * h * 4) as usize]],
|
||||
};
|
||||
let upload = |w: u32, h: u32| -> UploadChain { for_upload(chain(w, h)) };
|
||||
let rgba = |w: u32, h: u32| vec![0u8; (w * h * 4) as usize];
|
||||
|
||||
// Governed: the four lanes that pass `applyGlobal = 1`.
|
||||
let governed: Vec<(&str, Image)> = vec![
|
||||
(
|
||||
"blp loader world art",
|
||||
crate::blp::world_art_image(upload(8, 8), (true, false)),
|
||||
),
|
||||
(
|
||||
"WorldAssets model texture",
|
||||
crate::repeat_texture_authored(upload(8, 8), (true, true)),
|
||||
),
|
||||
(
|
||||
"terrain layer array",
|
||||
crate::terrain::layer_array_image(
|
||||
4,
|
||||
1,
|
||||
crate::terrain::PackedLayers {
|
||||
data: rgba(4, 4),
|
||||
format: TextureFormat::Rgba8Unorm,
|
||||
mip_count: 1,
|
||||
},
|
||||
),
|
||||
),
|
||||
(
|
||||
"liquid frame array",
|
||||
crate::liquid_frame_array(vec![chain(4, 4)], false),
|
||||
),
|
||||
];
|
||||
for (what, image) in &governed {
|
||||
let desc = governed_sampler(image).unwrap_or_else(|| {
|
||||
panic!(
|
||||
"{what}: builds its sampler outside `descriptor()`, so \
|
||||
the live refresh will never re-sample it"
|
||||
)
|
||||
});
|
||||
assert_eq!(desc.mag_filter, ImageFilterMode::Linear, "{what}");
|
||||
}
|
||||
|
||||
// Its own mode: the `applyGlobal = 0` subsystems, which a filter change must not touch.
|
||||
let ungoverned: Vec<(&str, Image)> = vec![
|
||||
("minimap tile", crate::blp::map_tile_image(4, 4, rgba(4, 4))),
|
||||
("glue sprite", crate::blp::sprite_image(4, 4, rgba(4, 4))),
|
||||
("effect sprite", crate::blp::effect_image(4, 4, rgba(4, 4))),
|
||||
(
|
||||
"weather point sprite",
|
||||
crate::blp::point_sprite_image(upload(4, 4)),
|
||||
),
|
||||
("Lua texture widget", crate::sprite_image(4, 4, rgba(4, 4))),
|
||||
("unit portrait", crate::portrait_image(4, 4, rgba(4, 4))),
|
||||
];
|
||||
for (what, image) in &ungoverned {
|
||||
assert!(
|
||||
governed_sampler(image).is_none(),
|
||||
"{what}: wears the policy label, so a filter change would re-sample a texture \
|
||||
the reference leaves alone"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clamp_above_one_only_ever_rides_all_linear_filters() {
|
||||
for s in [
|
||||
|
||||
@@ -38,26 +38,10 @@ pub fn repeat_texture_authored(upload: crate::gpu_blp::UploadChain, wrap: (bool,
|
||||
);
|
||||
image.data = Some(data);
|
||||
image.texture_descriptor.mip_level_count = levels;
|
||||
// The M2 texture record's own address mode (`flags & 0x1/0x2`): a cutout card authors UVs
|
||||
// past `0..1` to clamp onto its transparent border, where repeat folds its opaque middle in.
|
||||
let mode = |repeat: bool| {
|
||||
if repeat {
|
||||
ImageAddressMode::Repeat
|
||||
} else {
|
||||
ImageAddressMode::ClampToEdge
|
||||
}
|
||||
};
|
||||
// M2, WMO and character textures take the process filter policy (`0x449ae0`, applyGlobal 1).
|
||||
let filter = crate::tex_filter::tex_filter();
|
||||
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
|
||||
address_mode_u: mode(wrap.0),
|
||||
address_mode_v: mode(wrap.1),
|
||||
mag_filter: ImageFilterMode::Linear,
|
||||
min_filter: ImageFilterMode::Linear,
|
||||
mipmap_filter: filter.mipmap_filter(),
|
||||
anisotropy_clamp: filter.anisotropy_clamp(),
|
||||
..default()
|
||||
});
|
||||
// M2, WMO and character textures take the process filter policy (`0x449ae0`, applyGlobal 1);
|
||||
// the wrap pair is the M2 texture record's own (`flags & 0x1/0x2`).
|
||||
image.sampler =
|
||||
ImageSampler::Descriptor(crate::tex_filter::tex_filter().descriptor(wrap.0, wrap.1));
|
||||
image
|
||||
}
|
||||
|
||||
@@ -193,16 +177,8 @@ pub fn liquid_frame_array(frames: Vec<BlpMipChain>, normalize_dc: bool) -> Image
|
||||
..default()
|
||||
});
|
||||
// The liquid sheets take the process filter policy (`0x68abab`, applyGlobal 1), as ground does.
|
||||
let filter = crate::tex_filter::tex_filter();
|
||||
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
|
||||
address_mode_u: ImageAddressMode::Repeat,
|
||||
address_mode_v: ImageAddressMode::Repeat,
|
||||
mag_filter: ImageFilterMode::Linear,
|
||||
min_filter: ImageFilterMode::Linear,
|
||||
mipmap_filter: filter.mipmap_filter(),
|
||||
anisotropy_clamp: filter.anisotropy_clamp(),
|
||||
..default()
|
||||
});
|
||||
image.sampler =
|
||||
ImageSampler::Descriptor(crate::tex_filter::tex_filter().descriptor(true, true));
|
||||
image
|
||||
}
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ pub mod sun;
|
||||
pub mod surface;
|
||||
pub mod sys_timing;
|
||||
pub mod terrain_stream;
|
||||
pub mod tex_filter_live;
|
||||
pub mod thread_qos;
|
||||
pub mod view;
|
||||
pub mod vis_chain;
|
||||
|
||||
@@ -198,7 +198,8 @@ struct GxCellGpu {
|
||||
/// One bind group per pool class the region's items touch: (pool class, bind group).
|
||||
bind_groups: Vec<(u16, BindGroup)>,
|
||||
record_table: Buffer,
|
||||
#[allow(dead_code)] // held alive for the bind groups that reference it
|
||||
/// Held alive for the bind groups that reference it, and re-bound by
|
||||
/// [`refresh_gx_samplers`] when the texture filter moves.
|
||||
cell_uniform: Buffer,
|
||||
/// Per item, its index into `bind_groups`, kept for kill-driven run rebuilds.
|
||||
item_slot: Vec<u16>,
|
||||
@@ -316,19 +317,7 @@ fn init_pipelines(
|
||||
),
|
||||
),
|
||||
);
|
||||
let filter = benilla_assets::tex_filter();
|
||||
let make = |label: &'static str, mode: AddressMode| {
|
||||
render_device.create_sampler(&SamplerDescriptor {
|
||||
label: Some(label),
|
||||
min_filter: FilterMode::Linear,
|
||||
mag_filter: FilterMode::Linear,
|
||||
mipmap_filter: filter.gpu_mipmap_filter(),
|
||||
address_mode_u: mode,
|
||||
address_mode_v: mode,
|
||||
anisotropy_clamp: filter.anisotropy_clamp(),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let make = |label: &'static str, mode: AddressMode| gx_sampler(&render_device, label, mode);
|
||||
// MONKEY (room gate): the claim table's persistent GPU buffer, written every frame by
|
||||
// `prepare_room_claims`. Created here (RenderStartup) rather than beside the shared light
|
||||
// buffer so the whole binding — layout, buffer, upload — lives with its one reader.
|
||||
@@ -345,8 +334,8 @@ fn init_pipelines(
|
||||
cell_layout,
|
||||
light_layout,
|
||||
torch_layout,
|
||||
sampler: make("static_gx_repeat", AddressMode::Repeat),
|
||||
sampler_clamp: make("static_gx_clamp", AddressMode::ClampToEdge),
|
||||
sampler: make(GX_SAMPLER_REPEAT, AddressMode::Repeat),
|
||||
sampler_clamp: make(GX_SAMPLER_CLAMP, AddressMode::ClampToEdge),
|
||||
pipelines: HashMap::default(),
|
||||
specialized_for: None,
|
||||
});
|
||||
@@ -402,6 +391,75 @@ fn vertex_layout() -> VertexBufferLayout {
|
||||
}
|
||||
}
|
||||
|
||||
const GX_SAMPLER_REPEAT: &str = "static_gx_repeat";
|
||||
const GX_SAMPLER_CLAMP: &str = "static_gx_clamp";
|
||||
|
||||
/// One of static_gx's two samplers at the live texture-filter policy. static_gx does not sample
|
||||
/// a `GpuImage` — it copies art into its own pool arrays — so it carries its own pair rather than
|
||||
/// wearing [`benilla_assets::POLICY_SAMPLER_LABEL`], and refreshes them itself below.
|
||||
fn gx_sampler(render_device: &RenderDevice, label: &'static str, mode: AddressMode) -> Sampler {
|
||||
let filter = benilla_assets::tex_filter();
|
||||
render_device.create_sampler(&SamplerDescriptor {
|
||||
label: Some(label),
|
||||
min_filter: FilterMode::Linear,
|
||||
mag_filter: FilterMode::Linear,
|
||||
mipmap_filter: filter.gpu_mipmap_filter(),
|
||||
address_mode_u: mode,
|
||||
address_mode_v: mode,
|
||||
anisotropy_clamp: filter.anisotropy_clamp(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-sample static_gx when `trilinear` or `anisotropic` moves: a fresh pair of samplers, and
|
||||
/// every cached region's bind groups built again around them.
|
||||
///
|
||||
/// Rebuilt, not dropped. Clearing [`GxGpuCache`] would be one line, but a region with no GPU state
|
||||
/// does not draw — every building in view would blink out and then trickle back under the bake
|
||||
/// budget. Everything a cell bind group needs is already on the cached region (its uniform, its
|
||||
/// record table) or in the pool (the class view), so this is five bindings per group and no bake,
|
||||
/// no upload and no re-assembly of anything.
|
||||
fn refresh_gx_samplers(
|
||||
pipes: &mut GxPipelines,
|
||||
cache: &mut GxGpuCache,
|
||||
pool: &GxTexturePool,
|
||||
pipeline_cache: &PipelineCache,
|
||||
render_device: &RenderDevice,
|
||||
) {
|
||||
pipes.sampler = gx_sampler(render_device, GX_SAMPLER_REPEAT, AddressMode::Repeat);
|
||||
pipes.sampler_clamp = gx_sampler(render_device, GX_SAMPLER_CLAMP, AddressMode::ClampToEdge);
|
||||
let cell_layout = pipeline_cache.get_bind_group_layout(&pipes.cell_layout);
|
||||
let mut rebuilt = 0usize;
|
||||
for gpu in cache
|
||||
.cells
|
||||
.values_mut()
|
||||
.chain(cache.wmos.values_mut())
|
||||
.chain(cache.props.values_mut())
|
||||
{
|
||||
let GxCellGpu {
|
||||
bind_groups,
|
||||
record_table,
|
||||
cell_uniform,
|
||||
..
|
||||
} = gpu;
|
||||
for (class, bind_group) in bind_groups.iter_mut() {
|
||||
*bind_group = render_device.create_bind_group(
|
||||
"static_gx_cell",
|
||||
&cell_layout,
|
||||
&BindGroupEntries::sequential((
|
||||
cell_uniform.as_entire_binding(),
|
||||
record_table.as_entire_binding(),
|
||||
pool.view(*class),
|
||||
&pipes.sampler,
|
||||
&pipes.sampler_clamp,
|
||||
)),
|
||||
);
|
||||
rebuilt += 1;
|
||||
}
|
||||
}
|
||||
info!("static_gx: {rebuilt} cell bind groups re-sampled at the new texture filter");
|
||||
}
|
||||
|
||||
/// Specialize the four pipelines for the world view, and assemble visible regions' GPU state.
|
||||
fn prepare_static_gx(
|
||||
gx: Res<GxWorld>,
|
||||
@@ -419,9 +477,23 @@ fn prepare_static_gx(
|
||||
// because `ExtractResourcePlugin` only publishes it while the lighting plugin is in the app
|
||||
// (a bare static_gx harness has no lighting) — absent reads as the shipped Gaussian.
|
||||
shadow_filter: Option<Res<crate::lighting::ShadowFilterGaussian>>,
|
||||
// The texture-filter revision this system last saw; `Local` for the same reason
|
||||
// `specialized_for` is a field — once per CHANGE, not per frame.
|
||||
mut filter_seen: Local<Option<u32>>,
|
||||
) {
|
||||
let shadow_gaussian = shadow_filter.is_none_or(|f| f.0);
|
||||
let _t = super::gx_perf_guard(3);
|
||||
// Ahead of the no-view return: a policy change while nothing is drawn still has to land, or
|
||||
// it would be dropped and the next change would be the only one that counted.
|
||||
if crate::tex_filter_live::revision_moved(&mut filter_seen) {
|
||||
refresh_gx_samplers(
|
||||
&mut pipes,
|
||||
&mut cache,
|
||||
&pool,
|
||||
&pipeline_cache,
|
||||
&render_device,
|
||||
);
|
||||
}
|
||||
let Some((view, msaa, _, _)) = views.iter().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
//! The live half of the texture-filter policy: `trilinear` and `anisotropic` applied to textures
|
||||
//! that are *already resident*, so the two settings move in the session instead of at the next
|
||||
//! launch.
|
||||
//!
|
||||
//! The reference could not do this. Its sampler state lived inside each `TextureCreate` call
|
||||
//! (`0x449ae0` reads the two globals and bakes the result into the texture object) and nothing
|
||||
//! kept a list of live textures, which is why its own video options say "enabled upon restart".
|
||||
//! Two things let us:
|
||||
//!
|
||||
//! 1. A governed sampler wears [`benilla_assets::POLICY_SAMPLER_LABEL`], written by the one
|
||||
//! constructor [`TexFilterSetting::descriptor`]. That is how this module tells a texture that
|
||||
//! takes the policy from one of the six `applyGlobal = 0` subsystems that keeps its own mode.
|
||||
//! 2. Extraction of a `RENDER_WORLD`-only `Image` takes its *pixels* and leaves the asset behind
|
||||
//! (`bevy_render`'s `extract_render_asset` calls `take_gpu_data`, which `Option::take`s
|
||||
//! `image.data`). So every resident texture's own `ImageSamplerDescriptor` is still readable in
|
||||
//! the main world, for nothing, at any time — there is no ledger to keep and none to go stale.
|
||||
//!
|
||||
//! The work is then two halves that have to land in the same frame:
|
||||
//!
|
||||
//! - [`refresh_policy_samplers`] (render world, `ExtractSchedule`) replaces `GpuImage::sampler` on
|
||||
//! every governed texture. A `wgpu` sampler is immutable, so this is a fresh one per texture
|
||||
//! built from that texture's own descriptor with only the policy's two fields re-stamped.
|
||||
//! - [`invalidate_policy_materials`] (main world) touches every material asset of the four types
|
||||
//! that bind a governed texture. A bind group captured the OLD sampler when it was built, and
|
||||
//! nothing in Bevy invalidates one because an image it names changed; a `get_mut` raises
|
||||
//! `AssetEvent::Modified`, which is what makes `prepare_materials` build it again.
|
||||
//!
|
||||
//! Both key off [`benilla_assets::tex_filter_revision`] with a remembered copy, so they fire on
|
||||
//! the same revision in the same frame — and the render half runs in extract, which is before
|
||||
//! every `Prepare`, so the rebuilt bind groups read the new samplers rather than racing them.
|
||||
//!
|
||||
//! What this costs is paid once per change, and it is why the Options row commits on Apply rather
|
||||
//! than on the drag: one sampler per resident texture and one bind group per material. Nothing is
|
||||
//! re-uploaded — the pixels never move — so it is allocation and descriptor work, not bandwidth.
|
||||
//!
|
||||
//! `static_gx` is not here. It builds its own bind groups from two samplers of its own
|
||||
//! (`GxPipelines`), so it refreshes itself off the same revision
|
||||
//! (`static_gx::render::refresh_gx_samplers`).
|
||||
//!
|
||||
//! One deliberate gap: `blp::point_sprite_image` (weather's point sprites) reads the policy's mip
|
||||
//! filter at load without taking its anisotropy, and carries no label, so it is not refreshed. It
|
||||
//! is an `applyGlobal = 0` subsystem in the reference — a hard-coded mode 4 — so what it keeps is
|
||||
//! a filter it should arguably never have read, not one a change ought to move.
|
||||
//! `exactly_the_reference_s_apply_global_lanes_are_governed` names it on that side of the split.
|
||||
|
||||
use benilla_assets::materials::{LiquidMaterial, TerrainMaterial, WdlMaterial, WowModelMaterial};
|
||||
use benilla_assets::{governed_sampler, tex_filter, tex_filter_revision};
|
||||
use bevy::asset::{Asset, AssetId};
|
||||
use bevy::image::Image;
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_asset::RenderAssets;
|
||||
use bevy::render::renderer::RenderDevice;
|
||||
use bevy::render::texture::GpuImage;
|
||||
use bevy::render::{Extract, ExtractSchedule, RenderApp};
|
||||
|
||||
/// Wires both halves. Added by [`crate::world_plugins::WorldPlugins`] after the four
|
||||
/// `MaterialPlugin`s, since the main-world half writes their `Assets<M>`.
|
||||
pub struct TexFilterLivePlugin;
|
||||
|
||||
impl Plugin for TexFilterLivePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
// `Last`: after anything that could write the CVar this frame (the observer runs on the
|
||||
// write, the Options row's Apply is a UI frame), so the touch and the extract that follows
|
||||
// it are one frame apart from nothing.
|
||||
//
|
||||
// The four world-art types, which are every material in `benilla-assets` — ground, M2 and
|
||||
// WMO, the liquid sheets, the low-detail horizon. Lanes outside this crate register their
|
||||
// own beside their own `MaterialPlugin`.
|
||||
app.add_systems(
|
||||
Last,
|
||||
(
|
||||
invalidate_materials::<TerrainMaterial>,
|
||||
invalidate_materials::<WowModelMaterial>,
|
||||
invalidate_materials::<LiquidMaterial>,
|
||||
invalidate_materials::<WdlMaterial>,
|
||||
),
|
||||
);
|
||||
// A headless app (`benilla-worldview`'s tests, the booth) has no render app; the main-world
|
||||
// half is harmless there and the render half simply never exists.
|
||||
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
|
||||
render_app.add_systems(ExtractSchedule, refresh_policy_samplers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a remembered revision has moved, seeding itself on the first call.
|
||||
///
|
||||
/// The seed is why nothing happens at boot: every texture resident at the first frame was sampled
|
||||
/// at the policy the CVar load published, so there is nothing to re-stamp, and re-stamping anyway
|
||||
/// would spend a bind-group rebuild of the whole world during the world load. The cost is that two
|
||||
/// publishes before the first frame read as one — which needs a `SetCVar` from an addon in the
|
||||
/// same startup as `config.toml`, and the textures that load after it take the new policy at load.
|
||||
pub(crate) fn revision_moved(seen: &mut Option<u32>) -> bool {
|
||||
let now = tex_filter_revision();
|
||||
seen.replace(now).is_some_and(|was| was != now)
|
||||
}
|
||||
|
||||
/// Re-sample every resident governed texture. See the module doc for why the descriptors are still
|
||||
/// readable and why this sits in `ExtractSchedule`.
|
||||
fn refresh_policy_samplers(
|
||||
images: Extract<Res<Assets<Image>>>,
|
||||
mut gpu_images: ResMut<RenderAssets<GpuImage>>,
|
||||
device: Res<RenderDevice>,
|
||||
mut seen: Local<Option<u32>>,
|
||||
) {
|
||||
if !revision_moved(&mut seen) {
|
||||
return;
|
||||
}
|
||||
let filter = tex_filter();
|
||||
let mut restamped = 0usize;
|
||||
for (id, image) in images.iter() {
|
||||
let Some(desc) = governed_sampler(image) else {
|
||||
continue;
|
||||
};
|
||||
// Not yet prepared (loaded this frame): it will be built from the live policy anyway.
|
||||
let Some(gpu) = gpu_images.get_mut(id) else {
|
||||
continue;
|
||||
};
|
||||
let mut desc = desc.clone();
|
||||
filter.restamp(&mut desc);
|
||||
gpu.sampler = device.create_sampler(&desc.as_wgpu());
|
||||
restamped += 1;
|
||||
}
|
||||
info!(
|
||||
"tex filter: re-sampled {restamped} resident textures — mode {}, anisotropy {}",
|
||||
filter.mode(),
|
||||
filter.anisotropy_clamp()
|
||||
);
|
||||
}
|
||||
|
||||
/// Raise `AssetEvent::Modified` on every asset of one material type when the policy moves, so its
|
||||
/// bind group is built again against the samplers [`refresh_policy_samplers`] has just replaced.
|
||||
///
|
||||
/// **Register this for every material whose `AsBindGroup` names a policy-governed image**, in the
|
||||
/// crate that owns it — `register_invalidation` does it for the four world-art types here, and
|
||||
/// `benilla_app::world_shadow` for the cutout shadow caster, which deliberately reuses the forward
|
||||
/// pass's own leaf handle.
|
||||
///
|
||||
/// An omission is silent in the worst way: the texture is re-sampled, so a *newly* built bind group
|
||||
/// is right and the one already in the cache is wrong, which means part of the frame changes and
|
||||
/// part does not. Finding one is a measurement, not an assertion — the boot-vs-late capture A/B in
|
||||
/// `docs/GRAPHICS.md` is what caught the shadow caster, as a dapple of stale tree shadow on an
|
||||
/// otherwise correct road.
|
||||
pub fn invalidate_materials<A: Asset>(mut seen: Local<Option<u32>>, mut assets: ResMut<Assets<A>>) {
|
||||
if !revision_moved(&mut seen) {
|
||||
return;
|
||||
}
|
||||
// Collected first: `get_mut` borrows the collection, and the `Modified` event it raises is the
|
||||
// whole point of the call — nothing here reads or writes the material itself.
|
||||
let ids: Vec<AssetId<A>> = assets.ids().collect();
|
||||
for id in &ids {
|
||||
let _ = assets.get_mut(*id);
|
||||
}
|
||||
info!(
|
||||
"tex filter: {} {} bind groups queued for rebuild",
|
||||
ids.len(),
|
||||
// `ExtendedMaterial<StandardMaterial, LiquidExt>` ends in the extension's name and a
|
||||
// closing angle bracket; the bracket is what makes the log read "LiquidExt>".
|
||||
core::any::type_name::<A>()
|
||||
.trim_end_matches('>')
|
||||
.rsplit("::")
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The seed, which is the whole reason nothing re-samples the world during the world load:
|
||||
/// the first look at the revision only remembers it.
|
||||
#[test]
|
||||
fn a_remembered_revision_seeds_itself_before_it_ever_reports_a_move() {
|
||||
let mut seen = None;
|
||||
assert!(
|
||||
!revision_moved(&mut seen),
|
||||
"the first call seeds; acting on it would rebuild every bind group at boot"
|
||||
);
|
||||
assert!(
|
||||
!revision_moved(&mut seen),
|
||||
"and a revision that has not moved keeps reporting nothing"
|
||||
);
|
||||
benilla_assets::publish_tex_filter(benilla_assets::TexFilterSetting {
|
||||
trilinear: true,
|
||||
aniso: if tex_filter().aniso == 8 { 2 } else { 8 },
|
||||
});
|
||||
assert!(revision_moved(&mut seen), "a publish has to be seen once");
|
||||
assert!(!revision_moved(&mut seen), "and only once");
|
||||
}
|
||||
|
||||
/// [`TexFilterLivePlugin`] names its four world-art material types by hand, and a fifth in
|
||||
/// `benilla-assets` would be a silent omission — the ground, or every model, keeping a stale
|
||||
/// sampler with nothing to say so. (A material in another crate is that crate's to register;
|
||||
/// this guards only the set that lives beside the lanes.)
|
||||
///
|
||||
/// `benilla-assets`' `materials.rs` is the home of every material that draws world art, which
|
||||
/// is the same thing as every material that binds an image sampled under the policy (ground,
|
||||
/// M2 and WMO, the liquid sheets, the low-detail horizon). The other `ExtendedMaterial`s in
|
||||
/// the tree — sky, clouds, sun, stars — live in `benilla-world` beside their own lanes and
|
||||
/// carry their own samplers, so this scan is deliberately that one file.
|
||||
#[test]
|
||||
fn the_four_invalidated_types_are_every_world_art_material_there_is() {
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../benilla-assets/src/materials.rs");
|
||||
let src = std::fs::read_to_string(&path).expect("benilla-assets materials.rs is readable");
|
||||
let mut declared: Vec<&str> = src
|
||||
.lines()
|
||||
.filter_map(|l| l.trim().strip_prefix("pub type "))
|
||||
.filter(|l| l.contains("= ExtendedMaterial<"))
|
||||
.filter_map(|l| l.split_whitespace().next())
|
||||
.collect();
|
||||
declared.sort_unstable();
|
||||
|
||||
let mut invalidated = vec![
|
||||
"TerrainMaterial",
|
||||
"WowModelMaterial",
|
||||
"LiquidMaterial",
|
||||
"WdlMaterial",
|
||||
];
|
||||
invalidated.sort_unstable();
|
||||
assert_eq!(
|
||||
declared,
|
||||
invalidated,
|
||||
"a world-art material was added or renamed in {}: add it to \
|
||||
`invalidate_policy_materials` too, or its bind groups keep the old sampler when \
|
||||
`trilinear` or `anisotropic` moves",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -139,9 +139,11 @@ impl Plugin for WorldFoundation {
|
||||
// `gxMultisample`, read once at the camera's spawn, as the reference latches it.
|
||||
.init_resource::<crate::view::MsaaSetting>()
|
||||
.init_resource::<crate::view::MsaaFormats>()
|
||||
// The texture filter (`trilinear`, `anisotropic`), read once at the CVar load: the
|
||||
// reference applies it on restart.
|
||||
// The texture filter (`trilinear`, `anisotropic`), published at the CVar load and on
|
||||
// every later write. Unlike the reference, which applies it on restart, a write
|
||||
// re-samples the textures that are already resident — the plugin below.
|
||||
.init_resource::<benilla_assets::TexFilterSetting>()
|
||||
.add_plugins(crate::tex_filter_live::TexFilterLivePlugin)
|
||||
// Its clamp to what this GPU accepts, in `finish()`, between the adapter and `Startup`.
|
||||
.add_plugins(crate::view::MsaaSupportPlugin)
|
||||
// The viewer's body, empty in a program with no avatar.
|
||||
|
||||
+31
-5
@@ -39,6 +39,13 @@ absolute difference, and the count of pixels that moved by more than 2. `WOW_CVA
|
||||
registered row for the session; `WOW_ANISO`, `WOW_FARCLIP`, `WOW_RENDER_SCALE`, `WOW_MSAA` are
|
||||
direct levers. Validated eye positions live in `benilla-app/src/capture/scenarios.rs`.
|
||||
|
||||
`WOW_CVAR_LATE=<name>=<value>` is the other half, and the only way to measure a setting that
|
||||
claims to apply **in the session**: it writes the CVar *after* the scene has settled, then re-settles
|
||||
the image around it. A latched lever and a live one produce the same boot picture, so a boot-time
|
||||
A/B cannot tell them apart; `WOW_CVAR_LATE` can. Anisotropy's live path is verified exactly this way
|
||||
— `WOW_CVAR=anisotropic=16` against `WOW_CVAR_LATE=anisotropic=16` is **byte-identical over all
|
||||
1,440,000 pixels**, which says the refresh reaches every texture the loader does and misses nothing.
|
||||
|
||||
**Always run a starved control.** A knob that forces the bad path, or an identical pair of renders,
|
||||
proves the harness is actually reaching the thing being measured. Five such pairs once matched byte
|
||||
for byte before anyone checked whether the view read the table at all.
|
||||
@@ -49,18 +56,37 @@ These need no code. They are the cheapest picture available today.
|
||||
|
||||
| Knob | Shipped | Worth | Measured cost | Measured change |
|
||||
|---|---|---|---|---|
|
||||
| `anisotropic` | 1 (off) at Classic/Low, **16 from High** | 16 | **+1.0 ms GPU, 0 main** | **34.1% of the frame**, peaking at 66.5% across the band where the ground recedes |
|
||||
| `anisotropic` | 1 (off) at Classic/Low, **16 from High**; Anisotropic Filtering on the Graphics page | 16 | **+1.0 ms GPU, 0 main** | **34.1% of the frame**, peaking at 66.5% across the band where the ground recedes |
|
||||
| `renderScale` | 1.0 (Render Scale slider in Options) | 1.25–1.5 | +1.5 ms GPU, 0 main | 19.4% of the frame |
|
||||
| `farclip` | 777 at High, 1497 at Ultra | taste | streaming and draws, i.e. **main thread** — measure before raising | — |
|
||||
|
||||
The anisotropic row is the one to take first: the reference ships it *off*, so ground texture at a
|
||||
grazing angle smears to mush a few yards out, which is most of an outdoor screen. It is a sampler
|
||||
state, so it is free on the CPU and costs a milligram of the GPU time we are not using. The sampler
|
||||
is baked into each texture at load, so a change lands on the **next launch** — the reference's own
|
||||
options window says the same ("enabled upon restart").
|
||||
state, so it is free on the CPU and costs a milligram of the GPU time we are not using.
|
||||
|
||||
It applies **in the session**, which the reference's own options window could not do ("enabled upon
|
||||
restart"). A sampler is still baked into each texture at load, but `benilla_world::tex_filter_live`
|
||||
re-stamps the resident ones when the policy moves: the selection is the
|
||||
`benilla_assets::POLICY_SAMPLER_LABEL` every governed sampler wears, and the two halves are one
|
||||
fresh sampler per resident texture (render world, `ExtractSchedule`) plus an `AssetEvent::Modified`
|
||||
on every material that binds one, because a bind group captured the old sampler and nothing in Bevy
|
||||
invalidates one for an image that changed. `static_gx` carries its own pair and refreshes itself off
|
||||
the same revision.
|
||||
|
||||
On the Elwynn vista above that is **873 samplers, 75 material bind groups and 264 static_gx cell
|
||||
bind groups**, once. Nothing is re-uploaded — the pixels never move — so it is descriptor work, not
|
||||
bandwidth. That is why the Options row commits on **Apply** rather than on the drag.
|
||||
|
||||
Every material that binds a governed image has to register
|
||||
`tex_filter_live::invalidate_materials::<M>`, and forgetting one is silent in the worst way: the
|
||||
texture is re-sampled, so a bind group built *after* the change is right and one already cached is
|
||||
wrong — part of the frame moves and part does not. `CutoutShadowCasterMaterial` was exactly that,
|
||||
and only the capture A/B found it: 6.7% of the frame, a dapple of stale tree shadow on a road that
|
||||
was otherwise correct.
|
||||
|
||||
A player already on a **Custom** Graphics Preset is not seeded by the ladder and keeps their saved
|
||||
value; `/console anisotropic 16` and a restart is the whole fix for them.
|
||||
value; the Graphics page slider, or `/console anisotropic 16`, is the whole fix for them, with no
|
||||
relaunch.
|
||||
|
||||
## What the picture is missing
|
||||
|
||||
|
||||
Reference in New Issue
Block a user