Navigation

Phaser 4 Dev Report 8

Published on 29th June 2020

Welcome to a freshly squeezed Dev Report! Let's dive right in. As always, you can track development in the @phaserjs/phaser repo, where you'll find the latest versions.

You shall Render Pass

In the previous Dev Report, I talked about how I had introduced various systems, such as the Shader System, into the WebGL Renderer. This was to prevent the renderer from becoming a God Class, and also just to keep things cleaner, as each system was responsible for just its own part of the process. As I carried on working through this, it became clear that all I was really doing was creating Demi-God Classes instead. Sure, the renderer was no longer being flooded with methods, yet dumping them into other classes didn't really solve the problem, it just moved it elsewhere. Each system was becoming a little too large for my liking and went against the modular nature of v4.

Before I got any deeper into the woods I decided to clean house.

Fundamentally, WebGL is a state machine. Once you modify an attribute, such as binding a texture or shader, those changes remain permanent until you modify that attribute again.

Because of the state machine nature of WebGL, and the cost involved in modifying that state, what was needed was a way to manage that state during the rendering process. To this end, I built the Render Pass object and its supporting functions.

During the game step, after collating a list of renderable objects, the renderer will begin the Render Pass. Let me show you a list of functions the Render Pass currently has, and you should get a clearer idea of how things work:

Let's take the `AddFramebuffer` function as an example:

Here we simply add the given framebuffer to the stack and return from it. The stacks are important. There is one stack per 'system', i.e. one for shaders, another for FBOs, and so on. The reason for this relates back to the state machine nature of WebGL. For example, if a Render Layer is on the display list, then it will need to set its own Framebuffer to be rendered to.

While we could easily just swap the framebuffer to the one belonging to the Render Layer, then reset it back to the default at the end, this doesn't allow for any kind of nesting or more complex Game Objects. If you nested another Render Layer inside of it, then technically once the sub-Render Layer was completed, it should set the framebuffer back to the parent layer, not the renderer. This gets even more complex when you've Game Objects that need multiple attributes set, such as a fbo, buffer, and texture. To manage this, a stack is required.

So, when a Render Layer has its `renderGL` method called, it invokes this Render Pass function:

And in its `postRenderGL` function, it calls these Render Pass functions:

By setting and popping attributes off the stacks, we retain order and it doesn't matter how deep our nesting goes. Another nice side-effect is that because we're retaining attribute history on the stack, it's easy for us to avoid changing the WebGL state unnecessarily.

Although changing state in WebGL is required, as that's how the API works, no function call is ever free and it can often be computationally expensive depending on which change is taking place. Some WebGL calls are more expensive than others. This is, of course, dependant on hardware and implementation, yet the following list still holds true as a guide to the cost of change, from highest to lowest:

  • Framebuffer
  • Blend state
  • Shader program
  • Depth state
  • Other render states
  • Texture
  • Vertex buffer
  • Index buffer
  • Shader parameters / uniforms

As you can see, changing the active frame buffer or blend state are things you really want to avoid doing too frequently. The Render Pass was constructed with this in mind as well. By using a stack it's easy to test to see if an attribute is already active, or not, to avoid resetting something that may already be in place, such as a blend mode. Render Pass gives us our persistence as we iterate the display list and it equally does its best to cut down on the amount of WebGL commands being issued.

This gives you unparalleled power, from a Phaser respective, when crafting your own advanced Game Objects. Should you need to do something a bit different, you can manipulate the Render Pass just for that object and return it to its previous state easily once finished, cutting down on duplicate operations as it goes.

Enter, World3D

So, what's the natural evolution of a Render Pass flexible enough to handle any data thrown at it? Why this of course ...

Hello 3D my old friend, it's been too long :)

Watch the video on YouTube: https://youtu.be/y2b%5Fpbf9JAk

Let's be clear, here. This isn't just Phaser passing itself off to Three.js or something. It's a proper 'native' 3D system, available directly as part of the v4 API. The video above is 1,048,560 tris (static geometry, of course) with a Phong reflection shader.

After all, as far as WebGL is concerned all it needs is a shader and a bunch of data to work from. It doesn't care what that data represents. Be it an orthographic matrix and a bunch of quads creating a 2D scene, or a perspective projection matrix and a load of vertices forming a 3D shape. It's all just data to WebGL at the end of the day, and it's all just slightly different calls being made to the Render Pass.

I was part-way through implementing the Render Pass when it occurred to me that I ought to test just how flexible it was and that's when this brand new 3D-shaped rabbit hole appeared. First, I had to finish off all of the math functions. This was tedious but essential work. I took most of what had been used previously, added some new parts, and converted every single last part of it to TypeScript, which took me days and days. It was quite mind-numbing while being essential, too. You can't have 3D without math, after all.

I've carried on tweaking the math functions as the 3D system evolved and I'm happy with where they are now. You can now bind listeners to Quaternions or Mat4s, for example, and be notified when their elements are updated. You can create a special type of Vec2/3/4 that also triggers callbacks should you change their values, all of which are used to help let the various systems know when something has become dirty without doing large array comparisons every frame.

After lots of smaller tests, I started forming some base generic shaders that could handle shading and lighting. I then implemented a 3D Camera system, ported a bunch of primitive generation functions over, and constructed a World3D class as well. Creating primitives is lovely and easy:

In the example above we've one Game Object 3D for each of the shapes and each shape has its own underlying geometry. However, geometry is quite expensive in terms of memory. For example, creating a Sphere with 12 width and height segments will generate 792 vertices and all corresponding normal and UV data. This is all stored in a vertex buffer for that one object. If you wanted to create a bunch of spheres, it would have to generate a 253,344 element sized buffer for each one of them, which is wasteful when the underlying geometry is literally identical for them all.

To that end, you can generate a Geometry object, which multiple Meshes can share:

Each mesh is its own instance of a Sphere, with its own position, scale, rotation, and texture, yet internally they all share the same geometry buffer, preventing us from wasting memory.

In terms of bundle size, the above 3D Sphere example, fully running with a World3D and the new Render Pass, including an Orbital Camera is just 15 KB min/gz. Of course, that doesn't include the 3 textures being used, but it does include the Loader for them! If you wanted to just use plain colored materials instead, you could do away with the Loader and textures entirely, coming in at under 14KB. See, even with 3D, I still kept a steely gaze on file size :)

There are currently 6 primitives available:

I have got a couple more to add, including a capsule and disc, and they will then be complete. This process of sharing underlying geometry data extends beyond primitives to external models, as well. At the time of writing this Dev Report I haven't yet implemented the GLTF / GLB loader, but I do have JSON object parsing in and I'll be happy to demonstrate the loading of all model formats in the next Dev Report (although, I'll probably demo it on Discord first, so make sure you're on there!)

My plan is to support loaders for the OBJ, GLTF, and GLB formats, and that's all. I think it's fair that it's up to the developer to convert any other model format into one of those for use in-game.

In the examples above all the meshes so far have been dynamic. This means you're able to change their position within the world directly and dynamically, i.e. as a response to user-input or physics. However, lots of geometry in 3D games is static. For example, if you think about a player walking around a city, it's common for most of the buildings to be entirely static and never need to actually move. To this end, I've allowed developers to be able to create static layers, where it builds up a buffer from the object data in the layer, that it can use each render. Static layers help you cut down on the number of buffer swaps, draw calls, and uniform sets.

It's really interesting seeing the difference between how you approach 2D and 3D rendering in WebGL. In 2D all of the quad data is assumed to be dynamic, with vertice position data generated via JavaScript and added to a self-refilling buffer that batches as much as it can before sending it to be drawn. Limiting the draw calls and batch flushes is key to 2D performance.

For 3D, however, it's almost the exact opposite. The approach is focused around small buffers and frequent draws, with each object often having unique shaders / materials and uniforms. The reason for this is that unless the geometry has a really tiny number of faces, you nearly always want to let the shaders handling the positioning of the vertices. In 2D we calculate the vertex data in JavaScript and add it to a dynamic global buffer. In 3D the buffer data is local to the object and doesn't change. Instead, we send a projection, camera, and model view matrix to the shader and let it calculate the vertex positions from those. It's very common for a scene with say 40 meshes in it, to issue 40 draw calls, one per mesh.

There are, of course, optimizations that can be made, such as hardware instancing and splitting geometry into static and dynamic groups, but on the whole, that is how 3D is handled in WebGL. 2D is all 'batch, batch, batch', and 3D is much more 'draw, draw, draw'. I'll be honest, it was quite an eye-opener to realize this. But I assure you it's common: run a GL Spector profile on any of the leading 3D frameworks (or 3D browser games, for that matter) and you'll be able to step through often hundreds of WebGL calls.

Worlds within Worlds

It's worth mentioning that the Phaser 4 renderer worlds by just iterating through a list of Worlds that belong to the Scene. It doesn't care if those are 2D or 3D Worlds if there is one, or one-hundred of them. It just processes them in order and displays the results, and each World is responsible for generating and optimizing its own render list.

This means there is nothing stopping you from having a 3D World with a 2D World over the top of it that contains all of your game UI. Or a 3D World sandwiched between two 2D Worlds. The combination is up to you, but I felt it was important to make it clear that just because you may want to create a 3D game, it doesn't mean you'll have to do everything in 3D.

You can easily throw 2D into the mix as well. Heck, you could even grab the results of a 3D World and use it as a Sprite texture! Or the opposite: project a 2D World onto a 3D Mesh. Each World renders to its own frame buffer by default, so how you choose to utilize those is entirely up to you.

Thoughts on a 3D Engine

I think it is worth taking a moment to lay down my thoughts on the 3D side of Phaser 4 and WebGL frameworks in general.

3D is a massive field. Developers dedicate their entire professional lives to its study and implementation. It's one of those rabbit holes you can venture down and literally never return from. It can also get really complex, really fast. Throw a game engine into the mix as well and it's just on a whole other level. So it's important for me to set some realistic goals and boundaries to my work.

If you look at the feature-set of a 3D library such as Babylon.js or Three.js you'll see quite literally hundreds and hundreds of options. Even something as seemingly simple as lights has many variations. From directional and point lighting to hemispheric ambient environments and light projection maps. And what do lights cast? Shadows. So you're then into the realm of shadow filters, soft shadows, self-shadowing, contact hardening, and much more. As I said, it's one hell of a rabbit hole.

My theory, however, is this: For an overwhelming number of browser-based 3D games, and don't ever forget that the browser is our target environment, you don't actually need the vast majority of these features. Yes, emissive objects do look very pretty when they glow, for example, but it's by no means essential. So my approach very much has to be one that will benefit your games, rather than making a single spinning object look cool. After all, Babylon and Three already do an amazing job here, so if that's your requirement, I'd simply urge you to use them instead.

My focus won't be on adding all the fancy effects I can find, it will be on building a flexible enough set of defaults that you can create fun 3D games nice and easily. Once the object loaders are complete I'll look at implementing filtering, which is the process of reducing wasteful work for the renderer, i.e. removing geometry that is too far away, or outside of the camera frustum to be seen, or that is occluded by other meshes. Much like in 2D, where I already cull objects that don't intersect with the camera, I can do the same for static and dynamic geometry, using an AABB tree and dense grid to begin purging the render list. Beyond this we can then look at grouping similar geometry, sorting into those that require blend modes set, grouping based on transparency and effects, all in order to reduce waste and increase resource sharing. These are features I consider essential for games.

If I had to liken this to something, I would say that I'm aiming for you to be able to create 3D games at the same sort of visual and performance levels as the PlayStation 2 / N64 era.

This may sound like a strange metric to apply to something, but I feel it's a valid one. Like me, you may have recently watched the PlayStation 5 + Unreal Engine 5 tech demo with its staggering looking Lumens lighting system and 'film-quality geometry'.

It's stunning to behold and as a gamer, I can't wait to experience games looking like this on my PS5. But let's be realistic here: This is technology so far outside the realm of web browsers it's not even funny. Yet, much more importantly, it's well outside the realm of the sorts of games our customers, the web-gaming public, like to actually play in their browsers.

It's far more important for them (and by extension, our ad revenue!) that the game they selected loads quickly and plays well. This should be our focus and we can only do that by embracing the platform we chose to build on in the first place, rather than trying to shoe-horn it into being something else. To that end, when we think of 3D we ought to be thinking PS2, not PS5. PS2 games were fast and fun, they had rudimentary lighting and basic shadows and mesh animations, often using fog to keep draw distance down, but that's absolutely fine, too: The more complex the models, the longer the download and parsing times. The bigger the buffers and the more complex the shaders, the harder WebGL has to work. The harder it works, the faster it drains battery or, even worse, crashes.

For me, 3D in Phaser 4 will be all about letting you make 3D games suitable for playing in the browser, without hefty downloads or start-up times. I will limit the initial feature set and focus development specifically on the systems needed to make this happen. After all, this is what our players want too. Our players are many things, but patient certainly isn't one of them :) Equally, they aren't expecting a AAA experience either, which is good, because even if the browser could handle it, I'd wager none of us here have the funds capable to bankroll one anyway!

Next Time

Thanks for reading another Dev Report :) As you can see, I've been incredibly busy as usual. I'm going to take a few days over the coming weeks to get Phaser 3.24 released, but aside from this, it's full steam ahead on V4. Next time I hope to be able to talk about our FX engine, Beam, and all of the work that Simo has been putting into creating it. It's absolutely gorgeous and I think you're going to love it!

Until then, please catch me on Discord or the forum.