"""GraphicsQuality: the authoritative quality-tier table.
One string dial, ``WorldEnvironment.quality_tier``, selects a tier; each named
tier resolves to a :class:`QualitySettings` record of dials through the single
``TIER_TABLE`` below. Both backends resolve through this table (the env-sync
spec applies it inside ``apply_spec``), so a tier means the same thing on
Vulkan desktop and WebGPU web.
Tier semantics:
* ``"auto"`` (the default): detect-default. No table lookup; each backend
keeps its own detection (the web runtime resolves a tier from the WebGPU
capability probe in ``quality_tier.js``, desktop runs at its built-in
defaults). The per-dial ``WorldEnvironment`` Properties still apply exactly
as before, so the default path is byte-identical to the pre-tier engine.
* ``"low"`` / ``"medium"`` / ``"high"`` / ``"ultra"``: the table drives every
wired dial. A per-dial Property explicitly moved OFF its default still wins
over the tier value (an escape hatch for one-dial tweaks); use ``"custom"``
to drive everything by hand. ``"high"`` is pinned to the engine defaults
(``QualitySettings()``), so selecting it renders identically to ``"auto"``
on desktop.
* ``"custom"``: pass-through. The table is never consulted; the per-dial
Properties are the whole story (same wiring as ``"auto"``, minus any future
auto-detection).
Wired dials today (each rides its existing env-sync row and transition path:
resize/drain on desktop, tier-invalidation on web): ``render_scale``,
``probe_blend_count`` + ``probe_face_size``, ``shadow_caster_count``. The
remaining dials are declared here so later work (debug views, SSR, FFT ocean,
volumetric quality) consumes the same table instead of growing their own; they
change nothing until a consumer lands.
One wired dial is a SHADER PERMUTATION dial on desktop: ``probe_blend_count``
is compiled into ``cube_textured.frag`` as a ``#define``, so moving it
recompiles the lit fragment permutations, drains the device and rebuilds the
mesh pipelines. ``shadow_caster_count`` reallocates both shadow atlases, which
drains the device too but compiles nothing. Either is a one-off cost at the
transition and nothing per frame, but it is why a tier is something a scene
selects, not something it animates.
"""
from dataclasses import dataclass
from typing import Any
__all__ = [
"QUALITY_TIERS",
"QualitySettings",
"TIER_TABLE",
"resolve_tier",
"tier_dial_overrides",
]
# The named tiers, weakest first. "auto" and "custom" are tier-SELECTOR values
# on WorldEnvironment.quality_tier, not tiers: neither resolves to a record.
QUALITY_TIERS: tuple[str, ...] = ("low", "medium", "high", "ultra")
[docs]
@dataclass(frozen=True, slots=True)
class QualitySettings:
"""One tier's dial record. Field defaults ARE the engine defaults (the
``"high"`` tier), so ``QualitySettings()`` always names today's look.
``ssr_mode`` / ``ssao_mode`` / ``volumetric_quality`` are CEILINGS on the
matching user-enabled feature (like ``WorldEnvironment.ambient_mode``): a
tier never switches a feature on, it caps how expensively it runs.
"""
# Main-view HDR-chain resolution scale. 1.0 = native.
render_scale: float = 1.0
# Directional CSM shadow-map resolution per cascade (future consumer;
# desktop currently fixes shadow_pass.SHADOW_MAP_SIZE = 2048).
shadow_map_size: int = 2048
# Reflection probes: top-K per-fragment blend width (0 disables
# probe capture + blending entirely) and per-face capture resolution.
probe_blend_count: int = 2
probe_face_size: int = 128
# Positional (point + spot) shadow casters of each kind, Vulkan only. Each
# slot is another atlas row (~20 MiB) and another shadow map to draw; the
# drawing is only paid again when that light, the casting geometry or a
# material it is drawn with changes, so a still scene pays per slot once. A
# tier that moves the dial pays one atlas reallocation at the transition.
shadow_caster_count: int = 1
# Screen-space reflections ceiling:
# "off" | "half" (half-res trace) | "full".
ssr_mode: str = "half"
# SSAO ceiling: "off" | "on" | "hbao" (future consumer; today SSAO is the
# user's ssao_enabled toggle at one quality).
ssao_mode: str = "on"
# Screen-space global illumination ceiling: "off" | "on".
# SSGI is an expensive half-res hemisphere trace, so only "ultra" enables it
# by tier; "auto"/"custom" leave it to the per-env ``ssgi_enabled`` toggle.
ssgi_mode: str = "off"
# FFT ocean: cascade texture size + count.
ocean_size: int = 256
ocean_cascades: int = 3
# Volumetric-fog march quality ceiling (future consumer): "low" | "medium" | "high".
volumetric_quality: str = "high"
# Temporal upsampling: whether the TAA resolve may
# upgrade the bilinear upscale when render_scale < 1 (requires taa_enabled).
taau: bool = True
# THE tier table. "high" is the engine-default look by
# construction (QualitySettings() defaults); the golden suite pins it.
TIER_TABLE: dict[str, QualitySettings] = {
"low": QualitySettings(
render_scale=0.5,
shadow_map_size=1024,
probe_blend_count=0,
probe_face_size=64,
shadow_caster_count=0,
ssr_mode="off",
ssao_mode="off",
ocean_size=128,
ocean_cascades=2,
volumetric_quality="low",
taau=False,
),
"medium": QualitySettings(
render_scale=1.0,
shadow_map_size=1024,
probe_blend_count=2,
probe_face_size=64,
shadow_caster_count=1,
ssr_mode="off",
ssao_mode="off",
ocean_size=256,
ocean_cascades=2,
volumetric_quality="medium",
taau=True,
),
"high": QualitySettings(),
"ultra": QualitySettings(
render_scale=1.0,
shadow_map_size=4096,
probe_blend_count=8,
probe_face_size=128,
# Four of each kind: the spot atlas stacks 1024 px rows, so four is what
# a device meeting Vulkan's 4096 px image floor can host. Affordable
# because a caster's map is only redrawn when it stops being static.
shadow_caster_count=4,
ssr_mode="full",
ssao_mode="hbao",
ssgi_mode="on",
ocean_size=256,
ocean_cascades=3,
volumetric_quality="high",
taau=True,
),
}
# WorldEnvironment Property defaults for the dials the tier table drives
# TODAY. A dial sitting at its default takes the tier value; a dial the user
# explicitly moved wins over the tier. test_graphics_quality.py asserts this
# map matches the live WorldEnvironment Property defaults, so a default change
# there cannot silently desync the tier resolution.
_WIRED_DIAL_DEFAULTS: dict[str, Any] = {
"render_scale": 1.0,
"probe_blend_count": None,
"probe_face_size": None,
"shadow_caster_count": None,
}
_NO_OVERRIDES: dict[str, Any] = {}
[docs]
def resolve_tier(tier: str) -> QualitySettings | None:
"""The :class:`QualitySettings` for a named tier, else ``None``.
``"auto"`` and ``"custom"`` (and anything unknown: the Property enum on
``WorldEnvironment.quality_tier`` already rejects those) resolve to
``None`` = "no table, per-dial Properties / backend detection decide".
"""
return TIER_TABLE.get(tier)
[docs]
def tier_dial_overrides(env: Any) -> dict[str, Any]:
"""Env-attr -> effective value for the wired dials of *env*'s tier.
Empty for ``"auto"`` / ``"custom"`` (pass-through: the env-sync spec reads
the per-dial Properties directly, exactly the pre-tier behaviour, so the
default path costs one dict lookup). For a named tier, every wired dial
still at its Property default maps to the tier's value; explicitly-set
dials are omitted so the Property wins. ``apply_spec`` consults this on
both backends: the ONE resolution point for quality tiers.
"""
settings = TIER_TABLE.get(getattr(env, "quality_tier", "auto"))
if settings is None:
return _NO_OVERRIDES
overrides: dict[str, Any] = {}
for dial, default in _WIRED_DIAL_DEFAULTS.items():
if getattr(env, dial, default) == default:
overrides[dial] = getattr(settings, dial)
return overrides