Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/dev/release-completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 28 additions & 0 deletions docs/user/display-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/renderer/Image.h
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down
77 changes: 70 additions & 7 deletions src/renderer/ImageManager.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,46 @@ 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<image_filterArgs> );
idCVar image_anisotropy(
"image_anisotropy",
"16",
CVAR_RENDERER | CVAR_ARCHIVE | CVAR_INTEGER,
"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",
Expand Down Expand Up @@ -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<int>( 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;
}

Expand All @@ -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 ) {
Expand Down
22 changes: 19 additions & 3 deletions src/renderer/OpenGL/gl_Image.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand All @@ -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<float>( Max( 1, cvarSystem->GetCVarInteger( "image_anisotropy" ) ) );
const float aniso = Min( requestedAniso, Max( 1.0f, glConfig.maxTextureAnisotropy ) );
glTexParameterf(target, GL_TEXTURE_MAX_ANISOTROPY_EXT, aniso );
Expand Down Expand Up @@ -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
Expand Down
24 changes: 19 additions & 5 deletions src/renderer/Vulkan/vk_Image.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<int>( 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;
Expand All @@ -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 ];
}
}
Expand All @@ -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;
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reclaim stale Vulkan samplers when settings change

When Vulkan users repeatedly switch image_filter or adjust image_anisotropy, each mode/anisotropy/repeat/mip combination is retained until device shutdown because the new mode is part of the cache key. Refreshing all loaded default-filter images can therefore consume the fixed 64-entry cache during a normal session; subsequent misses return vkSamplers[0], applying an unrelated filter or address mode to textures. Evict stale default samplers or otherwise bound/reuse these setting-dependent entries.

Useful? React with 👍 / 👎.

vkSamplers[ vkNumSamplers ] = sampler;
vkNumSamplers++;
return sampler;
Expand Down Expand Up @@ -884,6 +892,12 @@ void idImage::SetTexParameters( void ) {
entry->generation = vkImageGenerationCounter++;
}

void idImage::RefreshSamplerState() {
if ( IsLoaded() ) {
SetTexParameters();
}
}

/*
====================
idImage::Resize
Expand Down
75 changes: 75 additions & 0 deletions tools/tests/renderer_texture_filter.py
Original file line number Diff line number Diff line change
@@ -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_filterArgs>", "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()
Loading
Loading