diff --git a/docs/dev/release-completion.md b/docs/dev/release-completion.md index f263106c..4c4095be 100644 --- a/docs/dev/release-completion.md +++ b/docs/dev/release-completion.md @@ -36,6 +36,7 @@ is `docs/dev/macos-moltenvk-decision.md`. ## Ready For Changelog +- [x] Players can now choose smooth, sharp-mip, or deliberately pixelated material-texture sampling with the restored archived `image_filter` setting. All six retail Quake 4 filter names work on OpenGL and experimental Vulkan, changes update loaded textures immediately, and nearest-texel modes avoid anisotropic blur without overriding explicitly filtered HUD, font, shadow, or internal texture sampling. This resolves GitHub issue #121. - [x] Milestone F now has a locally validated, default-off implementation for explicitly authored PBR materials, bounded OpenGL specular probes, and atomic OpenGL clustered decals. The 2026-08-23 current-source debug x64 evidence passed the static PBR/advanced-lighting checks, the final native 10/10 run, the final focused engine 2/2 rerun after an earlier 8/8 safe set, all four retail-PK4 compatibility roles, forced GL 3.3/4.1/4.3/4.5 PBR execution, the narrow validation-enabled Vulkan route, and exact `r_rendererModernQuality 0` rollback against leaf-disabled images. The stock baseline used all 40 retail PK4s with zero loose retail files; the generated PBR fixture remained temporary under `.tmp/`. PBR, probe, decal, and modern-visible controls stay default-off, `MODERN_LIGHTING_PARITY_PROVEN_DOMAINS` stays `0`, and this local implementation exit is not final committed-package/platform/driver promotion or broad authored probe/decal visual qualification. - [x] Milestone F's remaining quality leaves are implemented independently and default off on OpenGL and Vulkan. Bounded view-aligned froxel integration, depth-normal SSR, and fixed eight-tap depth-derived SSGI share the native scene-colour/depth presentation tail without allocating temporal history for effect-only use. The stable eight-float contract, final 11/11 native suite, static backend checks, Vulkan shader pin, combined GL/Vulkan `game/airdefense1` gameplay, individual OpenGL leaves, visible enabled delta, and exact master-off zero packet pass locally with engine screenshots and clean API/error counters. `r_rendererModernQuality 0` remains the one-setting rollback. This is a scoped screen-space implementation, not shadowed light-injected volumetrics, roughness-aware G-buffer reflections, world-space GI, or whole-frame modern-lighting promotion. - [x] The post-roadmap performance pass removes two default-path regressions without enabling the guarded level-load cache experiment. Cinematic fast-forward no longer samples non-presented poses for every spawned entity on every 60 Hz tick, visible-frame interpolation samples active movers plus their bounded physics-team members and cleanup members, and maps without baked light-grid assets no longer construct a 22,024-point bake/debug layout during ordinary loading. On `game/airdefense1`, the controlled 186-second scripted skip fell from 7,113 to 5,770 ms at the exact same game-time endpoint and missing-grid setup fell from 357 to 1 ms; the isolated end-to-end run improved from 33,121 to 25,091 ms and steady pacing from 110.8 to 120.4 Hz, with the documented cache/scene-variance qualification. The final staged build also passed default `game/airdefense2`, pure auto-joined `mp/q4dm1`, and a four-role compatibility run against 40 retail PK4s with zero loose retail files on the tested Windows system. diff --git a/docs/user/display-settings.md b/docs/user/display-settings.md index 73c84cd7..0099a155 100644 --- a/docs/user/display-settings.md +++ b/docs/user/display-settings.md @@ -98,6 +98,34 @@ image_picmipFilter 3 That halves world and model diffuse textures twice while leaving lighting detail, the HUD, and menus untouched. +## Texture Sampling + +`image_filter` controls how ordinary material textures are sampled. It is saved +in your configuration and updates loaded textures immediately, without a +`vid_restart`. Material stages that explicitly request linear or nearest +sampling keep their authored behavior. That protects explicitly filtered fonts, +HUD elements, shadow data, and renderer-internal textures from the global +choice. + +| Value | Texels | Mip levels | Typical use | +|---|---|---|---| +| `GL_LINEAR_MIPMAP_LINEAR` | Linear | Smooth blend | Default, smoothest general-purpose sampling. | +| `GL_LINEAR_MIPMAP_NEAREST` | Linear | Nearest level | Smooth texels with sharper mip transitions. | +| `GL_NEAREST_MIPMAP_NEAREST` | Nearest | Nearest level | Pixelated textures with stable distance scaling. | +| `GL_NEAREST_MIPMAP_LINEAR` | Nearest | Smooth blend | Pixelated texels with softer distance transitions. | +| `GL_NEAREST` | Nearest | Disabled | Fully point-sampled; may shimmer in the distance. | +| `GL_LINEAR` | Linear | Disabled | Bilinear sampling without mip levels. | + +For a pixelated texture style, enter: + +``` +seta image_filter GL_NEAREST_MIPMAP_NEAREST +``` + +Nearest-texel modes automatically avoid anisotropic filtering so +`image_anisotropy` cannot blur the requested pixel edges. The same values work +with the supported OpenGL renderer and the experimental Vulkan renderer. + ## Renderer Backend (OpenGL default; Vulkan is experimental) openQ4 ships with an **OpenGL renderer as the default and only supported diff --git a/src/renderer/Image.h b/src/renderer/Image.h index 0e1dfed0..2004f227 100644 --- a/src/renderer/Image.h +++ b/src/renderer/Image.h @@ -127,6 +127,29 @@ void R_ApplyImageDownsizePolicy( const imageDownsizePolicy_t &policy, int &width // loaders that select a level out of an existing mip chain instead of resampling. int R_ImageDownsizePolicyMipSkip( const imageDownsizePolicy_t &policy, int width, int height, int availableLevels ); +// User-selectable sampling for TF_DEFAULT images. The names intentionally +// mirror Quake 4's image_filter values, while this backend-neutral state keeps +// the Vulkan renderer independent of OpenGL constants. +typedef enum { + IMAGE_FILTER_LINEAR_MIPMAP_LINEAR = 0, + IMAGE_FILTER_LINEAR_MIPMAP_NEAREST, + IMAGE_FILTER_NEAREST, + IMAGE_FILTER_LINEAR, + IMAGE_FILTER_NEAREST_MIPMAP_NEAREST, + IMAGE_FILTER_NEAREST_MIPMAP_LINEAR, + IMAGE_FILTER_MODE_COUNT +} imageFilterMode_t; + +struct imageFilterState_t { + imageFilterMode_t mode; + bool minLinear; + bool magLinear; + bool usesMipmaps; + bool mipLinear; +}; + +imageFilterState_t R_GetDefaultImageFilterState(); + #include "ImageOpts.h" #include "../imagetools/BinaryImage.h" @@ -145,6 +168,7 @@ class idImage { // Should be called at least once void SetSamplerState(textureFilter_t tf, textureRepeat_t tr); + void RefreshSamplerState(); // used by callback functions to specify the actual data // data goes from the bottom to the top line of the image, as OpenGL expects it diff --git a/src/renderer/ImageManager.cpp b/src/renderer/ImageManager.cpp index cee56b4c..e9276cb6 100644 --- a/src/renderer/ImageManager.cpp +++ b/src/renderer/ImageManager.cpp @@ -64,6 +64,22 @@ idImageManager imageManager; idImageManager * globalImages = &imageManager; idCVar preLoad_Images( "preLoad_Images", "1", CVAR_SYSTEM | CVAR_BOOL, "preload images during beginlevelload" ); +static const char *image_filterArgs[] = { + "GL_LINEAR_MIPMAP_LINEAR", + "GL_LINEAR_MIPMAP_NEAREST", + "GL_NEAREST", + "GL_LINEAR", + "GL_NEAREST_MIPMAP_NEAREST", + "GL_NEAREST_MIPMAP_LINEAR", + NULL +}; +idCVar image_filter( + "image_filter", + "GL_LINEAR_MIPMAP_LINEAR", + CVAR_RENDERER | CVAR_ARCHIVE, + "sampling mode for material textures that use the default filter", + image_filterArgs, + idCmdSystem::ArgCompletion_String ); idCVar image_anisotropy( "image_anisotropy", "16", @@ -71,6 +87,23 @@ idCVar image_anisotropy( "anisotropic filtering level for mipmapped material textures", 1, 16 ); + +imageFilterState_t R_GetDefaultImageFilterState() { + static const imageFilterState_t states[ IMAGE_FILTER_MODE_COUNT ] = { + { IMAGE_FILTER_LINEAR_MIPMAP_LINEAR, true, true, true, true }, + { IMAGE_FILTER_LINEAR_MIPMAP_NEAREST, true, true, true, false }, + { IMAGE_FILTER_NEAREST, false, false, false, false }, + { IMAGE_FILTER_LINEAR, true, true, false, false }, + { IMAGE_FILTER_NEAREST_MIPMAP_NEAREST, false, false, true, false }, + { IMAGE_FILTER_NEAREST_MIPMAP_LINEAR, false, false, true, true } + }; + + const int mode = image_filter.GetInteger(); + if ( mode < 0 || mode >= IMAGE_FILTER_MODE_COUNT ) { + return states[ IMAGE_FILTER_LINEAR_MIPMAP_LINEAR ]; + } + return states[ mode ]; +} idCVar image_downSize( "image_downSize", "0", @@ -259,30 +292,49 @@ static bool R_ImageReductionCvarsChanged( bool clear ) { return changed; } +static bool R_ImageSamplerCvarsChanged( bool clear ) { + idCVar *const samplerCvars[] = { &image_filter, &image_anisotropy }; + bool changed = false; + for ( int i = 0; i < static_cast( sizeof( samplerCvars ) / sizeof( samplerCvars[0] ) ); i++ ) { + if ( samplerCvars[i]->IsModified() ) { + changed = true; + if ( clear ) { + samplerCvars[i]->ClearModified(); + } + } + } + return changed; +} + /* =============== idImageManager::PrimeCvars Every cvar is born CVAR_MODIFIED, so without this the first rendered frame of every launch would see nine "changed" reduction cvars and reload every image for -nothing. Called once the intrinsic images exist and the real values are in. +nothing. Sampling cvars need the same startup priming so they do not pointlessly +reapply every default sampler. Called once the intrinsic images exist and the +real values are in. =============== */ void idImageManager::PrimeCvars() { R_ImageReductionCvarsChanged( true ); + R_ImageSamplerCvarsChanged( true ); } /* =============== idImageManager::CheckCvars -The image reduction cvars change the pixels a texture is built from, so they can -only take effect through a reload. Doing it here means a console change is -visible immediately instead of silently waiting for the next vid_restart. +Image reduction cvars change the pixels a texture is built from, so they take +effect through a reload. Sampling cvars only reapply loaded TF_DEFAULT samplers. +Doing both here makes console changes visible without a vid_restart. =============== */ void idImageManager::CheckCvars() { - if ( !R_ImageReductionCvarsChanged( true ) ) { + const bool reductionChanged = R_ImageReductionCvarsChanged( true ); + const bool samplerChanged = R_ImageSamplerCvarsChanged( true ); + if ( !reductionChanged && !samplerChanged ) { return; } @@ -292,8 +344,19 @@ void idImageManager::CheckCvars() { return; } - common->Printf( "Texture reduction changed, reloading images...\n" ); - ReloadImages( true ); + if ( reductionChanged ) { + common->Printf( "Texture reduction changed, reloading images...\n" ); + ReloadImages( true ); + return; + } + + common->Printf( "Texture sampling changed, updating samplers...\n" ); + for ( int i = 0; i < images.Num(); i++ ) { + idImage *image = images[i]; + if ( image != NULL && image->IsLoaded() && image->GetFilter() == TF_DEFAULT ) { + image->RefreshSamplerState(); + } + } } static void R_NormalizeInternalImageName( idStr& name ) { diff --git a/src/renderer/OpenGL/gl_Image.cpp b/src/renderer/OpenGL/gl_Image.cpp index 91870b25..91487404 100644 --- a/src/renderer/OpenGL/gl_Image.cpp +++ b/src/renderer/OpenGL/gl_Image.cpp @@ -252,10 +252,18 @@ void idImage::SetTexParameters() { const bool hasMipChain = opts.numLevels > 1; + const imageFilterState_t defaultFilter = R_GetDefaultImageFilterState(); switch( filter ) { case TF_DEFAULT: - glTexParameterf(target, GL_TEXTURE_MIN_FILTER, hasMipChain ? GL_LINEAR_MIPMAP_LINEAR : GL_LINEAR); - glTexParameterf( target, GL_TEXTURE_MAG_FILTER, GL_LINEAR ); + if ( hasMipChain && defaultFilter.usesMipmaps ) { + const int minFilter = defaultFilter.minLinear + ? ( defaultFilter.mipLinear ? GL_LINEAR_MIPMAP_LINEAR : GL_LINEAR_MIPMAP_NEAREST ) + : ( defaultFilter.mipLinear ? GL_NEAREST_MIPMAP_LINEAR : GL_NEAREST_MIPMAP_NEAREST ); + glTexParameterf( target, GL_TEXTURE_MIN_FILTER, minFilter ); + } else { + glTexParameterf( target, GL_TEXTURE_MIN_FILTER, defaultFilter.minLinear ? GL_LINEAR : GL_NEAREST ); + } + glTexParameterf( target, GL_TEXTURE_MAG_FILTER, defaultFilter.magLinear ? GL_LINEAR : GL_NEAREST ); break; case TF_LINEAR: glTexParameterf( target, GL_TEXTURE_MIN_FILTER, GL_LINEAR ); @@ -271,7 +279,7 @@ void idImage::SetTexParameters() { { // only do aniso filtering on mip mapped images - if ( filter == TF_DEFAULT && hasMipChain ) { + if ( filter == TF_DEFAULT && hasMipChain && defaultFilter.usesMipmaps && defaultFilter.minLinear ) { const float requestedAniso = static_cast( Max( 1, cvarSystem->GetCVarInteger( "image_anisotropy" ) ) ); const float aniso = Min( requestedAniso, Max( 1.0f, glConfig.maxTextureAnisotropy ) ); glTexParameterf(target, GL_TEXTURE_MAX_ANISOTROPY_EXT, aniso ); @@ -317,6 +325,14 @@ void idImage::SetTexParameters() { } } +void idImage::RefreshSamplerState() { + if ( !IsLoaded() ) { + return; + } + R_BindTextureForDirectAccess( ( opts.textureType == TT_CUBIC ) ? GL_TEXTURE_CUBE_MAP_EXT : GL_TEXTURE_2D, texnum ); + SetTexParameters(); +} + /* ======================== idImage::AllocImage diff --git a/src/renderer/Vulkan/vk_Image.cpp b/src/renderer/Vulkan/vk_Image.cpp index d14e9b57..7a6572bd 100644 --- a/src/renderer/Vulkan/vk_Image.cpp +++ b/src/renderer/Vulkan/vk_Image.cpp @@ -222,6 +222,7 @@ typedef struct vkSamplerKey_s { textureRepeat_t repeat; bool mips; int anisotropy; + int defaultFilterMode; } vkSamplerKey_t; static const int VK_MAX_SAMPLERS = 64; @@ -230,8 +231,13 @@ static VkSampler vkSamplers[ VK_MAX_SAMPLERS ]; static int vkNumSamplers = 0; static VkSampler VK_Image_GetSampler( textureFilter_t filter, textureRepeat_t repeat, bool mips ) { + const imageFilterState_t defaultFilter = R_GetDefaultImageFilterState(); + const int defaultFilterMode = filter == TF_DEFAULT ? static_cast( defaultFilter.mode ) : -1; + if ( filter == TF_DEFAULT && !defaultFilter.usesMipmaps ) { + mips = false; + } int anisotropy = 0; - if ( filter == TF_DEFAULT && mips ) { + if ( filter == TF_DEFAULT && mips && defaultFilter.minLinear ) { anisotropy = image_anisotropy.GetInteger(); if ( anisotropy < 0 ) { anisotropy = 0; @@ -243,7 +249,8 @@ static VkSampler VK_Image_GetSampler( textureFilter_t filter, textureRepeat_t re for ( int i = 0; i < vkNumSamplers; i++ ) { if ( vkSamplerKeys[ i ].filter == filter && vkSamplerKeys[ i ].repeat == repeat - && vkSamplerKeys[ i ].mips == mips && vkSamplerKeys[ i ].anisotropy == anisotropy ) { + && vkSamplerKeys[ i ].mips == mips && vkSamplerKeys[ i ].anisotropy == anisotropy + && vkSamplerKeys[ i ].defaultFilterMode == defaultFilterMode ) { return vkSamplers[ i ]; } } @@ -267,9 +274,9 @@ static VkSampler VK_Image_GetSampler( textureFilter_t filter, textureRepeat_t re sci.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; break; default: // TF_DEFAULT - sci.magFilter = VK_FILTER_LINEAR; - sci.minFilter = VK_FILTER_LINEAR; - sci.mipmapMode = mips ? VK_SAMPLER_MIPMAP_MODE_LINEAR : VK_SAMPLER_MIPMAP_MODE_NEAREST; + sci.magFilter = defaultFilter.magLinear ? VK_FILTER_LINEAR : VK_FILTER_NEAREST; + sci.minFilter = defaultFilter.minLinear ? VK_FILTER_LINEAR : VK_FILTER_NEAREST; + sci.mipmapMode = defaultFilter.mipLinear ? VK_SAMPLER_MIPMAP_MODE_LINEAR : VK_SAMPLER_MIPMAP_MODE_NEAREST; break; } sci.maxLod = mips ? VK_LOD_CLAMP_NONE : 0.25f; @@ -313,6 +320,7 @@ static VkSampler VK_Image_GetSampler( textureFilter_t filter, textureRepeat_t re vkSamplerKeys[ vkNumSamplers ].repeat = repeat; vkSamplerKeys[ vkNumSamplers ].mips = mips; vkSamplerKeys[ vkNumSamplers ].anisotropy = anisotropy; + vkSamplerKeys[ vkNumSamplers ].defaultFilterMode = defaultFilterMode; vkSamplers[ vkNumSamplers ] = sampler; vkNumSamplers++; return sampler; @@ -884,6 +892,12 @@ void idImage::SetTexParameters( void ) { entry->generation = vkImageGenerationCounter++; } +void idImage::RefreshSamplerState() { + if ( IsLoaded() ) { + SetTexParameters(); + } +} + /* ==================== idImage::Resize diff --git a/tools/tests/renderer_texture_filter.py b/tools/tests/renderer_texture_filter.py new file mode 100644 index 00000000..bb0d254c --- /dev/null +++ b/tools/tests/renderer_texture_filter.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Static contract for the user-selectable default texture sampler.""" + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] + + +def read(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def require(text: str, token: str, context: str) -> None: + if token not in text: + raise AssertionError(f"Missing {token!r} in {context}") + + +def main() -> None: + header = read("src/renderer/Image.h") + manager = read("src/renderer/ImageManager.cpp") + gl_image = read("src/renderer/OpenGL/gl_Image.cpp") + vk_image = read("src/renderer/Vulkan/vk_Image.cpp") + docs = read("docs/user/display-settings.md") + validator = read("tools/validation/openq4_validate.py") + + filter_values = ( + "GL_LINEAR_MIPMAP_LINEAR", + "GL_LINEAR_MIPMAP_NEAREST", + "GL_NEAREST", + "GL_LINEAR", + "GL_NEAREST_MIPMAP_NEAREST", + "GL_NEAREST_MIPMAP_LINEAR", + ) + args_start = manager.index("static const char *image_filterArgs[]") + args_end = manager.index("};", args_start) + args_block = manager[args_start:args_end] + positions = [args_block.index(f'"{value}"') for value in filter_values] + if positions != sorted(positions): + raise AssertionError("image_filter value order no longer matches imageFilterMode_t") + + require(manager, 'idCVar image_filter(', "image_filter registration") + require(manager, '"GL_LINEAR_MIPMAP_LINEAR",\n\tCVAR_RENDERER | CVAR_ARCHIVE', "image_filter default and persistence") + require(manager, "idCmdSystem::ArgCompletion_String", "image_filter completion") + require(manager, "&image_filter, &image_anisotropy", "live sampler CVar tracking") + require(manager, "image->GetFilter() == TF_DEFAULT", "authored filter isolation") + require(manager, "image->RefreshSamplerState();", "live sampler refresh") + + for value in filter_values: + require(header, f"IMAGE_FILTER_{value.removeprefix('GL_')}", "backend-neutral filter modes") + require(docs, f"`{value}`", "texture-filter documentation") + + require(header, "imageFilterState_t R_GetDefaultImageFilterState();", "shared filter resolver") + require(gl_image, "defaultFilter.usesMipmaps", "OpenGL mip selection") + require(gl_image, "defaultFilter.minLinear", "OpenGL texel selection") + require(gl_image, "defaultFilter.mipLinear", "OpenGL mip blending") + require(gl_image, "void idImage::RefreshSamplerState()", "OpenGL live refresh") + + require(vk_image, "defaultFilterMode", "Vulkan sampler cache key") + require(vk_image, "defaultFilter.magLinear ? VK_FILTER_LINEAR : VK_FILTER_NEAREST", "Vulkan magnification selection") + require(vk_image, "defaultFilter.mipLinear ? VK_SAMPLER_MIPMAP_MODE_LINEAR", "Vulkan mip blending") + require(vk_image, "void idImage::RefreshSamplerState()", "Vulkan live refresh") + + require(docs, "seta image_filter GL_NEAREST_MIPMAP_NEAREST", "pixelated-texture example") + require( + validator, + 'root / "tools" / "tests" / "renderer_texture_filter.py"', + "validation-suite registration", + ) + + print("renderer_texture_filter: ok") + + +if __name__ == "__main__": + main() diff --git a/tools/validation/openq4_validate.py b/tools/validation/openq4_validate.py index 4220d33d..7f237b4d 100644 --- a/tools/validation/openq4_validate.py +++ b/tools/validation/openq4_validate.py @@ -517,6 +517,7 @@ def run_python_tests(args: argparse.Namespace, root: Path, env: dict[str, str]) root / "tools" / "tests" / "renderer_screenshot_readback.py", root / "tools" / "tests" / "renderer_supersampling_safety.py", root / "tools" / "tests" / "renderer_temporal_presentation.py", + root / "tools" / "tests" / "renderer_texture_filter.py", root / "tools" / "tests" / "renderer_vulkan_decal_compatibility.py", root / "tools" / "tests" / "renderer_vulkan_gui_residency.py", root / "tools" / "tests" / "renderer_vulkan_md5r_compatibility.py",