You want a poster on your world's wall, with the content changing monthly.
Putting the image in the project means a build and upload every time you swap it. Fetching an image hosted on the web means just replacing the file.
This article builds up to displaying one web image on a wall panel.
What You'll Learn
- The flow of requesting an image and receiving it later
- Why you keep the downloader around
- When to call
Dispose()- Building it so the previous image survives a failure
Start from having tried updating a board from external text.
Images also arrive after you ask
The flow matches text. You ask, and either success or failure arrives later.

| Method | When it fires |
|---|---|
OnImageLoadSuccess(IVRCImageDownload result) | It loaded |
OnImageLoadError(IVRCImageDownload result) | It didn't |
The difference is that you can hand over the destination up front.
downloader.DownloadImage(posterUrl, posterMaterial, (IUdonEventReceiver)this, textureInfo);
Pass a material as the second argument and it's already applied to that material the moment loading succeeds. No re-applying logic on success needed.
Failure works out conveniently too. Since no swap happens, the previous image stays. The same result as the notice board's "replace the body only on success" comes for free.
There are conditions on usable images.
- Direct links to PNG or JPEG
- Around 2048 pixels square as an upper bound
- URLs are
VRCUrl. They can't be built from strings at runtime
Keep the courier around
Here's the biggest stumble in image loading.
You create VRCImageDownloader yourself with new. When you do, it must not be a local variable.
// This won't work
private void Fetch()
{
VRCImageDownloader downloader = new VRCImageDownloader();
downloader.DownloadImage(posterUrl, posterMaterial, (IUdonEventReceiver)this, textureInfo);
}
When the method exits, nothing references that variable any more. And then it gets cleaned up along with the image that hasn't arrived yet.

The person who placed the order walks off, and there's nobody to receive the parcel. That analogy makes it easy to remember.
The correct form is a field.
private VRCImageDownloader downloader; // Hold onto it
private void Start()
{
downloader = new VRCImageDownloader();
}
And clean it up when leaving the world.
private void OnDestroy()
{
if (downloader != null) downloader.Dispose();
}
Dispose() declares "I'm done with this." Don't call it while the image is displayed. The instant you do, the images that downloader holds disappear with it. Call it only during cleanup.

Hands-On: Build a poster panel
Show a web image on a wall panel and make an update button re-fetch it.
1. Prepare the panel
Right-click in the Project window and make PosterMaterial with "Create → Material." Set its Shader to Unlit/Texture.
Place the following in the scene.
| Name | How to make it, and settings |
|---|---|
PosterPanel | 3D Object → Quad. (0, 2, 4), Scale (1.4, 2, 1). Material PosterMaterial |
StatusCanvas | UI Canvas (World Space). (0, 0.7, 3.95), Scale (0.003, 0.003, 0.003) |
StatusText | TextMeshPro as a child of StatusCanvas. Small text |
UpdateButton | Cube. (1, 1, 3), Scale (0.4, 0.4, 0.4) |

The Quad's 1.4 × 2 is a portrait ratio close to a B2 poster. Without matching your image's aspect ratio, the picture stretches.
Putting some placeholder image into PosterMaterial up front keeps the panel from being blank before loading.
2. Write the code
Create PosterPanel in Assets/Scripts.
using UdonSharp;
using UnityEngine;
using TMPro;
using VRC.SDK3.Image;
using VRC.SDKBase;
using VRC.Udon.Common.Interfaces;
[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class PosterPanel : UdonSharpBehaviour
{
[SerializeField] private VRCUrl posterUrl;
[SerializeField] private Material posterMaterial;
[SerializeField] private TextMeshProUGUI statusText;
[SerializeField] private float cooldownSeconds = 10f;
// The courier. Hold onto it
private VRCImageDownloader downloader;
private TextureInfo textureInfo;
private float nextAllowedTime;
private void Start()
{
downloader = new VRCImageDownloader();
textureInfo = new TextureInfo();
textureInfo.GenerateMipMaps = true; // No grain when viewed at an angle
Fetch();
}
// Called from the update button
public void RequestUpdate()
{
if (Time.time < nextAllowedTime)
{
SetStatus("Wait a moment and try again");
return;
}
Fetch();
}
private void Fetch()
{
nextAllowedTime = Time.time + cooldownSeconds;
SetStatus("Loading...");
downloader.DownloadImage(posterUrl, posterMaterial, (IUdonEventReceiver)this, textureInfo);
}
public override void OnImageLoadSuccess(IVRCImageDownload result)
{
// It's already applied to the material
SetStatus("Poster swapped");
}
public override void OnImageLoadError(IVRCImageDownload result)
{
// Do nothing. The previous image stays
SetStatus("Couldn't swap the poster");
Debug.LogWarning("[PosterPanel] " + result.Error);
}
private void OnDestroy()
{
if (downloader != null) downloader.Dispose();
}
private void SetStatus(string message)
{
if (statusText != null) statusText.text = message;
}
}
Three things to note.
downloader and textureInfo are fields. Both are created once in Start() and reused on every load.
Note how nearly empty the success method is. VRChat does the applying, so all you do here is report status.
The failure method doesn't touch the image either. That's what leaves the previous poster in place. With the notice board you had to decide "don't touch the body"; with images, doing nothing gets you there.
3. Wire it up
Add a Udon Behaviour to PosterPanel and set the PosterPanel script.
| Field | What to set |
|---|---|
| Poster Url | A direct link to a PNG or JPEG |
| Poster Material | PosterMaterial |
| Status Text | StatusText |
Put a relay script on UpdateButton.
using UdonSharp;
[UdonBehaviourSyncMode(BehaviourSyncMode.None)]
public class PosterUpdateButton : UdonSharpBehaviour
{
[SerializeField] private PosterPanel panel;
public override void Interact()
{
if (panel != null) panel.RequestUpdate();
}
}
4. Confirm
Launch Build & Test.
| Order | Action | Expected result |
|---|---|---|
| 1 | Enter | After a short wait, the web image appears on the panel. Status reads "Poster swapped" |
| 2 | View it at an angle | The image doesn't look grainy (the mipmap effect) |
| 3 | Press update rapidly | "Wait a moment and try again" appears |
| 4 | Change the URL to something nonexistent and re-enter | The panel keeps the image you set originally. Only the status says "Couldn't swap the poster" |
Row 4 is the most important check in this article. Even when loading fails, the wall doesn't go blank.
Common Pitfalls
- It never displays → You're creating the downloader as a local variable. Make it a field
- It displayed once and vanished → You're calling
Dispose()while displaying. Cleanup belongs only inOnDestroy - The picture stretches → The Quad's aspect doesn't match the image's. Match them, or prepare an image with margins
- The panel is dark → The material's Shader is still Standard. Use Unlit
- Loading fails → Check that the URL is a direct link, that it's PNG or JPEG, and that the size isn't too large
- Rapid presses stop it loading → You're hitting the rate limit. Check the spacing logic
Bonus: Good to Know Up Front
- Testing in the editor rewrites the material: Loading in Play mode can leave that material's appearance changed. If that bothers you, duplicate the material for testing
- Use RawImage for UI: To display inside a Canvas, put
result.Resultinto aRawImage'stextureinstead of a material.Imageis a component for Sprites and isn't used here - For switching between several: Hold URLs in an array and switch with a button. Each switch triggers a load, so watch the rate limit
- Each person swaps their own: One person updating doesn't change anyone else's panel. To line everyone up, send the trigger with network events
- It's useful all over: Event announcement posters, photo galleries, monthly art exhibitions, swapping signage. Anything you want to change without rebuilding
Summary
Displaying external images comes down to keeping the courier around.
- Hold
VRCImageDownloaderin a field. A local variable gets cleaned up before delivery - Hand over a material and it's applied the moment loading succeeds
- Failure does nothing, so the previous image stays
Dispose()is only for cleanup. Don't call it while displaying
The question to ask while building is: "When it can't load, what does the wall show?" If the previous poster is still there, the design is right.
To save per-person settings, go to saving volume with PlayerData. To play video, go to setting up a video player.