Skip to main content
GameDev.net gamedev.net
🔒 Locked

Just had the weirdest WebGL performance bug

Started by RmbRT Feb 11 at 5:00 PM 17 replies 2.3k views
Original Post
RmbRT
RmbRT

I tinkered with some WebGL & WASM, and for the first time, used glUniform4fv(). In my WebGL bindings, if the GL context is a WebGL2 context, then there is the option to not just pass a new javascript Float32Array every time that contains only the range I want to pass (basically a fresh array view object that gets GC'd after use), but to pass such an array plus a start index and length. The intent of the extended signature in WebGL2 is to avoid constantly creating temporary slice view objects that then have to get GC'd. Well guess what: having a Float32Array view that spans the entire WASM application's memory, and passing that to glUniform4fv() with the extended WebGL2 signature easily takes a second to execute the GL call. My application only has a few megabytes of memory. I have zero clue how something could take that long. Maybe if it had quadratic complexity on the number of bytes or something, haha. Using glUniform4f() or the WebGL1 signature where I just pass a fresh 4-length array view into the function executes instantly. Using the new convention on a short array view also is fast. But somehow, using it on a large view is super slow, even though it is supposed to just look a small slice in the array and not touch all the other entries.

Also, Brave seems to occasionally drop 1–2 successive frames, but that doesn't show up in the recorded performance statistics. Or maybe it's my Ubuntu 25 that is dropping the frames, idk. Really infuriating when you just want to deliver 60FPS. But a 1s+ stall when simply calling a GL API function topped all my expectations. Somehow, when doing this during profiler recording, it takes even longer. I managed to have one frame render in 36 seconds, whereas without recording, it takes 4 seconds. And with the old signature of the function or when using glUniform4f(), it is basically instant. I could not identify the issue further.

Running in the browser is just ridiculous, but I brought this upon myself…

Walk with God.
JoeJ
JoeJ

I had a quick look into wasm, and i've learned you can compile C code with emscripten to wasm, which is supported by all major browsers. Which is quite cool, but how far does it go? Can it do C++ and stl? Multithreading?

How can you interact with the C code? Can you call it from JS, which probably needed to process input?
Can you call OpenGL form C, or do you need to call from JS?

Your idea to provide a web demo for a game is pretty awesome i think. Reminds me on the shareware days. So much better than watching game trailers on YT, which is always just cutscenes anyway, and barely enough to tell i'd probably not like the game.

But coincidentally yesterday i saw there is another way. There is a company which hosts playable web demos using video feeds. So it's like cloud gaming with latency but good enough to test a game. I forgot the name of the company, but i'd love to see more of this. It would be marketing done right for games.

When i got to PC in the 90s there were magazines with demos on CD-Rom, which was awesome too.
Though, at some time the games industry stopped releasing demos, because they noticed it is ‘bad for business’, i've heard.
Which is really strange, because back then the medium was growing and still doing very well.
Currently i notice a trend back to making demos, which is nice. But the big download sizes are a hurdle for me.

RmbRT
RmbRT

All exported functions become accessible to javascript, and you can inject importable functions from javascript. You can do everything, but it does not come with a runtime. I don't use emscriptem (which tries to replicate posix or something), I directly use plain Clang and set up the runtime myself. You have to write your own heap, which isn't that big of a deal. If you implement your own malloc, you can basically use the stl if you want to. Though WASM doesn't run on virtual, fragmented memory, it runs on one linear memory starting at 0 (so no error on 0 deref). Allocating more memory simply grows the linear memory it lives in, so you then have to manage the layout of that linear memory.

You can basically call OpenGL from C, although it goes through JS. But the JIT optimises often-called functions more aggressively. So if you instantiate a lambda function that is just for a single GL call, and on a fixed gl context object, and bind it to the WASM module, then after repeated calls, it should inline that whole function. So with enough usage, you should approach 0 overhead.

The main loop of the executable is called from JS, although that's negligible overhead really.

You do get multithreading by instantiating WebWorkers or what they're called, which are basically background processes. You can then use a shared memory buffer for the WASM linear memory, which lets you use atomics & shared memory communication (although the atomics are all sequentially consistent in WASM, so they aren't as performant as they could be).

And yeah, the idea is to have the game run in the browser, for easy internal testing on multiple machines, and without having to compile on every machine, etc.. Especially since I'm the only real programmer on the team, it makes it easier for my artist buddy to just run it. Also great for closed playtesting. Demos are a risk factor, not sure whether we'd do one, but that would also be an important way to distribute and run the demo. I can also add some code that tries to copy-protect the HTML file so that it needs to be hosted in a specific domain in order to run, and only up to a certain timestamp or something. Not 100% bulletproof, but good enough, probably.

Walk with God.
RmbRT
RmbRT

Just came back from lunch…

I think going forward, I want to have all my software be written with the framework I'm setting up, and then I'll have a runtime that runs natively everywhere and also in the browser using WASM. I think for now it's the best bet we have for native performance, but good sandboxing of untrusted software. I'm thinking not just games, but also general productivity software. All UI and graphics will be done using WebGL, although I'm not exposing it 1:1, but instead offering a wrapper that, if running natively, maps onto more efficient calls such as named buffers, etc..

Additionally, I'm thinking about restricting it to a subset of WebGL where if the system runs on Vulkan, it is easier to emulate or something, because emulating a fully compliant GL is a huge pain. A leaner GL with better guarantees that allow for less cumbersome emulation, or something. Which will still map onto a valid subset of GL.

Then I'll also some day offer a more modern graphics API that you can opt into, but which isn't guaranteed to be supported, which lets you basically use all the modern stuff.

Currently, I'm building up a small library for writing GUI-intensive programs. The test program for that is going to be an image viewer and statistical analyser for my image compression. It works based on which colours occur how often, setting up a frequency-sorted palette, and then looking at what percentile of the palette's occurrences fit into how many bits. I'll then do variable-depth palette index encoding. Another technique I want to try out is to first have a mipmap and then for each mipped tile, only encode the offsets from the average colour, again reducing the bit depth / palette size required (except for pathological cases). This also lets me split the image into two parts, one low-resolution mipmap, and then the rest that fills in the details as a separate payload. That lets me bake at least low quality versions into the initial payload, in cases where that makes sense. After all, in a web app, you want to have a fast initial render, so the essential payload should be small, but as complete as possible.

I hope I can achieve mad compression skills like the demoscene guys. Especially Farbrausch, they're nuts.

Walk with God.
JoeJ
JoeJ

RmbRT said:
my image compression

In case you missed it, there is a range of existing open source image compression methods, from jpeg up to Khronos ktx (ratio close to jpg, but can be converted to GPU compressed formats on client, surely overcoming the human perception metric used for jpeg which isn't good for textures e.g. normal maps)

I've used ktx only to convert given dds image files for use with Vulkan, but when reading about it i thought it would even allow to make a game like Rage, without a need to dig into image compression myself. (id software came up with their own format similar to jpeg back then)

Though, i'd guess integrating such library maybe isn't less work than doing something simple myself, like e.g. the quadtree compressed lightmaps i had used. But results would be better.

A good general compression library is zstd. High performance and good ratio.
So what i'd do first is using GPU compressed image formats, compressed a second time with zstd. Which might still reduce it more.
Afaik that's also more or less what most games do. (Just using Oodle instead zstd, but that's not so much better.)

RmbRT
RmbRT

JoeJ said:
In case you missed it, there is a range of existing open source image compression methods

no I did not miss it. I just like rolling my own tech. I also want to make a midi-like audio engine, and then have the game influence the music on a more fundamental level than just having a transition between pre-mastered pieces. I'll have to add a library that converts to GPU formats, though. But for now, I'm more concerned about the network payload than about the VRAM footprint. For my current usecase, it is fine to leave uncompressed textures in memory.

It's fun to dabble in compression. It's not that different from what you deal with when inventing instruction encoding schemes, although the requirements for random access decoding are different.

Basically my aim is to have a progressively loadable (aka you load half the file, you get a lower quality full image, rather than just half the image at full detail) compressed image format that is lossless and takes very little codesize for the decoder. I can then upload full-res versions to the GPU and swap them out with GPU-compressed versions once the compression worker thread finishes that.

Then I do not have to bundle multiple compression formats (desktop vs. phone compression formats) of the game's assets into the game. I'll just load a deferred WASM module that can do GPU compression of all the formats, which can be independently cached in the browser cache based on hash. So at first the game simply uses uncompressed data and then once the GPU compression module loads, it can start compressing the images for better bandwidth. And I can't ship gigabytes of textures anyway.

Walk with God.
RmbRT
RmbRT

https://github.com/matusnovak/texture-compression This one seems to completely offload compression to the driver by rendering the uncompressed texture onto a framebuffer and then reading out compressed pixels. Not sure what the API minspec is for that though. He seems to rely on GL 3.3 for that.

Edit: Ah, seems like it's a desktop-GL only function. So on mobile or in the browser, I'd have to manually do the compression on the CPU :(

I'll probably be using the STB compression libraries, they seem very lean.

Walk with God.
JoeJ
JoeJ

RmbRT said:
This one seems to completely offload compression to the driver by rendering the uncompressed texture onto a framebuffer and then reading out compressed pixels.

Very interesting. I did not know framebuffer compression works the same way as texture compression. And i wonder how this can work at all, since you can only compress a block after all its pixels are known afaik.

Now i assume framebuffer is only compressed after the rendering is completed? If so, what's the benefit of this at all?
If anybody knows, please enlighten me…

RmbRT
RmbRT

JoeJ said:
Now i assume framebuffer is only compressed after the rendering is completed? If so, what's the benefit of this at all? If anybody knows, please enlighten me…

This is when you want to generate a compressed texture at runtime. For example you render a cube map dynamically, and then want to reuse it frequently, but with compression. Anyway, that's what I assume this is intended for.

Walk with God.
JoeJ
JoeJ

Yeah, i concluded it has nothing to do with framebuffer HW compression. The extensions are probably for convenience and implemented with a compute shader under the hood.

RmbRT
RmbRT

Desktop GL already had glGetCompressedTexImage() since GL 1.3 core. But nowadays, it is probably some kind of compute shader, yeah.

Walk with God.
RmbRT
RmbRT

Since this thread turned into kind of a blog and the forum is pretty inactive the past few days anyway, and we already mentioned image compression: I finally surpassed 9x PNG compression on a nontrivial image. Granted, I'm still a 3x away from PNG in the worst case (images with a wide range of used colours, and with bad colour distributions that don't benefit much from variable-depth (hot/cold) palette encoding). But so far, I have only looked at per-pixel compression and statistical compression, not at all at neighbourhood compression yet.

Wrote a tool to test my immediate mode UI skills in GL:

Currently, it shows the “Palette bits” mode, where it shows how many bits of the palette (that is, the first 2^b entries in it) cover how much % of the image (going from b=0 to b=8, so 9 bars). The most recent advancement is basically by choosing a variable-length encoding that has one bit indicating whether to use the “hot” part of the palette or the whole range, with different bit widths. The bit width that determines how large the “hot” part is is determined by the statistics shown on the graph, basically choosing the best fit. Currently, it also compresses channels individually, although it eliminates redundant/identical channels.

The screenshot is made from the browser btw. I can drag & drop .bmp files into it, or hit CTRL+O to open them. With CTRL+S, it saves out a compressed file. The .BMP is 16,454 bytes, the 9x .PNG is 4,787 bytes, and mine is 3,806 bytes. There is no zlib or anything involved yet. The entire decompression code is 70 lines of C, currently. The compression is a few hundred lines though, but that does not have to bundle with the game.

The “Colour histogram” mode shows me the frequency of each brightness value, so I get to see the colour distribution & profile, and the “Palette histogram” shows me how often each entry in the palette occurs (the palette is frequency-sorted, so the most frequent colours come first).

Edit: Oh, and the compression step is also basically instant. Way faster than how long it takes GIMP to compress this as a 9x PNG. ;)

Walk with God.
RmbRT
RmbRT

Wow… I fixed the bug. So dumb. In my Wasm class in javascript, I had two getter properties:

class Wasm {
	get memory_f32() { return new Float32Array(this.#memory.buffer); }
	get memory_f32() { return new Int32Array(this.#memory.buffer); }
}

First of all, it did not complain about duplicate property names. And then, glUniform4fv(), which according to specs expects a Float32Array or an untyped Array of numbers, did not complain about receiving an Int32Array, either. Instead, it seems to have created a copy of the array (which was a view on the entire WASM memory), converted everything to integers (probably also with saturation checks or some dumb shit), and then took the actual entries I needed. So every call to glUniform4fv() took a whole copy of the entire WASM memory, lol.

IMO, it should simply have thrown an error for receiving a typed array of the wrong type (like it does in most other cases), or at least warned about receiving the wrong type of array. And why can classes have duplicate / overwriting functions anyway? :(

I replaced those getters with constant fields so that the instances also stay cached. Apparently, resizes of the underlying buffer will automatically adjust the views that view into the buffer to track the new length. Edit: Ok, apparently there is such a thing as a length-tracking array, but that is not what the underlying WASM storage does. It seems to not be a resizeable buffer but simply allocates a new buffer or something. So I still have to renew the views into the array after each resize of the WASM memory…

P.S.: I still need to reduce the burden on the GC in my other project, though. I got an up to 8ms GC freeze every few seconds, and while the GC stops just before the frame boundary, the code that calls the GC takes just a little longer and then causes the frame to drop regardless.

Walk with God.
marshalwerner
marshalwerner

@RmbRT
It sounds like you’ve hit one of the subtle performance pitfalls in WebGL2 related to large Float32Array views. Even though the extended glUniform4fv() signature with start index and length is meant to avoid unnecessary allocations, it appears that some browser/WebGL implementations do not fully optimize for huge underlying buffers. Passing a view that spans your entire WASM memory may force the GL driver to validate or copy the full backing store internally, even if the range you intend to use is small. This could explain why the call takes an unexpectedly long time.

In contrast, using glUniform4f() or a small array slice avoids this because the driver only sees the exact values it needs, with no potential for massive memory validation or alignment checks. Profiling amplifies the issue because the browser and GPU synchronization overhead increases dramatically when recording, which is why you observed one frame taking 36 seconds instead of 4.

A practical workaround is to create a small, dedicated Float32Array of just the values you need to upload rather than passing a view spanning the entire WASM memory. This keeps performance predictable and avoids driver-level edge cases. Also, testing across different browsers can reveal if this behavior is specific to a particular engine’s WebGL2 implementation.

RmbRT
RmbRT

@marshalwerner Are you an AI? You are a new account, weirdly miss the point and pick up multiple facts completely wrong, and replied to two threads in basically the span of time it takes to type out those replies, so no actual time is left for reading the threads between those replies.

Walk with God.
marshalwerner
marshalwerner

@RmbRT No sir, I’m not an AI. i am just another developer trying to help.

If my reply missed part of your point, that wasn’t intentional. I focused mainly on the large WASM-backed Float32Array because that detail stood out as a potential trigger for unexpected driver or browser behavior. I agree that in theory the extended WebGL2 signature shouldn’t scale with the full buffer size, which is exactly why your observation is interesting.

I may have replied quickly, but I did read the thread-I simply concentrated on the aspect that seemed most likely to cause the stall. If I misunderstood something in your setup, feel free to clarify. I’m genuinely curious what ends up being the root cause.

RmbRT
RmbRT

OK. Whatever. Anyway: the performance problem was because I accidentally passed an Int32Array into the glUniform4fv function which expects a Float32Array, so it converted the whole array to Float32 in a full copy, and then accessed it. And somehow, this was also unreasonably slow even when you factor in the fact that it did int to float conversion on a few million integers and that it did a copy. The 4 seconds were when I did not have the profiler running, while the 36 seconds were with the profiler. Fixing the type to Float32Array also made it go as fast as expected again.

Walk with God.
marshalwerner
marshalwerner

@RmbRT Okay sir, now that makes more sense.

Passing an Int32Array into glUniform4fv would absolutely explain the full-buffer conversion and copy. I wouldn’t have expected the overhead to balloon that dramatically either, but given that it had to convert and duplicate a large backing store, plus the additional instrumentation the profiler introduces, the behavior becomes much more understandable.

I appreciate you following up with the actual resolution. Subtle type mismatches like that are easy to overlook, especially when everything compiles and runs without obvious errors. It’s a good reminder that with WebGL and WASM bindings, even small discrepancies in typed array usage can have disproportionately large performance implications.

the key issue is that glUniform4fv strictly expects a Float32Array. When an Int32Array is passed, the WebGL binding layer must internally allocate a new Float32Array, convert every element, and copy the entire buffer before submitting it to the driver. If that view spans a large WASM memory block, the cost scales with the full backing store size, not just the intended slice.

A safe pattern to avoid this class of issue is -

/* Ensure the correct type at the source */
const uniformData = new Float32Array(wasmMemory.buffer, offset, length);

/* Or explicitly convert once, not implicitly per call */
const uniformData = new Float32Array(intArray);
gl.uniform4fv(location, uniformData);

To be honest it's an interesting case to read through.

Topic Locked

This topic has been locked by a moderator. New replies are not allowed.

Sign in to reply to this topic.