Load a WebP image from a URL in Unity
Unity’s built-in loaders decode PNG, JPG and EXR only, so a .webp comes back blank — download the raw bytes and hand them to a WebP decoder instead.
Applies to Unity 2022.3 LTS through Unity 6.0 LTS
The reason this is confusing is that nothing fails. The request succeeds, the bytes arrive, the texture is created, and what you get is blank. There is no exception and usually no console entry — so the bug looks like it is in your material, your UI canvas or your CORS setup, and it is in none of them.
Why it comes back blank
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class WhyItIsBlank : MonoBehaviour
{
IEnumerator Load(string url)
{
// UnityWebRequestTexture.GetTexture decodes as it downloads — and it
// decodes PNG and JPG. Handed a .webp it gives you a placeholder
// texture and NO error, which is the blank quad you are looking at.
using (UnityWebRequest request = UnityWebRequestTexture.GetTexture(url))
{
yield return request.SendWebRequest();
Texture2D texture = DownloadHandlerTexture.GetContent(request);
GetComponent<Renderer>().material.mainTexture = texture; // blank
}
}
IEnumerator AlsoBlank(string url)
{
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
yield return request.SendWebRequest();
var texture = new Texture2D(2, 2);
// ImageConversion.LoadImage "Loads PNG, JPG or EXR image byte array
// into a texture" and returns false for anything else. Most code
// ignores the bool, keeps the untouched 2x2 texture, and reports a
// blank image rather than a decode failure.
if (!texture.LoadImage(request.downloadHandler.data))
{
Debug.LogError("Not PNG, JPG or EXR — nothing was decoded.");
}
}
}
} Neither of these is wrong about the network. Both of them are wrong about the format.
Unity's documentation is unambiguous once you go looking:
ImageConversion.LoadImage “loads PNG, JPG or EXR image byte array into
a texture” and returns false for anything else. WebP is not on that
list and never has been. UnityWebRequestTexture.GetTexture runs the
same decoder inside the download handler, so it fails the same way — it just fails
somewhere you cannot see the boolean.
So the first thing to fix is the silence: check the return value. A
LoadImage that returns false turns “my image is blank”
into “this file is not a format Unity decodes”, which is a two-minute problem
instead of a two-day one.
Download the bytes, decode them separately
Split the two jobs. UnityWebRequest.Get fetches bytes and does not try
to be clever about them; the decode is then yours to route to something that
understands WebP.
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
public class WebpDownload : MonoBehaviour
{
/// <summary>
/// Downloads the raw bytes. This part always works — WebP is only a problem
/// at decode time, never at download time.
/// </summary>
public IEnumerator Download(string url, Action<byte[]> onBytes, Action<string> onError)
{
using (UnityWebRequest request = UnityWebRequest.Get(url))
{
yield return request.SendWebRequest();
if (request.result != UnityWebRequest.Result.Success)
{
onError($"{request.responseCode} {request.error}");
yield break;
}
byte[] bytes = request.downloadHandler.data;
if (!LooksLikeWebp(bytes))
{
// Worth checking before you blame the decoder: a CDN that
// content-negotiates will hand a Unity client the AVIF or the
// JPEG, not the .webp the URL names.
onError("Server did not return a WebP file.");
yield break;
}
onBytes(bytes);
}
}
/// <summary>
/// RIFF container check, per RFC 9649: "RIFF", a 4-byte length, then "WEBP".
/// </summary>
public static bool LooksLikeWebp(byte[] bytes)
{
return bytes != null
&& bytes.Length >= 12
&& bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F'
&& bytes[8] == 'W' && bytes[9] == 'E' && bytes[10] == 'B' && bytes[11] == 'P';
}
} The header check is four lines and it has settled a lot of arguments about whose bug it is.
That LooksLikeWebp check earns its place. A CDN with content
negotiation will happily serve a Unity client whatever it thinks the client wants —
a JPEG, or increasingly an AVIF — from a URL that ends in .webp. If the
bytes are not a RIFF/WEBP container, the decoder was never the problem, and you can
stop reading here and go fix the Accept header.
What can actually decode it
Three honest options, in the order you should consider them:
- Serve a format Unity already reads. If you control the
endpoint, this is the cheapest correct fix and it is not a cop-out: re-encode to
PNG or JPG at build time, or content-negotiate on the
User-Agent. Zero new code, zero new dependencies, and you keep WebP for the browser clients that benefit from it. If you are loading a handful of images from your own server, do this and stop. - A native plugin around libwebp. Several open-source Unity
bindings exist and they decode correctly and fast. The cost is not the code, it
is the binaries: a
.dll,.so,.aand.aarto ship, sign and keep matched to each platform you target — and getting one onto WebGL means an Emscripten build of the library, not the desktop binary you already have. - A managed C# decoder. No binaries at all, so the same code compiles for every target Unity does, WebGL included. The trade is CPU: managed decode is slower than libwebp, which matters if you are decoding dozens of images per frame and not at all if you are loading avatars.
Whichever you pick, the shape is the same: bytes in, Color32[] or a
pixel buffer out, uploaded into a Texture2D. Do the decode on a worker
thread and the upload on the main thread — Texture2D construction and
Apply are main-thread-only, and everything before them is not. WebGL is
the exception, because it is single-threaded: there the decode runs on the main
thread whatever you do, so a large image stalls the frame.
Cache the bytes, not the texture
The second load of the same URL should not hit the network, and almost every implementation of this caches the wrong thing.
using System.IO;
using System.Security.Cryptography;
using System.Text;
using UnityEngine;
/// <summary>
/// Cache the DOWNLOADED BYTES, not the decoded Texture2D.
///
/// A texture is expensive to hold (width x height x 4 bytes of GPU memory,
/// per image, forever) and cheap to rebuild. The bytes are the opposite.
/// </summary>
public static class WebpByteCache
{
static string PathFor(string url)
{
using (var sha = SHA256.Create())
{
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(url));
var name = new StringBuilder(hash.Length * 2);
foreach (byte b in hash)
{
name.Append(b.ToString("x2"));
}
// temporaryCachePath is the platform's "you may delete this" location.
return Path.Combine(Application.temporaryCachePath, name + ".webp");
}
}
public static bool TryRead(string url, out byte[] bytes)
{
string path = PathFor(url);
if (File.Exists(path))
{
bytes = File.ReadAllBytes(path);
return true;
}
bytes = null;
return false;
}
public static void Write(string url, byte[] bytes)
{
// WebGL has no System.IO filesystem — keep the memory half only there.
#if !UNITY_WEBGL || UNITY_EDITOR
File.WriteAllBytes(PathFor(url), bytes);
#endif
}
} A 2048x2048 texture is 16 MB of GPU memory. The bytes behind it are usually under 200 KB.
Hold the compressed bytes and rebuild the texture on demand: they are one to two orders of magnitude smaller, they survive a scene load, and on desktop and mobile they survive a restart. Holding decoded textures instead is how an image-heavy app ends up with a memory graph that only goes up.
Also called
load webp from url, Texture2D.LoadImage webp blank, UnityWebRequestTexture webp not working, display webp image at runtime, unity webp texture, download webp and convert to sprite — all the same wall, which is that Unity's built-in decoders read PNG, JPG and EXR and nothing else.
WebP from a URL on every platform Unity builds for, WebGL included.
WebP for Unity is a complete WebP codec written in C#: it decodes still and animated .webp and encodes it too, with no native plugins, so the same assembly runs on every platform Unity builds for — WebGL included. Loading from a URL, with a memory and disk byte cache, is built in. It is made for the files you do not control: user uploads, third-party APIs and pipelines that are already WebP.