[VRChat] Swapping Posters with External Images: Keep the Courier Around

Created: 2026-09-08Last updated: 2026-09-09

Displaying a web image on a world panel. Covers why the downloader lives in a field, how to handle Dispose, and building it so the previous image survives a failure.

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.

A poster from the web, applied to a panel on the wall

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.

Sponsored


Images also arrive after you ask

The flow matches text. You ask, and either success or failure arrives later.

Ask; success swaps the image, failure leaves the previous one
MethodWhen 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
Sponsored

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.

Keeping the downloader keeps the image; discarding it takes the image too

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.

Don't call it while displaying. 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.

NameHow to make it, and settings
PosterPanel3D Object → Quad. (0, 2, 4), Scale (1.4, 2, 1). Material PosterMaterial
StatusCanvasUI Canvas (World Space). (0, 0.7, 3.95), Scale (0.003, 0.003, 0.003)
StatusTextTextMeshPro as a child of StatusCanvas. Small text
UpdateButtonCube. (1, 1, 3), Scale (0.4, 0.4, 0.4)
Arrangement of the poster panel, status display, and update button

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.

FieldWhat to set
Poster UrlA direct link to a PNG or JPEG
Poster MaterialPosterMaterial
Status TextStatusText

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.

OrderActionExpected result
1EnterAfter a short wait, the web image appears on the panel. Status reads "Poster swapped"
2View it at an angleThe image doesn't look grainy (the mipmap effect)
3Press update rapidly"Wait a moment and try again" appears
4Change the URL to something nonexistent and re-enterThe 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.

Sponsored

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 in OnDestroy
  • 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.Result into a RawImage's texture instead of a material. Image is 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 VRCImageDownloader in 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.

VRChat Notes in this section63