DEV Community

Cover image for DOOM...*rendered* using a single DIV and CSS! ๐Ÿคฏ๐Ÿ”ซ๐Ÿ’ฅ
GrahamTheDev
GrahamTheDev

Posted on • Edited on

DOOM...*rendered* using a single DIV and CSS! ๐Ÿคฏ๐Ÿ”ซ๐Ÿ’ฅ

For clarity, I have not rebuilt DOOM in CSS...yet.

No this is far simpler:

  • rendering the output of DOOM
  • into a single div
  • using a single background-image: linear-gradient block.
  • all client side in the browser!

Is it silly? Yes

Why did I do it? I take it you have never read my articles before, I do silly things with web technologies to learn things...

Why should you read this nonsense? - well you get to play DOOM rendered with CSS for a start!

But seriously, the code can show you some interesting things about interacting with WASM and scaling an image from a <canvas>.

I also deep dive into averaging pixel values from a 1D array, so if that interests you, that might be useful too!

Oh and a huge shoutout to Cornelius Diekmann, who did the hard work of porting DOOM to WASM

Anyway, enough preamble, you are here to play "Doom in CSS*"

Doom rendered in CSS

On mobile, controls are below the game, for PC controls are explained in the pen.

You have to click on the game before input is recognised

(Also if you are on PC clicking the buttons below the game will not work, they are mobile only).

Note: codepen doesn't seem to like this on some devices, you can play it on my server instead if that happens: grahamthe.dev/demos/doom/

So what is going on here?

It just looked like low quality Doom right?

BUT - if you dared to inspect the output you probably crashed chrome...

You see, we are doing the following:

  • Getting the output from doom.wasm and putting it on a <canvas> element.
  • We are hiding the canvas element and then using JS to gather pixel data
  • We take that pixel data, find the average of every 4 pixels to halve the resolution.
  • We then convert those new pixels to a CSS linear gradient.
  • We apply that linear gradient to the game div using background-image: linear-gradient

A single linear gradient generated is over 1MB of CSS (actually 2MB), so sadly I can't show you here what it looks like (or on CodePen!) as it is too large!

And we are creating that 60+ times a second...web browsers and CSS parsing is pretty impressive to be able to handle that!

Now I am not going to cover everything, but one thing that was interesting was turning the <canvas> data into an array and then getting pixel data for rescaling, so let's cover that:

Simple way to get average pixel colour

I had an issue.

Rendering the game in CSS at 640*400 made Web browsers cry!

So I needed to downscale the image to 320*200.

There are loads of ways to do this, but I chose a simple pixel averaging method.

There are some interesting things in the code, but I think the resizing function is one of the most interesting and may be useful for you at some point.

It's especially interesting if you have never dealt with an array of pixel data before (as it is a 1D representation of a 2D image, so traversing it is interesting!).

Here is the code for grabbing the average across pixel data for reference:

function rgbaToHex(r, g, b) {
  return (
    "#" +
    [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join("")
  )
}

function averageBlockColour(data, startX, startY, width, blockSize) {
  let r = 0, g = 0, b = 0;

  for (let y = startY; y < startY + blockSize; y++) {
    for (let x = startX; x < startX + blockSize; x++) {
      const i = (y * width + x) * 4;
      r += data[i];
      g += data[i + 1];
      b += data[i + 2];
    }
  }

  const size = blockSize * blockSize;
  return rgbaToHex(r / size, g / size, b / size);
}
Enter fullscreen mode Exit fullscreen mode

This averageBlockColour function is useful if you ever want to do simple image resizing (for a thumbnail for example).

It is limited to clean multiples (2 pixel, 3 pixel block size etc.), but gives a good idea of how to get average colours of a set of pixels.

The interesting part is const i = (y * width + x) * 4

This is because we are using a Uint8ClampedArray where each pixel is represented by 4 bytes, 1 for red, 1 for green, 1 for blue and 1 for the alpha channel.

We use this as we need to move around an array that is in 1 dimension and grab pixel data in 2 dimensions.

Pixel data explanation

We need to be able to move around in blocks to average the colours.

These blocks are X pixels wide and X pixels tall.

This means jumping past the rest of an images row data to get the second (or third, or fourth...) rows data as everything is stored in one long line.

Let me try and explain with a quick "diagram":

Image (3x2 pixels):

Row 0:  RGBA0: (0,0) RGBA1: (1,0) RGBA2: (2,0)
Row 1:  RGBA3: (0,1) RGBA4: (1,1) RGBA5: (2,1)

Array data:   [ RGBA0 | RGBA1 | RGBA2 | RGBA3 | RGBA4 | RGBA5 ]
Image pos:      (0,0)   (1,0)   (2,0)   (0,1)   (1,1)   (2,1)
Array pos:       0-3     4-7     8-11   12-15   16-19   20-23
Enter fullscreen mode Exit fullscreen mode

So now you can see how each row of our image is stacked one after the other, you can see why we need to jump ahead.

So our function takes:

  • data: our array of pixel data,
  • startX: the left most position of the pixels we want data for (in 2 dimensions)
  • startY: the top most position of the pixels we want data for (in 2 dimensions)
  • width: the total width of our image data (so we can skip rows)
  • blockSize: the height and width of the number of pixels we want to average.

If we wanted to get the average of the first 2 by 2 block of pixels here we would pass:

  • data: [ RGBA0 | RGBA1 | RGBA2 | RGBA3 | RGBA4 | RGBA5 ]
  • startX: 0
  • startY: 0
  • width: 3
  • blockSize: 2

Within our loops we get:

//const i = (y * w + x) * 4;

  const i = (0 * 3 + 0) * 4 = start at array position 0: RGBA0
  const i = (1 * 3 + 0) * 4 = start at array position 12: RGBA3
  const i = (0 * 3 + 1) * 4 = start at array position 4: RGBA1
  const i = (1 * 3 + 1) * 4 = start at array position 16: RGBA4
Enter fullscreen mode Exit fullscreen mode

Which is pixel data for:
(0,0, 1,0)
(0,1, 1,1)

Then if we want to get the average of the next 4 pixels we just pass:

  • data: [ RGBA0 | RGBA1 | RGBA2 | RGBA3 | RGBA4 | RGBA5 ]
  • startX: 1 <-- increment the start by 1
  • startY: 0
  • width: 3
  • blockSize: 2

Within our loops we now get:

//const i = (y * w + x) * 4;

  const i = (0 * 3 + 1) * 4 = start at array position 4: RGBA1
  const i = (1 * 3 + 1) * 4 = start at array position 16: RGBA4
  const i = (0 * 3 + 2) * 4 = start at array position 8: RGBA2
  const i = (1 * 3 + 2) * 4 = start at array position 20: RGBA5
Enter fullscreen mode Exit fullscreen mode

Which is pixel data for:
(1,0, 2,0)
(1,1, 2,1)

Now we have the raw pixel data

The rest of the process is easier to understand

We have some RGBA data for a pixel - which might look like [200,57,83,255].

We just add up the values of each part:

  r += data[i]; //red
  g += data[i + 1]; //green
  b += data[i + 2]; //blue
  //we deliberately don't grab the "a" (alpha) channel as it will always be 255 - the same as opacity: 1 or non-transparent.
Enter fullscreen mode Exit fullscreen mode

Once we have done this for our 4 pixels (our 2 loops for y and x) we will end up with a total R, G and B value for those 4 pixels (y is 0 and 1, x is 0 and 1 in the first instance and y is 0 and 1 and now x is 1 and 2 in the second instance).

We then just take the average of them:

  const size = blockSize * blockSize; // (2 * 2)
  //               avg red,    avg green,  avg blue
  return rgbaToHex(r / size,   g / size,   b / size);    
Enter fullscreen mode Exit fullscreen mode

And then we pass it to a function that turns variables of red, green and blue into a valid hex value.

function rgbaToHex(r, g, b) {
  return (
    "#" +
    [r, g, b].map((x) => Math.round(x).toString(16).padStart(2, "0")).join("")
  )
}
Enter fullscreen mode Exit fullscreen mode

Let's break this down into steps:

  • Start with a #
  • Take each of the R, G, B values in order and do the following:
    • Round the value (as we had averages and we need integers)
    • Convert the raw value to hexidecimal (0-9A-F to change the string to base16)
    • make sure that smaller numbers (0-15) are padded with a 0 so we always get 2 digits for each of the R, G and B values (so we always get a total of 6 characters(
    • join the R, G and B hex values together.

So if we had [200.2,6.9,88.4] as our R, G and B values we would get:

A = 10, B = 11, C = 12, D = 13, E = 14, F = 15

- Start -> "#"
- 200.2 -> round (200) -> (12 * 16) + 8 = C8 
- 6.9   -> round (7)   -> (0 * 16)  + 7 =  7
- 88.4  -> round (88)  -> (5 * 16)  + 8 = 58
- Pad   -> C8, 07, 58
- Join  -> #C80758
Enter fullscreen mode Exit fullscreen mode

And there we have it, R200, G7, B88 is hex code #c80758.

That's a wrap

There are some really interesting parts around sending commands to WASM applications in there, I would encourage you to explore those yourself, along with the super interesting article by Cornelius Diekmann on porting DOOM to WASM I mentioned earlier.


ย ย 

If you found this article interesting (or distracting...haha) then don't forget to give it a like and share it with others. It really helps me out!

ย ย 

See you all in the next one, and have a great weekend

Top comments (30)

Collapse
 
besworks profile image
Besworks

This is great! One little suggestion: Add user-select: none; to the controls.

button quirk screenshot

Collapse
 
grahamthedev profile image
GrahamTheDev

Ahhh, I should have tested it more on mobile, added, hopefully that fixes it!

Collapse
 
younes_alouani profile image
Younes ALOUANI

Awesome

Thread Thread
 
younes_alouani profile image
Younes ALOUANI

Awesome

Collapse
 
warkentien2 profile image
Philip Warkentien II

As always, amazing work!
Replacing the OLED display with CSS

Collapse
 
grahamthedev profile image
GrahamTheDev

Haha, maybe we should be rendering games and tv shows in CSS gradients to take, full advantage of OLEDs :-P

Collapse
 
warkentien2 profile image
Philip Warkentien II

"James Cameron, hear me out, Avatar 5... in CSS"

Thread Thread
 
grahamthedev profile image
GrahamTheDev

๐Ÿคฃ๐Ÿ’—

Collapse
 
warkentien2 profile image
Philip Warkentien II

How else are we going to max those lch() colors?

Collapse
 
nathan_tarbert profile image
Nathan Tarbert

this is so fun haha, love seeing people push css to the absolute limit for no reason except curiosity

Collapse
 
grahamthedev profile image
GrahamTheDev

No reason? Are ytou trying to say that you don't think it would be a good idea to render a whole website using CSS gradients? :-P

Collapse
 
nathan_tarbert profile image
Nathan Tarbert

Haha no I'm not saying that. More of a joke.
I'm a big fan of gradients :)

Collapse
 
ansellmaximilian profile image
Ansell Maximilian

Awesome

Collapse
 
nevodavid profile image
Nevo David

This is hilarious and honestly the amount of browser abuse here cracks me up. anyone else ever dig way too deep just because youre curious?

Collapse
 
grahamthedev profile image
GrahamTheDev

Literally my whole back catalogue is me digging too deep! hahahaha ๐Ÿ’—

Collapse
 
nathan_tarbert profile image
Nathan Tarbert

honestly this kinda stuff feels like magic to me, love seeing how folks break things down to make it possible. you ever find yourself just sucked into a problem so deep you forget how weird it is?

Collapse
 
nathan_tarbert profile image
Nathan Tarbert

insane seeing doom run like that in a single div, honestly stuff like this always makes me wonder - you think messing with limits like this actually teaches more than just following tutorials?

Collapse
 
grahamthedev profile image
GrahamTheDev

I think you learn things more deeply doing stupid stuff, as to do something that is not normal means you have to work out the solution yourself plus understand all the fundamental parts of whatever you are playing with to know where you can push the boundaries / combine things in unexpected ways.

I don't think I would learn the same depth from tutorials (not that tutorials are bad though, just different purpose!)

Collapse
 
best_codes profile image
Best Codes

This is awesome. I always love your articles, keep it up!

Collapse
 
grahamthedev profile image
GrahamTheDev

Awww thanks! ๐Ÿ’—

Collapse
 
dotallio profile image
Dotallio

Honestly, I love seeing web tech pushed this far just for the fun of it. Did you notice any major differences in performance across browsers while testing this?

Collapse
 
grahamthedev profile image
GrahamTheDev

Not really, firefox was a little more stressed at higher resolutions but as everything else uses chromium under the hood they all performed pretty much the same.

To be fair, I just did a "does it look jerky" manual look, never did any true performance checking. Feel free to steal my stuff and up the resolution and do some browser comparisons, would be pretty cool to see which are faster with CSS gradient parsing / massive CSS file parsing! ๐Ÿ’—

Collapse
 
armando_ota_c7226077d1236 profile image
Armando Ota

hehe tops

OSZAR »