Mapgen4’s use of WebGL2

 from Red Blob Games’s Blog
Blog post: 13 Oct 2025

I try to avoid big software rewrites. But sometimes the rewrites are just an excuse to re-familiarize myself with the code. I rationalized rewriting Mapgen4’s renderer by saying I wanted to use WebGL2. And I did use WebGL2, but the improvements turned out to be minor:

  1. Vertex Array Objects to simplify and speed up code — but OES_vertex_array_object[1] makes this available in WebGL1 (good support on all platforms[2])
  2. gl_VertexID to simplify and speed up code — only available in WebGL2
  3. R16F texture format for elevation and depth — needing EXT_color_buffer_half_float[3] in WebGL1 (poor support on Android[4]) or EXT_color_buffer_float[5] in WebGL2 (good support on all platforms[6])
  4. Linear filtering of elevation and depth — needing the R16F format first
  5. SRGB8 texture format for linear rgb color handling — supported in WebGL2, but could be emulated in WebGL1

I did not need to switch to WebGL2 for features, but implementing those features would make the code more complex and error prone. I decided to switch to WebGL2 so that I could simplify my code.

The Vertex Array Objects and gl_VertexID didn’t change the rendering output, but the texture format did, both good and bad, and I wanted to share some screenshots.

Let’s start with a screenshot comparison I showed last time:

GL_NEAREST vs GL_LINEAR filtering

Why do I get the blue blotches when I use GL_LINEAR filtering?

Elevation is slightly too big for an 8 bit color channel, so I encode the elevation in the red and green channels (8 bits each), [fract(elevation)floor(elevation)/256.0]. For example an elevation of 32.5 would be encoded as [32.5%1.032.0/256.0], or [0.50.125]. In the fragment shader I decode it with with green*256.0 + red, in this case 0.125*256.0 + 0.5 = 32.5.

GL_LINEAR texture filtering will blend each channel independently, then combine the channels into one value:

(a.hi + b.hi) / 2.0 + ((a.lo + b.lo) / 2.0) / 256.0

But that produces a different value than correctly combining the channels first and then blending:

((a.hi + a.lo / 256.0) + (b.hi + b.lo / 256.0)) / 2.0

Near sea level, some of the below-sea-level elevation must be getting blended incorrectly into the above-sea-level ground.

If I could store a single 16-bit value instead of two 8-bit values, blending would work correctly. And I wouldn’t have to split the elevation into two channels, and I wouldn’t have to combine them together again. It’d be simpler code and it would look better.

Or so I thought. Here’s the comparison:

RGBA8 vs R16F texture, with GL_NEAREST filtering

It looks the same, right? Well, no, it’s slightly different, especially in the mountains:

RGBA8 vs R16F texture, zoomed in on mountains

The R16F format makes the mountains smoother! It also makes the valleys smoother but that difference is harder to see.

The outputs should have been the same. Why aren’t they?

It turns out mapgen4 had a bug in it. I didn’t actually encode the elevation the way I described above. I encoded with vec4(fract(256.0*e), e, 0, 1) and decoded with dot(color.xy, vec2(1.0/256.0, 1.0)). That doesn’t preserve the value. I should have used vec4(fract(256.0*e), floor(256.0*e)/256.0, 0, 1). I used ObservableHQ Plot[7] to plot the difference in these two calculations. The black dots are the incorrect encoding, and we can see that half of them are a little bit off:

Correct vs incorrect encoding

This bug adds a “texture” to the sides of mountains. I like the old look better. But don’t want to keep the bug. I would prefer adding a texture intentionally.

After I got R16F working, I decided to compare GL_NEAREST to GL_LINEAR. I couldn’t use GL_LINEAR because it interpolated each channel separately, but I can use it now that I have only one channel. I think the coastlines look better, but the difference is small:

GL_NEAREST vs GL_LINEAR, smoother coastlines

To compensate for the mountains looking smooth, I added a slider mountain_folds that adds geometry to make the sides of mountains look more interesting:

mountain_folds slider to add geometric interestingness

I enjoyed the graphics rewrite. I hadn’t looked at the graphics code in a long time, and I found lots of places for improvement. I found and understood a bug that I had long suspected. All of this could have been done without a rewrite, but the rewrite got me to actually do it.

Email me , or comment here: