Skip to content

Quality Presets

The options window’s Video → Quality group opens with a Preset dropdown: Low, Medium, High, Ultra and Custom, with a Detect button beside it. Picking a tier writes every preference the table below covers in one go. The same model drives the desktop client, the iPad and the Steam Frame: the table, the matching and the detection all live in DigitalHeaven.Engine.Client (Quality/), and the hosts only push the question, the progress and the result through the confirm dialog and loading card they already have.

High is today’s defaults. Its column is not written anywhere: each row reads its High value from the preference’s own declared default (QualitySetting<T>), so a client that never touches the dropdown draws exactly what it drew before presets existed, and a changed default moves High with it. Applying High therefore leaves nothing pinned in the config.

PreferenceLowMediumHigh (default)Ultra
client.render.msaa1 (off)248
client.render.sceneResolution0.50 (automatic)0 (automatic)1 (native)
client.render.shadowResolution512102420484096
client.render.shadowDistance40 m80 m120 m250 m
client.render.aooffoffoffon
client.render.motionBluroffononon
client.render.bloomFastononoffoff
client.render.giVolumeoffononon
client.render.giPerPixeloffoffonon
client.render.reflectionProbes0 (sky only)321024 (all)1024 (all)
client.render.parallaxShadowTaps0 (off)81632
client.render.waterRefractionoffononon
client.render.waterWavesoffononon
client.render.waterFoamoffononon
client.render.waterFoamPersistenceoffoffonon
client.render.glassoffononon
client.render.maxTextureSize102420480 (automatic)0 (automatic)
client.render.lightBudget16325664
client.cache.objectBudgetMb1282565121024

Low has to be cheap on a phone GPU: the Frame’s Adreno 750 spends hundreds of milliseconds in the opaque pass at 1600x900 at the defaults, so Low renders at half resolution with no multisampling, no parallax self-shadow, no probes, no GI volume and a quarter of the light budget. Probe count and texture size apply on the next map load, exactly as the preferences themselves do.

Medium lights an imported map’s light probe group per leaf (client.render.giPerPixel off): each cell’s probes are blended once at load and a pixel reads that one probe, the way Source lights a leaf, instead of blending up to 32 probes per pixel. It shows a step where a surface crosses from one cell into the next, which High and Ultra do not pay for. Low has no probes at all.

Medium keeps the automatic resolution on purpose. A fraction is relative to native pixels while automatic renders at logical points, so on a 2x display (the iPad) automatic is already 0.5: a Medium of 0.75 would have cost more than High there. Low’s 0.5 matches automatic on such a display and halves the resolution everywhere else.

Deliberately not covered:

  • client.fpsMax and VSync — display choices, not cost.
  • client.render.textureBlocks — always on; off only costs memory.
  • PVS culling — a correctness switch, not a quality one.
  • client.render.lightFadeSeconds and lightLockMargin — how the light ranking feels, not what it costs. The budget is the cost knob.
  • client.load.decodeWorkers — already sized to the machine at 0.

client.render.vramBudgetMb will join the table as one more row once it exists; nothing depends on it today.

The dropdown’s value is derived every draw: QualityPresets.Match compares each covered preference’s live value against each tier and shows the first tier they all match, or Custom. Nothing tracks “the current preset”, so a slider moved in the window, a console line, an exec’d config and a hand-edited file all read as Custom on the next frame with nothing to keep in sync. Choosing Custom from the dropdown changes nothing.

A row is a QualitySetting: a preference and the value each tier writes, with Apply and Matches. The opaque shader’s feature variants (specialization constants per material and per quality feature) are being built separately; when a preset needs to carry global shader feature bits, they join the table as another QualitySetting subclass — one that writes and matches a feature mask instead of a preference — and Apply, Match and the dropdown pick them up unchanged. The presets never edit a shader themselves.

The first time a client reaches the main menu with no answer on record, a confirm dialog asks whether to detect quality settings:

  • Detect runs the benchmark and applies the tier it earns.
  • Use Medium applies Medium. Escape answers the same way; the backdrop does nothing, because an answer given by missing is not an answer.

It waits for the main menu rather than for the process: a client launched straight into a map is asked when it first comes back to the menu, not over the loading box. Automated runs that boot to the menu can pass client.quality.asked true to skip it.

The answer is kept in preferences, not the profile — the profile is identity and travels to servers, while this describes one machine’s GPU:

PreferenceMeaning
client.quality.askedThe question has been answered.
client.quality.detectionVersionThe detection version the answer was made under.
client.quality.detectedThe tier the last detection chose (custom until one has run).

QualityDetection.Version is the version this build writes. Bumping it asks everyone again: the question goes up whenever asked is false or the stored version is older.

Detect on the Video page and Detect in the question run the same QualityDetector. The question and the detector are shared code; the desktop host and the mobile frame host each drive it once a frame, route its answers through their existing confirm channel and show its progress on their existing loading card.

  1. The covered preferences are set to High, so the time always means the same thing whatever the person had set before.
  2. From the next presented frame, the renderer draws QualityBenchmarkScene in place of whatever the host submits: the procedural developer arena (built from code, so no pallet, map or install can change it), lit by a ring of sixteen point lights and a light probe group shaped like an imported Source map’s (SyntheticProbeGroup: the arena’s box halved twelve times into 4,096 cells of about a meter, one probe a cell, each cell listing the sixteen probes under its fourth ancestor), from one fixed camera that fills the frame with floor and wall. The host’s UI still draws over it, and no water field applies. The group is bound only while the benchmark runs; the host’s probes come back when it ends. SyntheticProbeGroup.Build takes the box, the depth and the list reach, so a benchmark map that wants probe load builds its own the same way.
  3. It reads per-pass GPU time from the renderer’s own GpuPassTimings — the same timestamps the frame-stats card reads. After client.quality.benchmarkWarmupFrames (8) frames it averages up to client.quality.benchmarkFrames (30), stopping early once client.quality.benchmarkSeconds (2) have passed. A slow GPU ends its warm-up after two frames and half the budget, so a phone is never held for long.
  4. The scene time is every pass but the UI and the editor previews, scaled to 1920x1080 by pixel count, and mapped to a tier by three thresholds. Since the scene gained its probe group the time includes a per-pixel probe blend at High, so a GPU that is slow at it lands a tier lower; the thresholds were set before that and are due a desktop re-measurement:
PreferenceDefaultTier at or under it
client.quality.ultraMs2.5 msUltra
client.quality.highMs6 msHigh
client.quality.mediumMs14 msMedium; anything slower is Low

If the graphics queue cannot write timestamps, the benchmark scene cannot be built, or no frame reaches the GPU within twice the time budget, the tier comes from what the device says about itself, in this order:

  1. The device-name table (QualityDetection.DeviceNames), first substring match wins: recent RTX and top Radeon cards are Ultra, other RTX, Radeon RX 6000/7000 and Arc are High, GTX, integrated Radeon, Iris and Apple M-series are Medium, Intel UHD and the phone GPUs (Adreno, Mali, Immortalis, PowerVR, Xclipse, Apple A-series) are Low.
  2. The platform: an unrecognized GPU on a phone or tablet (the same classification that picks ASTC textures) is Low.
  3. The vendor: Qualcomm, Arm and Imagination are Low, Apple is Medium, and NVIDIA, AMD and Intel are High on a discrete card and Medium otherwise.
  4. Medium, when nothing else is known.

A detection is never silent. Both entry points, the question’s Detect and the Video page’s Detect, show the same two surfaces from the same shared model (QualityDetector.Phase: idle, asking, running, reporting), and every host shows them through the surfaces it already has:

  • While it runs, the loading card says Graphics quality, Measuring how fast this device draws the benchmark scene, and counts frames (21 / 38 frames) over a bar. The bar is the benchmark’s own Progress: frames drawn against the warm-up plus measured frames, or the time budget spent, whichever is further along, so it moves on a GPU too slow to draw the planned frames and never slides back. The card has no button. A load in flight keeps the card; the detection’s card shows only when no load holds it (QualityDetector.ProgressOver).
  • When it ends, a result dialog on the confirm channel names the tier and one line of why, with OK. Escape and the backdrop put it down too.
    • Measured: Detection chose Medium. Measured 8.6 ms per 1080p frame; Medium keeps it under 14 ms. A Low result says the time was slower than Medium’s ceiling.
    • Not measured: the dialog says the benchmark couldn’t run and why, then which fallback chose: the device table chose it from the GPU name “Adreno”, the mobile check chose it, since this is a phone or tablet, the vendor check chose it from the GPU’s vendor, NVIDIA, or it fell back to the default, Medium.

Pressing Detect again while a result stands puts it down and measures again; pressing it during a run does nothing.

Every detection prints one line on the quality channel naming the device, what was measured, the thresholds and the choice:

quality detection: device "NVIDIA GeForce RTX 4090" (vendor 0x10de, discrete); measured 1.84 ms per 1080p frame (opaque 0.92 ms, 30 frames at 2560x1440); thresholds ultra <= 2.5, high <= 6, medium <= 14 ms; chose Ultra because the benchmark measured at or under the Ultra threshold
quality detection: device "Adreno (TM) 750" (vendor 0x5143, integrated); no measurement (GPU timestamps are not supported); thresholds ultra <= 2.5, high <= 6, medium <= 14 ms; chose Low because the device table matched "Adreno"

UiCaptureTimeline.QualitySettings photographs the Quality group (settingsQualityPreset), the dropdown open on its five entries (settingsQualityPresetMenu), and over the main menu a detection’s progress card part of the way through (qualityDetecting), the first-launch question (qualityQuestion), a measured result (qualityResult) and a device-table fallback after a benchmark that could not run (qualityResultFallback), at the end of the --renderUi timeline. All are ordinary widget-tree surfaces, so none needs a band hand-off.