Mozilla Hubs is capable of rendering highly realistic scenes, but it can also render stylized ones, and pixelated retro worlds are no exception. There is something charming about 3D worlds made from retro pixel art. From my experience, the nostalgic appearance of these worlds can trigger incredibly powerful emotions in people! Nostalgia is a powerful attention grabber that should not be underestimated.
Sprite-based worlds also have the benefit of being extremely performance-friendly, so it's a great way to create a world that you can show off to anyone on almost any device - even a low-end mobile phone.
In this tutorial, I’m going to show you how to to create a 3D world in Blender using 2D pixel art exclusively as our textures, with tips and tricks shown throughout the tutorial to optimize the scene for our target platform, Mozilla Hubs. Then I'll show you how to get it up and running for the whole world to visit on the web!
Art Sources
For the purpose of this tutorial, I will be using public-domain pixel art from OpenGameArt.org. There are other places where you can source pixel art from – Google can help you there. As a side note, I will be referring to pixel art interchangeably as “sprites” or “spritework” throughout this tutorial.
Wherever you get your sprites, there are certain guidelines to keep in mind when picking the source:
- Sprites that were drawn for a top-down camera view are generally ideal. ie. RPGs where you see the character/scene from above, in an overhead angle.
- Sprites drawn with an isometric view are generally much harder to adapt into 3D as-is, and I strongly recommend against them for a beginner.
- Sprites drawn for 2D side-scrolling gameplay, meaning that you can only see the front of the object, can be usable but will require extra work/creativity on your part to adapt to 3D since you only have one view to start with - the front view.
- Sprites with a lower resolution (ie. 8-bit) are the easiest and strongly recommended for your first attempt.
Tools
For this tutorial, I am going to be using the newest version (at the time of writing this) of the following tools, all open source.
- GIMP 2.10.32 (image editor) – https://www.gimp.org/
- Blender 3.4 (3D modeling suite) – https://www.blender.org/
- Mozilla Hubs / Spoke (VR platform) - https://hubs.mozilla.com/spoke/
I will keep the image editing instructions general enough so that you can follow most of the same steps in any other image editor that you may prefer. GIMP just happens to be available on every major desktop OS and 100% free, so anyone can use it. Although GIMP has a complex interface, we’re only going to use a tiny subset of its features, so you can safely ignore most of its interface without feeling overwhelmed. There is a part of this tutorial, towards the end, that can only be done in GIMP, so I recommend you install GIMP even if you follow most of the tutorial using an alternative program.
I always use the latest version of Blender, which at the time of writing this, is 3.4. However, you can probably follow the same instructions on earlier versions, at least since 3.0. There may just be slight differences in the UI that you'll have to deal with.
For Blender, I’ll also be using the Hubs Exporter addon, which makes publishing of Blender scenes in Hubs much easier (in my humble opinion!). You are expected to have this addon installed for this tutorial. You can download the addon separately using the link below.
https://github.com/MozillaReality/hubs-blender-exporter
For this tutorial, you will also be expected to have at least a basic understanding of the latest Blender’s interface and tools. You should already know how to move, rotate, and scale objects, how to assign materials and textures, and how to work with collections. There are many tutorials on YouTube that can get you up to speed on the very basics.
Create a Project Folder
This is how I recommend you structure your project folder. You’ll be storing all your textures in the textures folder, and exporting models for Hubs to the export folder. I also often create a "ref" folder for storing visual references for the scene I'm planning to create.


Preparing Your First Spritesheet
Our first task is to create a sprite sheet that will contain most, if not all, of the 2D tiles for your scene. A sprite sheet is basically an image that has many sprites laid out in a large grid. What I typically do is use one spritesheet for each area; for example, one sprite sheet exclusively for outdoor tiles, another for indoor areas, and another for characters. You can also just combine them all into a single one, but you’ll need to increase the image size accordingly if so.
Typically, you can usually go with an image size of 256 x 256 or 512 x 512 for your sprite sheet, or perhaps even smaller, depending on the complexity of your scene.
It’s good practice to stick to dimensions that are a power of 2, so you may need to resize the image even if it's already in a grid format.


I’m going to be using this tileset, created and published by Buch under a CC0 public domain license. The image above depicts what the final scene will resemble after all our tiles are arranged in the right positions. Download the file named tiles_packed.png on that page and place it in your textures folder.
This sprite sheet is already in a convenient grid format, but without ideal image dimensions (368 x 128). Let’s resize it in GIMP real quick to 512 x 128 to keep the dimensions in powers of 2. Open the PNG in GIMP, and in the top menu bar, go into Image→ Canvas Size...

Set the width to 512 as shown above. Then press Resize. Now do File→ Overwrite tiles_packed.png in the top menu bar. You’ll notice that a bunch of empty space was created in the image. That’s okay – you can use the extra space to fit in extra sprites/tiles if you wish, but you can also just leave it blank. That extra space won’t really make any notable difference in performance or file size. The image is still really small anyway!
So we now have a sprite sheet that is ready to use. So let’s open up Blender!
Setting Up Your Blender Scene
You can either create an entirely new Blender file yourself, or you can use the template I've made available for download below:

Delete the default cube and create a Plane (Shift+A → Mesh → Plane). By default, this plane will have dimensions of 2 x 2 Blender units. In the right-hand sidebar (N), in the Item tab, you’ll see the Dimensions listed, as seen below:

To ensure that all of the sprites in the scene maintain correct relative proportions to each other, we’re going to be using the scale of 1 Blender unit = 1 sprite pixel. Later on, we’ll resize the entire scene so that it’s in the proper dimensions for Hubs, but for now, stick to these proportions. The sprite sheet we resized earlier has dimensions of 512 x 128, so we’re going to resize the dimensions of this 3D plane to X=512, Y=128.

Create a material called “tiles” on this plane, and add an Image Texture node with the tiles_packed.png we resized earlier. Set it up so it looks like the setup below:

Note the following settings:
Cutting Up Your Sprites
The next task is going into Edit Mode (Tab) and using the Knife tool (K) to cut up the plane, so we can get the individual sprites out of it. As you make cuts, you can extract each individual sprite using the Y key and then move it out of the main plane, as shown below:

Do this until you’ve completed the whole set or at least the ones that you know you'll actually use. Yes, this might take a while! It’s one of the most time-consuming parts of this process. You’ll need to zoom in on the pixel to make sure you’re cutting exactly at the ends of each pixel. In some cases, this process can be made faster by also utilizing Loop Cuts (Ctrl+R while hovering your cursor near an edge, then mousewheel up), as shown below:

Perfecting Your Cuts
During this cutting process, you'll probably notice that the Knife tool can be quite unpredictable, and it often creates cuts in places you weren't even trying to. You can usually fix this after it happens, by hitting X and choosing Dissolve Faces, while the faces with unnecessary cuts are highlighted in Edit mode.
If you mess up and are slightly off in your intentional Knife cuts, don’t worry! You can correct your own cuts easily by enabling “Correct Face Attributes” in the lower right Options menu, as shown below:


Correct Face Attributes makes it so that you can move edges around without actually changing the appearance of the plane. See below for a demonstration:


Removing Transparency
Later on, we’ll be baking all of the light in the scene. However, baking the light on objects with transparency can lead to very ugly lighting artifacts.
Because of this, I strongly recommend cutting up your sprites so that any transparent parts are completely removed. One way to achieve this quickly is by using Loop Cuts (again, Ctrl + R while hovering the cursor over the sprite, then mousewheel up) to create a pixel grid on the sprite. Then highlight the transparent pixels and delete them. See the GIF below for a demonstration:

After deleting the transparent parts, you can remove the pixel grid we just created by highlighting the entire sprite in Edit Mode, and then press X → Limited Dissolve on it again. That grid was drastically increasing the polygon count of the sprite, and we don’t need it anymore, so it’s good practice to get rid of it to maximize performance.
After completely removing all transparent parts from all of your sprites, you can also then remove any Alpha settings from your material, which will also help with the performance of your scene. Set the following back to Opaque, for both Blend Mode and Shadow Mode:


Structure Building
Once you’re done cutting everything up, highlight all of the faces in Edit Mode, press P to open the Separate menu, and choose “By Loose Parts.” This will separate all of your sprites into separate objects, which you can then duplicate individually as many times as you need to create the structure of your scene.

We now have all our sprites as individual objects, ready for use, as shown above. Notice that I already went ahead and rotated some parts of these sprites to make them have actual 3D depth, such as the terrain on the left, and the stairs on the center-left.

Look closely at the staircase sprite above. Try to imagine how you’d turn this into 3D. Notice anything weird? In 2D, the stairs look fine, but the moment you start recreating this in 3D, you’ll immediately notice how problematic this sprite actually is. Assuming that the walls next to the stairs are 100% vertical, the stairs would be perfectly vertical too, and wouldn't make any sense in 3D as is.
You’ll encounter this often when working with videogame pixel art. Many staircase sprites are drawn this way. For that reason, you’ll often have to get creative and extend the length of the 3D stairs beyond the original length of the 2D stairs. Otherwise, visitors won’t even be able to walk up the stairs!
Back to where we were: You can now start tiling all of the pieces together to create the base structure of your scene. There are two features in Blender that make this part significantly easier:

Snapping – This allows you to automatically snap pieces of geometry to each other, based on their edges, faces, or vertices. There are many tutorials on the snapping system out there if you’re not already familiar with it. With snapping, you can very quickly build a terrain using the modular pieces we've prepared, as long as the edges/vertices on the pieces are close enough.

1) Join the separate sprites into one object by highlighting both in Object Mode and hitting Ctrl+J.
2) In Edit mode, highlight both/all faces, press M to open the Merge menu, then choose By Distance. This merges any adjacent edges/vertices together so that there are no "doubles." (Trivia: This tool actually used to be called "Remove Doubles" in older Blender versions!)
Array Modifier – Very often, you’ll encounter a sprite that is supposed to repeat over and over, in many rows or columns. You can use Blender’s Array modifier to achieve this quickly without having to manually tile the sprite yourself. You can even use the Array modifier to tile an entire 3D structure of sprites, as shown below.

This can save an enormous amount of time. If you use it, note the following:
- Make sure you tick the Merge checkbox in the Array modifier so that the new copies are all connected.
- The Array modifier might produce unexpected results if there are temporary transformations (ie. rotation, scale, translation) on the model. If this happens for you, hit Ctrl+A and choose All Transforms and it might fix that.

Guidelines for Animated Sprites
Because animation is a huge complex topic all by itself, we won’t be going into a detailed step-by-step on how to animate sprites in this tutorial. However, here are a few quick notes that I will mention.
Do not store animated sprites in the same file that you’re using for the static sprites. Animated sprites must be laid out in a flipbook format, which your main tileset usually won’t and often can't have. In addition, animated sprites nearly always have transparency, and our main tileset will have alpha rendering disabled. As a general rule, keep all of the following categories in their own separate image files - do not mix and match these:
- animated sprites with alpha (ie. most animated sprites)
- animated sprites
- static sprites with alpha
- static sprites
This allows us to selectively enable alpha rendering only on specific parts of the scene, not on the biggest parts. That's better for performance and also prevents ugly light artifacts on the static parts of your scene, later on when we bake light.
1) Alpha Clip
renders pixels either 100% transparent, or 100% opaque
2) Alpha Blend
renders pixels in a range between 0% to 100% transparent/opaque.
You should stick to Alpha Clip as much as possible.
You'll rarely ever need Alpha Blend in sprite-based worlds. There are only a few situations where you might. For example, creating fake light rays emanating from windows. Or when you want to add "fades" to passageways or doorways, so that they fade to black. For both of these scenarios, you can use a transparent gradient texture that fades to 0%, or vertex color alpha.
Alpha Blend does come with a significantly higher performance hit than Clip, and also can create rendering glitches when there's overlapping between transparent objects. So use it only sparingly.
To maximize performance even further, if you have many different animated sprites, you can combine them all into one single sprite sheet, if they happen to have the same frame count. Here’s a visual example of what I mean:


Pictured are 4 different animated circles. They all have 4 animated frames (going across horizontally), so they all fit neatly into a single square-shaped texture. You can use a single sprite sheet like this to add, for example, dozens of different animated NPCs to your scene, using only a single image file! It’s great for performance, and also saves you from having to create separate materials for every single NPC.
Organizing the Scene
After you finish tiling your sprites, you should end up with something roughly like the following:

It's okay if yours doesn't look exactly like the original reference. Remember that you are creating your own adaptation - in 3D and for VR. That inherently means there will be differences from the source material.
Since we are creating a scene specifically for Hubs, which can host dozens of people at a time in a single room, you'll also want to keep that in mind when structuring the size of the scene. If the original source material was too small to hold this many people, it's okay to expand on it and add extra walkable areas.
Now that we have a base structure completed, we can start to add props, like trees and rocks. However, just before we do, let's do some cleanup first in the Blender project itself, to keep things organized and easier to export later on. We use the Outliner for that - usually located at the very top right of the Blender window, where all your objects and collections are listed.

Organize your scene so that it's organized as above. We want to have everything that we want to export in one big "export" parent collection. Add a subcollection inside it that will contain our lights (for a daytime outdoor scene though, a single Sun light is often enough), and another subcollection for static structures/terrain. We'll also need a subcollection for any sprites that aren't base terrain/structures, such as rocks or trees, as pictured above.
Outside of the export collection, create another collection named "modular" and add to it all of the modular sprite pieces that we created and cut up very early on in the tutorial. We still need some of these to place our remaining props (ie. rocks, trees) into the scene, but we don't necessarily want the raw modular pieces of terrain exported with our scene to Hubs, so let's keep them separate. That makes it easy to exclude them at export time.
As for our completed models themselves, my recommendation is to have one object for terrain, one for manmade structures (like the stairs and bridges), and one for any bodies of water (if there are a lot of them). This lets you selectively hide/show different parts if you ever need to. Ultimately, when we export the scene later, everything will be exported together even if they're in separate objects.

Placing Props
Start plucking props from the "modular" collection, such as rocks and trees, and place them onto your base terrain. As you do this, there are a few things you want to do every single time for the sprites you move over:
- Move each sprite object (with the M hotkey) to the "sprites" subcollection we created earlier.
- After positioning, with the sprite still selected, hit Ctrl+A → Rotation & Scale
- Set the origin point of each sprite to the bottom of the sprite. This is important, and you'll see why in a bit. See below:

Snapping (via Face mode) can be used to position props onto the terrain in a more natural fashion:

Notice how this rock above is completely flat, though? There are times when, due to the way the sprite was drawn, you may need to just leave the sprite flat like this. That's okay - because we're going to be setting it as a billboard sprite. Billboard sprites always automatically rotate to face the camera, creating the illusion that they're a real object present in the scene, even though they're technically just a flat picture! We're intentionally not going for realism in these kinds of projects, so using an illusion like that is totally okay here.

To designate a sprite as a billboard sprite, highlight the sprite object and go into the Object Properties on the right side of the Blender window. In newest Blender versions, it's the tab with an orange square icon (see above). Assuming you successfully installed the Mozilla Hubs Blender addon at the beginning of the tutorial, you should see a Hubs subtab here, with a button titled "Add Component." Click it and select "Billboard." You won't see the billboard effect in Blender itself after you add this component - it only has an effect in Hubs.
Note that the tree's billboard sprite is uniquely large compared to every other sprite, and because of that, it should also have the "Only Y" checkbox enabled. When you have a particularly large object like a tree, the billboard effect just looks too fake without this setting enabled.
Repositioning and Resizing the Scene

Once you're done positioning all of your prop sprites, you should end up with something resembling the above. Notice that I've disabled the "modular" collection. We no longer need it, so untick the checkbox and disable it to ensure that it won't be exported when we export the whole scene to Hubs later.
During this whole time, we've had the entire scene positioned in an offset location from coordinate (0,0,0). We want to reposition it so that it's actually properly centered in the scene. An easy way to do this is to use the Numpad 1 and 3 keys to change the view in Blender to see the front and sides of the whole scene. Highlight everything using the A hotkey and then hit G to move everything until coordinate (0,0,0) is around the middle of the scene, but above ground, like so:

Now we need to also correct the size of the whole scene to one that works for Hubs' default avatar size. In the right sidebar of the 3D viewport (N key), in the View tab, you'll see settings for the 3D cursor's location. Change this to zero for all three, X, Y, and Z. This will move the 3D cursor to coordinate (0,0,0).

There's an extremely handy feature in Blender called Walk Navigation. By default, it's usually assigned to the hotkey Shift + tilde, which is usually under "Esc" on a keyboard. You can also find it in the 3D Viewport menu, View → Navigation → Walk Navigation. When you use it, it enables a first-person camera mode that lets you fly through the scene with the WASD keys (like in a first-person shooter) and preview it like a videogame. I use this constantly when I'm working in Blender. Note that you can increase/decrease the movement speed by moving the mouse scrollwheel up and down.
Walk Navigation can be used to gauge the scale of your scene for Hubs, thanks to its gravity feature. Press the G key while Walk Navigation mode is enabled, and you will be dropped down to any ground immediately beneath you, allowing you to walk on the ground. As soon as you land, look around and walk around to see if the scene is too big. The scene will likely look massive the first time you try this, as when I did it, the tiny mushroom sprites were several times taller than the height of my eye level. Hubs's default eye level isn't 100% identical to Blender's, but it's very close, so it still gives us a good idea of where our scene is in terms of Hubs' scale.

So let's resize! Exit Walk Navigation mode by simply clicking anywhere, or pressing Esc.
We first need to set our pivot point to the 3D cursor that we set to (0,0,0) earlier. Hit the period (.) key and select 3D Cursor, as shown below. This will ensure that when we resize the whole scene, it all stays still centered around the center point, (0,0,0).

Now highlight everything in your scene using the "A" hotkey. Press S to begin using the scale tool, then type .5 on your keyboard to shrink it by half. You'll usually need to do some trial and error and keep trying different numbers, while repeatedly entering Walk Navigation mode to test the scale each time. For this particular scene, I was able to resize to something reasonable by using a scale of .125, but this will vary for every scene.
Applying Transforms
There will be many times in Blender when you will accidentally move, scale, or rotate something, and you want to be able to quickly reset it to where it was supposed to be. We can make this possible by applying transforms on the objects in our scene so that their transforms (or at least most of them) become permanent. We already applied transforms previously in the tutorial with the Ctrl+A hotkey - now you know the reason for it!
For the static objects, which include terrain and other larger structures (like the bridges and the stairs), we want to highlight them, hit Ctrl+A, and select All Transforms.
For the billboard sprites though, special care is needed. De-select everything first by double-tapping the A key. Then select only your billboard sprites (easy to do if you've kept them all in one collection). Then make sure that the sprites are all facing forward, meaning that when you press Numpad 1 in the 3D viewport, they're all facing you head-on, with no weird rotations on them.
Now, hit Ctrl+A, but this time, we actually only want to apply the Rotation & Scale. Do NOT apply Location on your billboard sprites! As noted earlier in the tutorial, the billboard effect is dependent on each sprite's origin point. Applying the Location transform on them will alter their origin point, which would ruin their billboard effect.
If you applied transforms correctly, you should now be able to resize or rotate any of your 3D objects, and then reset their scale/rotation back to normal by hitting Alt+S (scale) or Alt+R (rotation). For the terrain and other large static structures, you can also reset their position with Alt + G if you accidentally move them.

Skybox Rendering
We've had a boring grey backdrop around our scene this entire time. It's time to finally pretty it up, by creating a skybox. How you come up with a skybox is largely a matter of preference and will vary per scene. For this specific tutorial, we're going to go with a simple, no-frills, gradient sky.
Gradient skies can be generated in Blender itself very easily. Create a new, separate Blender file and save it as "Skybox.blend" inside the same folder. You can use the Blender Template I provided in the beginning of the tutorial, or you can just create your own. Whatever you go with, set up the UI so that you have the 3D Viewport and the Shader Editor both visible. The Image Editor won't be needed until the end. You can go with a setup like this:

First, delete the default cube. There should be nothing in your scene except for a lone Camera object. Now select the Camera object. Hit Alt + G, followed by Alt + R to reset its location and rotation. It will now be in the dead center of the scene (0,0,0) and will be rotated to face straight downward. We want to rotate it on the X axis by 90 degrees, so hit the following keys: R, X, then type in the number 90.

Go into the Output Properties on the right of the Blender window.

Adjust your settings so that they reflect the above. Normally, skyboxes are extremely large images, but for a gradient skybox, you can get away with something relatively small, like 1024 x 512 here. Just make sure the width is twice the height. This is what we call equirectangular format.
Now let's tell the Camera object to also render in a panoramic, equirectangular format. At the time of writing this, only the Cycles rendering engine in Blender supports this. Eevee does not (yet!) So make sure you set Blender to use Cycles, like so:

Go into the Camera Properties tab now. Change the settings to the following:

Just one more set of settings we have to change, this time on the Render Properties tab (it's the first one). Change yours so they look like this:

Now let's set up the World material that actually tells Blender what gradient to use. As you're doing this, you can actually keep a live preview of the skybox in the 3D viewport by pressing the Numpad 0 key and going into Rendered mode with the Z key. Setup your material like in this example below, via the Shader Editor.

No textures are needed for this to work. You can find all of these nodes, by the way, using the Shift + A shortcut in the Shader Editor and then typing part of the node name.
You are welcome to use your own gradient colors. You can get ideas for some by googling "sky gradients." However, do note that there is one color that should be left the same as in my example, and that's the darkest color you see, on the farthest left of the ColorRamp.

This happens to also be the exact color (Hex code #292634) that is visible at the bottom of our platforms:

Toward the end of the tutorial, we're going to be making these platforms look like they're fading into the abyss below, and we can only do that if the bottom color is identical to the one in the bottom of the skybox. So that's why you see this color in the ColorRamp node.
Once you've set up the shader nodes correctly, you can go ahead and hit F12 to render the skybox. Blender will pop up a new window with the rendered image. It should be an almost instant render. Blender isn't going to save this new image automatically. Go into the menu of the window, in Image → Save a Copy, and save the image to the textures folder as "skybox.png".
To actually use it in our Sprite World, reopen your Blender file for the Sprite World scene, go into the Shader Editor (set to World mode, not Object mode), and modify the existing World material by adding an Environment Texture node.

You should immediately see it in effect in the 3D viewport while in Rendered mode (Z hotkey).

Keep in mind though, that this affects the skybox appearance only in Blender. It does absolutely nothing for Hubs. If you exported the scene right now, Hubs still wouldn't have a clue that this is supposed to be the skybox. To also set up the skybox for Hubs, we need a few extra steps.
In the Scene Properties tab, look for the Hubs section, and click the Add Component button. Add an Environment Settings component like below:

Adding a Pixel Sun
We're not quite done with the sky yet. Although we have Sunlight in the scene, it doesn't actually create a visual representation of the sun in the sky. All it does is cast light.
Remember that empty space we had in our tiles_packed.png? It's perfect for little extras like this. We can draw a simple sun in there. Open the PNG in GIMP and find an empty area. You can use the Ellipse Select tool (Tools → Selection Tools → Ellipse Select) to create a circle-shaped selection while holding the Ctrl and Shift keys, and then use the Bucket Fill tool (Tools → Paint Tools → Bucket Fill) to fill it in with a yellowish color. Here's how mine turned out, in the lower right:

Create a plane in the Blender scene with the dimensions of 512 x 128, just like when we first began. Assign it the "tiles" material we've already been using. However, we want the sun to be emissive, unlike the rest of the scene, so we actually shouldn't use the same material, even though it'll use the same texture. Duplicate the material by clicking the 📄 button right next to the ✖ button (see below). Name the new material "sun" and set its settings like this:

Now modify the nodes of the new "sun" material, like below:

Use the Knife tool (K) to cut out the sun sprite from the spritesheet, and delete the leftovers. Then follow the same process we used earlier for all the other tiles. For example, create a grid on the plane using Loop Cuts, remove the transparent parts, etc.
Once you're completely done using the Knife tool, clear ALL transparency settings in the sun material, ie. change Alpha Clip to Opaque, remove the Alpha connector from the Principled BSDF node, etc. Rename the sun object to "sprite_sun" and place it in the "sprites" subcollection.
Then use the Rotate (R) and Move (G) tools to position the sun far off in the distance. Here's where I recommend placing it:

There's still plenty of room in the tiles_packed.png, so if you wanted to, you could go even further and draw sprites for clouds, a moon, stars, etc. (leaving the possibility of a night time version of the scene in the future 🌝). That's up to you.
Concepts of Lightmapping
We've reached the most advanced part of the tutorial, so it's important to go over a few concepts first.
To sum it up, light baking (or "light mapping") is the process of pre-rendering the lighting/shadow data in the scene, allowing you to save all of the lighting to a file - what we call a lightmap. You can then add this prerendered lightmap to your 3D scene's materials, to add performance-friendly, high-quality realistic lighting to the scene. Rendering photorealistic 3D light in real-time is normally a very intensive process, requiring lots of processing power. However, by saving the light to a lightmap, you allow the 3D renderer to skip that intense rendering process, by instead loading the lighting from a pre-rendered image file.
Technically, you aren't required to bake light for your scene. You can actually leave the whole scene with zero lights or lighting, and then Hubs will render it fullbright / unlit when exported via the Blender Hubs exporter addon. For sprite-based worlds, this can actually look okay - in fact, it might even look more authentically retro! However, from my experience, baking Blender light into your scene can really make it feel much more immersive, adding much more of a sense of "place." In my opinion, the extra work is absolutely worth it. Due to the simplicity of this tutorial scene, that might not be immediately obvious - but as you create other scenes in the future, with more complex shapes and lighting setups, you'll see what I mean!
One critically important concept to understand for lightmapping is high-dynamic range, or HDR. Common image formats, like PNG, store data in 8-bit format, between values of 0.0 to 1.0, with zero being the absence of any light (pitch black), and one being the complete absence of shadow (pitch white). However, .HDR images can store values above 1.0, creating a bloom effect. When you have light in your Blender scene that is making textures brighter than they originally were, you are actually creating light values above 1.0!

When we bake light, we don't want to bake to a PNG, because that format can't store values above 1.0. Highlighted areas with bloom, like in the screenshot above, would be completely lost. Thankfully, the .HDR format can store values above 1.0 in 32-bit format, and the latest versions of Blender, GIMP, and Hubs all have full support for .HDR images. So that's what we're going to use!
Lightmapping Setup
First off, make sure you're in Cycles mode, not Eevee, because, at the time of writing this, only Cycles can bake light to an image file. If you downloaded my Blender template in the beginning, you'll already have a basic Sun light in the scene. If you don't, add a new Sun light with the settings below, and rotate it so that it matches the direction of the sun sprite we created earlier. The position of the Sun light doesn't matter and won't change how it casts light, but for the sake of making it easy to find, place it near the center of the scene.

We want to temporarily disable all of our billboard sprites (and anything else animated or dynamic) before baking. Why? As billboard sprites rotate to face you, any shadow you had baked for them instantly becomes inaccurate, making it pointless to bake the shadows they cast. So disable the "sprites" subcollection in the Outliner.

To make things easier, let's combine the "terrain" and "structures" into one object. Select the bridge objects first, then the terrain (in that order), and then hit Ctrl + J while hovering your mouse in the 3D viewport.
Select the newly combined "terrain" object, and create a second UV Map for it named "lightmap" in the Object Data Properties, like below:

We also want to create a new image in which we'll temporarily store our lightmap. In Blender's Image Editor, hit the New button, and set it up like below before pressing OK:

You'll then see a pitch-black image created. Go into the Image tab and set "Color Space" to Linear. Lightmap images should always be set to this.

Now back to the 3D Viewport. With "terrain" still selected, apply all transforms one additional time (Ctrl + A → All Transforms). This helps prevent warped UVs.
Now hit Tab to go into Edit mode, and follow these steps:
- Tap the A key to highlight all faces of the 3D model.
- Hit Ctrl + T to triangulate every face
- In the 3D Viewport, press the U key and select "Smart UV Project"
- You should see a newly generated UV Map in the Image Editor.
UV maps generated automatically by Blender's tools are rarely ever ideal. You usually have to tweak them further. You'll need to do some trial and error, repeatedly, to figure out the best settings to use for the "Smart UV Project" tool. Here's what I settled on:

One particular thing to watch out for is making sure that there's enough margin area around each UV island. Make sure all of your UV islands are far enough apart so that they have at least 5-10 pixels of space around them. Also, ensure that none of the UVs are overlapping (even within the same UV island)! Lightmap UVs should never be overlapping, not even by one pixel.
Lightmap Baking
With our lightmap UVs all ready, there is just one final step before we actually bake. We need to tell Blender what file to bake to. Go back into the Shader Editor with the "tiles" material active. Modify it so it has the following setup:

After you're done, click on the Image Texture node titled "light" (the one on the far right.) This sets it as the "active" node, which tells Blender that it is the texture we want to use for baking. Yes, this is a very weird, not very intuitive way to set that, but that's just how Blender does it. 🤷♂️
Go into the Render Properties tab on the right side of Blender and adjust your settings like in the screenshot below. I highly recommend reorganizing the sub-tabs so that you see them in this order, as these are the ones most relevant for baking.

Once you've finished copying these settings, press the "Bake" button at the top. Blender will begin calculating the lighting for the 3D object you currently have selected, and then will save the lighting to the image file we had set as active in our 3D model's material. This could take some time, depending on the power of your hardware. If it takes too long, try lowering the "Max Samples" amount.
Once the lightmap is fully generated, it should appear in the Image Editor within Blender. Save it via the Image Editor menu, Image → Save a Copy...
In the Save menu, on the righthand side, choose "Radiance HDR" as the format, as shown below. Make sure that "Save as Render" is disabled, and Color Space should be set to Linear. Save the .HDR file as "light.hdr" in the textures folder.

Cleaning Your Lightmap
There are many people out there who unfortunately assume that open source projects aren't as "good" as the commercial alternatives, just because the open source options are usually free. GIMP is a key example of how untrue this assumption is, because it is the only image editor I know of that can effectively and easily denoise a 32-bit (HDR) image. Even some of the most popular commercial image editors struggle with HDR images. For that reason, GIMP is mandatory for this part of the tutorial.
Open your new "light.hdr" in GIMP. There are two filters that can be used to reduce noise in HDR lightmaps:
- Filters → Blur → Selective Gaussian Blur
- Filters → Enhance → Noise Reduction
These filters will become your best friends as you begin creating lightmaps. Both achieve noise reduction but in different ways. Some lightmaps will do better with one of these two than with the other. Experiment with both and see what gives you the best results on a case-by-case basis. For this particular tutorial scene, I found Noise Reduction did a decent job, though only by running it multiple times. Selective Gaussian Blur usually does the trick after only one run.
Unlike HDR, EXR supports transparency, meaning that the UV islands will retain the transparent margins around them. The "Selective Gaussian Blur" filter in GIMP will respect these transparent margins, which then helps prevent color bleeding.
However, note that .EXR is not currently supported by Hubs. So you will need to save it again as an .HDR after you're done denoising the .EXR image in GIMP.

Applying Your Lightmap
We've denoised our lightmap and made it ready for use. Select the terrain object in the 3D viewport in Blender. Then go into the Shader Editor and modify the "tiles" material like below:

However, note that this shader setup does not make your new lightmap appear in Blender. It only enables the lightmap for use in Hubs. To temporarily preview how your lightmap looks, use this shader setup below instead:

After setting it up as above, you should now see the lighting and shadows in your scene, even when you are in Material Preview mode (Z → Material Preview).

But what about our billboard sprites? We didn't bake light for those - but thankfully, we won't have to! We can actually reuse the lightmap we just made. Re-enable the "sprites" subcollection. As soon as you do, you'll notice that your billboard sprites will all be pitch black. They're still assigned to the same "tiles" material we're using for the baked terrain, but unlike the terrain, we never set up the billboard sprites with the correct lightmap UVs. Let's do that.

For each billboard sprite, do the following:
- Select the billboard sprite in the 3D viewport.
- Go into Edit mode (with Tab), highlight all faces with A, then press Ctrl + T
- Go back to Object Mode (with Tab)
- Create a new UV Map in the Object Data properties tab on the right side of Blender. Name the new UV Map: "lightmap"
- Check the UVs of the terrain immediately next to the billboard sprite. Adjust the billboard sprite's lightmap UV map so that it's roughly in the same position. If that sounds confusing, look below for a visual:

When you've finished doing this for every single billboard sprite, you should end up with something like this:

Don't forget to set the "tiles" material back to the Hubs-friendly one. The temporary node setup that made the lightmap visible within Blender should not be the one exported to Hubs.
Fading Into the Void
Remember that special dark color we added to the bottom of our skybox? The intent was to make that color match the bottom of our 3D platforms so that they fade into the skybox. Baked light ruins this effect; see the screenshot above. The bottom colors no longer match the dark purple void in the skybox below.
We can fix that, by adding a special "fade out" wrapper around our terrain. Duplicate the entire terrain as a separate object, and then delete all of the vertices in the duplicate except for the very bottom ones that touch the void below. Then extrude those bottom vertices upward to the top of the platforms. Make the top vertices all align on the Z axis using the Scale tool (press S, then Z, then type zero). It helps to have Snapping (by Vertex) turned on while doing this. See the demonstration below:

This "wrapper" is going to wrap around our terrain and cover it with a fading gradient. The wrapper is currently overlapping with the original terrain, which can cause rendering glitches, so let's enlarge it by just a tiny bit. Highlight all faces in Edit mode press Alt+S, and then you can type in a very small number like 0.01 or so to enlarge it only just a tiny bit, then hit Enter.
Now delete the material on this wrapper. Create a new one named "fade" and set it up like below:

But wait, we haven't created a "fade.png" yet! You can create one pretty easily in GIMP using the Gradient tool, with an alpha channel. To save you the hassle, I already prepared the fade.png for you. Right-click this image below and save it to your textures folder. Then assign it to the Image Texture node in your "fade" material.

Go into Edit mode with the 3D fade wrapper selected, highlight all faces, and press Numpad 1 to adjust the viewport so that you're facing the front of the model directly head-on. Then press U and select Project from View (Bounds). You might not notice any effect in the 3D viewport, but if you temporarily hide your terrain, you can see that the fade is, in fact, there. It'll be noticeable once you export everything to Hubs.

Hubs-Specific Scene Entities
We're almost done! If you've managed to follow this tutorial all the way to this point, pat yourself on the back. I know it's been a lot! At this point in the tutorial, your scene should look like this in Blender's Outliner:

We're now going to add some features to the scene that are specifically for Hubs.
- Navigation Mesh - this tells Hubs where the "walkable" surfaces are. Without it, there would be no collision and you would only be able to float around the scene like a ghost.
- Directional Light and Ambient Light - Without these, avatars would have no light casting on them.
- Spawn Points - These tell Hubs where to place your avatar when you enter the scene.
- Background Music (optional) - Music is half of the experience, right?
Creating a Navigation Mesh
To speed up the process of creating a navigation mesh, we can "cheat" by duplicating our existing terrain object. Name the duplicate object "navmesh" and delete any materials on it. Create a new material named "navmesh" and assign a bright or noticeable color in the material settings "Viewport Display" subtab - like cyan or neon pink. This color will not be seen by visitors in the scene - in fact, the navigation mesh will be completely invisible in Hubs. So the color ultimately won't matter. The purpose is just to make it easier for you to identify and edit the navmesh within Blender.

A key concept to understand about navigation meshes is that they only tell Hubs where you can walk. They are not a "collision mesh" like in typical game development, where every surface that you can "bump" into is included. You only want to include horizontal surfaces in the navmesh - nothing steep or vertical. We can quickly isolate the ones we already have, using a feature in Blender: Select Similar.
With the navmesh object selected in the 3D viewport, go into Edit Mode (with Tab) and select a single triangle on the ground, literally anywhere on the ground. Then in the menu, go into Select → Select Similar → Normal.

Blender should now be highlighting all other faces that are also facing up. Now hit Ctrl + I to invert the selection, and then Delete. The only faces remaining should be the actually walkable areas. We now have our "ground"! And just like that, you now have an almost-complete navigation mesh.
However, there are still parts of the ground that are not connected, such as the stairs and the bridges. If they're not connected in the navmesh, Hubs will not treat them as contiguous ground, and you won't be able to walk from one to the other. For the stairs in particular, what we have now is a series of floating, disconnected planes. We can replace these unusable stairs with a slope that can actually be climbed by visitors. We can do this by highlighting the edges at both ends and then pressing F to fill in the gap. See below:

You may want to add some Loop Cuts to the slope to make it more closely resemble the shape of the original stairs. Otherwise, avatars might clip through some of the stairs.

The other issue with our navmesh is the bridges. These are trickier to fix than the stairs, but it's doable! Use the Knife tool (K) to cut up the terrain navmesh around the bridge ends, creating a rectangular opening. Then connect the bridge with the terrain using your new cuts. Before doing all of this, you may want to hide (using the H key) the sides of the bridge to make it easier to make the necessary cuts. See two examples below of this whole process:


There are three remaining operations to finalize the navmesh:
1) Face smoothing and 2) Triangulate: Simply highlight the entire navmesh in Edit mode, and hit Ctrl + T. Then right-click in Face selection mode (the 3 key) and choose Shade Smooth. If, after triangulating, there's any weird shading on the navmesh, you can run Limited Dissolve on it (X key) to potentially fix it. Then re-triangulate after running Limited Dissolve.
3) Fixing normals - Make sure that Backface Culling is turned on like in the screenshot below, and inspect the entire navmesh to make sure there are no faces anywhere that look "invisible" from above. If you see any invisible face or any spots where the shading looks weird, you probably need to flip the normals there. Select the face in Edit mode, press Alt + N, and choose Flip.

One last and final step for the navmesh: We need to actually tell Hubs that it's supposed to be interpreted as a navmesh. We do that by adding a Hubs component in the Object properties. Hit the "Add Component" button and choose Navigation Mesh. See below:

Light Entities for Hubs
Up until now, we've been relying on a single Blender "Sun" light. Hubs does not actually use this for lighting - only Blender does. We still need to create lights specifically for Hubs, which will be casting onto our avatars (with no effect on the terrain itself - since it's lightmapped.)
We're actually going to create two entirely new objects, which are called Empties. Hit Shift + A → Empty → Arrows. The other Empty types work as well; it's just a matter of preference. Name the two Empties, "light directional" and "light ambient".

Empties don't do anything on their own. What makes them useful is that we can add Hubs components to them in Blender, which when exported to Hubs, create special entities with certain functionality. In this case, we're going to add light components to the two Empties we just created, turning them into Hubs lights. For each Empty, select it and go into the Object properties tab, then push the "Add Component" button, and select "Directional Light" (and then repeat this but for "Ambient Light" on the other one):


You won't see any effect in Blender after adding these light components. These are used by Hubs only.
Remember that the "light directional" object still needs to be rotated, just like the Sun light, to match the direction of our sun sprite in the sky. We want the light that gets cast onto avatars to be similar to the light that we had baked on our terrain.
For now, you can copy the rotation from our existing "Sun" object. Go to its Object properties, and in the Transform section, jot down the X, Y, and Z rotations. Paste them in the Object properties for our "light directional" Empty. Later on, when you actually get your scene running in Hubs, it'll be easier to tweak the rotation further to make it more accurate.
Let's keep our Hubs lights organized to make sure they're included in our final export. Organize your scene like below, noting the new "hubs" subcollection at the bottom:

Doesn't it feel good to stay organized? It'll pay off at export time!
Placing Your Spawn Points
Just like with the lights, create another new Empty object with Shift + A. Name the new object "spawn point" and move it to the "hubs" subcollection. Now go into the Object properties and add a new Hubs component, like so:

Position this Empty in the main area where you want your players to spawn. It can be just slightly above the ground, but not too high up. Duplicate the Empty around 4-8 times, then position the duplicates around the scene. It's good practice to have multiple spawn points to prevent avatars from spawning inside of each other.

Adding Looping Background Music
This step is optional, but for retro scenes, it can be the finishing touch that really stirs up the strongest feelings of nostalgia in visitors! Visitors will often hear the music as soon as the room loads, but before they actually enter the room as a named user, so it's a great way to immediately get people's attention and entice them to come into the room.
To save you time, I've grabbed one already for you (in the public domain) - Dream Candy by Drozerix - and converted it to MP3. Download below:
Create yet another Empty object, name it "bgm", and move it to the "hubs" subcollection. Then go into the Object properties on the right side of Blender, with the Empty still selected.
There is more than one Hubs component that can get you background music, but for this tutorial, we're going to be using the Audio component. This creates a player inside the 3D scene with a pause button, so if people want to stop the music (ie. so they can voice chat instead), they still can.
Position the Empty "bgm" somewhere accessible, preferably at eye level (use Walk Navigation mode, Shift + ~ to test that). Add a "Audio" Hubs component, as shown below, replacing the "Audio URL" with your own preferred MP3 URL:


Exporting to Hubs
The moment we've been waiting for. We're finally ready to export everything into Hubs!
Go into Blender's Outliner on the top right, right-click our "export" collection, and choose "Select Objects". This will highlight only the objects we want to export to Hubs. Now in the top left menu of Blender, go to File → Export as GLTF 2.0.
A file browser window will open up. Go into the "export" folder we created in the very beginning. Then modify the export settings on the right sidebar like below, and press the "Export glTF 2.0" button on the lower right of the window.

Go to Mozilla Hubs' Spoke page and create an account if you have not yet already. After logging in, press "Get Started," and then press the "New Project" button.
Then in the top right, press the button, "Import from Blender." Enter your scene's details on the next page. You'll need to include a thumbnail. You can quickly render one in Blender by temporarily disabling the navmesh and "hubs" subcollection.

Hit "Publish" and once it's done uploading your scene, you'll be taken back to your project list, with your new scene now added to the list. You can click on it and then click on "Open in Hubs" to test out your scene!
Congratulations, you now have your own sprite-based VR world up and running for the whole world to check out. 🎉

You can check out a live demo of the completed scene here. You can also download the completed Blender project itself, using the link below.
Common Issues
The first time you test your scene, you may run into some common issues. Some of these issues are sometimes caused by using Blender versions newer than the one(s) that the Hubs addon was designed for. Let's go over a few:
My skybox looks upside down in Hubs.
I ran into this issue myself while writing this tutorial! I was able to work around it by vertically flipping the skybox image in GIMP.
I can walk through my billboard sprites. How can I prevent that?
This is determined by your navmesh. If you create a hole in the navmesh where the billboard sprite is, visitors will not be able to walk through it.

The bottom of my platforms isn't fading correctly.

This is a consequence of using the Principled BSDF shader (for transparency) instead of the "Background" shader for our "fade" material. Unlike the Background shader, the Principled BSDF shader is still technically lit and affected by lights in the environment. You may notice a "shine" (see above) being added to the fade as you move around the scene. A possible workaround for this can be found here.
The lighting being cast on avatars doesn't look accurate. How can I fix it?
This will require some trial and error on your part, tweaking the two light Empties in Blender and re-exporting repeatedly. However, there is a hotkey in Hubs that helps you see what the lighting looks like on your avatar. Press the "i" key while in Hubs, and then press the little 💡 icon at the bottom.

Should you encounter any other issues, you can always ask for help in the official Mozilla Hubs Discord. ✌