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.
The table
Section titled “The table”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.
| Preference | Low | Medium | High (default) | Ultra |
|---|---|---|---|---|
client.render.msaa | 1 (off) | 2 | 4 | 8 |
client.render.sceneResolution | 0.5 | 0 (automatic) | 0 (automatic) | 1 (native) |
client.render.shadowResolution | 512 | 1024 | 2048 | 4096 |
client.render.shadowDistance | 40 m | 80 m | 120 m | 250 m |
client.render.ao | off | off | off | on |
client.render.motionBlur | off | on | on | on |
client.render.bloomFast | on | on | off | off |
client.render.giVolume | off | on | on | on |
client.render.giPerPixel | off | off | on | on |
client.render.reflectionProbes | 0 (sky only) | 32 | 1024 (all) | 1024 (all) |
client.render.parallaxShadowTaps | 0 (off) | 8 | 16 | 32 |
client.render.waterRefraction | off | on | on | on |
client.render.waterWaves | off | on | on | on |
client.render.waterFoam | off | on | on | on |
client.render.waterFoamPersistence | off | off | on | on |
client.render.glass | off | on | on | on |
client.render.maxTextureSize | 1024 | 2048 | 0 (automatic) | 0 (automatic) |
client.render.lightBudget | 16 | 32 | 56 | 64 |
client.cache.objectBudgetMb | 128 | 256 | 512 | 1024 |
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.fpsMaxand 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.lightFadeSecondsandlightLockMargin— 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.
Custom is read, never stored
Section titled “Custom is read, never stored”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.
The seam for shader features
Section titled “The seam for shader features”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-launch question
Section titled “The first-launch question”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:
| Preference | Meaning |
|---|---|
client.quality.asked | The question has been answered. |
client.quality.detectionVersion | The detection version the answer was made under. |
client.quality.detected | The 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.
Detection
Section titled “Detection”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.
The benchmark
Section titled “The benchmark”- The covered preferences are set to High, so the time always means the same thing whatever the person had set before.
- From the next presented frame, the renderer draws
QualityBenchmarkScenein 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.Buildtakes the box, the depth and the list reach, so a benchmark map that wants probe load builds its own the same way. - It reads per-pass GPU time from the renderer’s own
GpuPassTimings— the same timestamps the frame-stats card reads. Afterclient.quality.benchmarkWarmupFrames(8) frames it averages up toclient.quality.benchmarkFrames(30), stopping early onceclient.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. - 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:
| Preference | Default | Tier at or under it |
|---|---|---|
client.quality.ultraMs | 2.5 ms | Ultra |
client.quality.highMs | 6 ms | High |
client.quality.mediumMs | 14 ms | Medium; anything slower is Low |
Fallbacks
Section titled “Fallbacks”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:
- 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. - The platform: an unrecognized GPU on a phone or tablet (the same classification that picks ASTC textures) is Low.
- 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.
- Medium, when nothing else is known.
What the person sees
Section titled “What the person sees”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 ownProgress: 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.
The log line
Section titled “The log line”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 thresholdquality 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"Photographed
Section titled “Photographed”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.