Quick answer: Use highp in fragment shaders. Render lights to a surface with format surface_rgba16float if available. Avoid 8-bit alpha quantization in light accumulation.

A 2D dungeon game uses a custom light shader on a surface, then blends to screen. On Android, the gradient turns into hard rings — mobile GPUs use mediump by default, quantizing values.

Force High Precision

// fragment shader
precision highp float;

varying vec2 v_vTexcoord;
varying vec4 v_vColour;

void main() {
    float d = length(v_vTexcoord - vec2(0.5));
    float intensity = smoothstep(0.5, 0.0, d);
    gl_FragColor = vec4(1.0, 1.0, 1.0, intensity);
}

Mediump (~10-bit float) quantizes the gradient into bands. Highp (32-bit) gives smooth falloff.

Use Float Surface

light_surface = surface_create(w, h, surface_rgba16float);

HDR-precision surface. Accumulates many lights without saturation. Standard surface_rgba8unorm clips to [0, 1].

Tone Map to Display

// post-pass shader: HDR -> LDR
void main() {
    vec3 hdr = texture2D(s_HDRTex, v_vTexcoord).rgb;
    vec3 ldr = hdr / (hdr + vec3(1.0));   // Reinhard
    gl_FragColor = vec4(ldr, 1.0);
}

Map HDR back to 8-bit display range smoothly.

Verifying

Run on Android device. Light gradient smooth. No banding. Cross-test on iOS — precision should match desktop.

“Mobile defaults to mediump. For lighting, explicitly request highp and float surfaces.”

surface_rgba16float isn’t universal on Android — test capability and fallback to rgba8 + dithering if unavailable.