Log inGet started

Graphics quality on phones and desktops: the device's quality tier

Updated October 16, 2026

A world is authored on one machine and played on every other. The engine assigns each device a quality tier at boot, "low", "medium" or "high", from what the device states about itself: its GPU family and limits, its memory, and its display (a phone or a touch browser states a density; a desktop window states none). Every renderer default a world's settings leave unstated comes from that tier, so a world that never mentions phones still plays on one at the phone's defaults.

local q = renderer.qualityTier()
print(q.tier, q.reason)
-- medium   Mali-G78 is a mali GPU of the medium class; 7.0 GiB of memory
print(q.defaults.resolutionScale, q.defaults.anisotropy)
Defaulthighmediumlow
Texture budgetthe device's ownat most 768 MiBat most 384 MiB
Anisotropy (render.anisotropy)1684
Scene on a handheld display2.25 px per point, 2.4 Mpx at most2.0 px per point, 1.3 Mpx1.75 px per point, 0.8 Mpx
Scene on a desktop displayevery pixel1.3 Mpx at most0.8 Mpx at most

A desktop, and any device whose GPU is outside the handheld families, is "high": the renderer exactly as a desktop has always run it, one scene pixel per display pixel. A handheld starts at the tier its GPU's generation affords (Adreno 7xx, Immortalis and Mali-G7xx are high; Adreno 6xx and Mali-G57 to G78 medium; PowerVR, Mali-G52 and older low) and is held down by its memory (under about 4.5 GiB is low, under 6.5 GiB medium) and by a GPU that cannot hold an 8192-texel texture.

The scene on a dense phone screen. A phone panel packs far more pixels than a 3D scene shows at arm's length, and shading cost is pixel count. On a handheld the tier renders the scene at its own density within its pixel budget (renderer.resolutionScale() reads below 1 on a 1080x2400 phone) under renderer.setResolutionScaleMode("reconstruct"), so the UI and text compose at the display's full resolution on top of the upscaled scene. A desktop renders every pixel at the high tier it is assigned; a lower tier chosen on one (a player's "low graphics") renders the scene within that tier's pixel budget the same way, so renderer.setQualityTier alone is a whole graphics setting on every platform.

Overriding. Three levels, each standing over the one before:

  1. The world's settings: a render.* key the world's settings state is used on every device, whatever its tier.
  2. renderer.setQualityTier("low" | "medium" | "high") chooses another tier for the process (a settings screen offering the player a quality choice is this call); renderer.setQualityTier() returns to the device's own. The choice holds across scene loads, and a change of tier applies the new tier's defaults at the call, replacing what an earlier renderer call set for them; choosing the tier already in force changes no setting.
  3. A single renderer call (setResolutionScale, setTextureBudget, setAnisotropy) overrides that one default until the tier changes again, including a call made in the same script right after setQualityTier. A setTextureBudget budget stands over the tier's texture cap until setTextureBudget() returns it to the device's own.

The engine log states the tier and every default it chose at boot, and again whenever the tier changes ([quality] lines).

A graphics setting players choose

A settings panel offering "Low / Medium / High" needs one call per choice; the tier carries every default with it, the render scale included, on a phone and on a desktop alike:

-- A button's handler. The choice holds for the process, across scene loads
-- and reloads, so nothing has to re-apply it when a scene loads.
local function choose(tier: string?)
    local q = renderer.setQualityTier(tier)  -- nil: back to the device's own
    print(q.tier, q.defaults.resolutionScale, q.defaults.anisotropy)
end

renderer.qualityTier().tier is the choice in force (the one to highlight in the panel) and deviceTier the device's own. A choice that should outlast the process (the next launch of the app) is the world's to store, in the player's runtime_data, and to hand back to renderer.setQualityTier at startup.

Seeing what it costs

renderer.renderTargets() and renderer.shadowMemory() name the memory the tier's defaults buy, and renderer.viewportSize() / renderer.compositeSize() the scene raster and the display it is carried up to. Read them before and after renderer.setQualityTier to see what a tier spends on this device.