DEV Community

Cover image for DOOM...*rendered* using a single DIV and CSS! 🤯🔫💥
GrahamTheDev
GrahamTheDev

Posted on • Edited on

83 18 22 11 17

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

AWS Security LIVE! Stream

Streaming live from AWS re:Inforce

Tune into Security LIVE! at re:Inforce for expert takes on modern security challenges.

Learn More

Top comments (34)

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
 
greenteaisgreat profile image
Nathan G Bornstein

Do you think the CSS gods stay inline because they too live in fear of what they've created?

Seriously, so so so so impressive. You're nothing short of a maestro 🔮

Collapse
 
grahamthedev profile image
GrahamTheDev

Haha thanks, pretty sure the CSS Gods are scared of their own creation with the new Houdini stuff, the stuff we can do with that is just scary (and fun!). 💗

Collapse
 
armando_ota profile image
Armando Ota

hehe tops

Collapse
 
hbthepencil profile image
HB_the_Pencil

Cool! But, um, doesn't that basically just make this a filter for DOOM?....

Collapse
 
grahamthedev profile image
GrahamTheDev

Not quite, we are rendering the image with CSS, not adjusting it (other than the resizing before we render it).

Collapse
 
dotallio profile image
Dotallio

You really made my browser sweat but I love it! Pretty sure CSS was never meant to handle this much chaos, but now I want to see what breaks first - the GPU or my patience.

Collapse
 
grahamthedev profile image
GrahamTheDev

Haha, I should have added a discalimer "warning, I will not be responsible for loss of GPU or fires on your PC when running this demo!" 🤣💗

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!)

Sentry image

Make it make sense

Only get the information you need to fix your code that’s broken with Sentry.

Start debugging →

👋 Kindness is contagious

Explore this insightful piece, celebrated by the caring DEV Community. Programmers from all walks of life are invited to contribute and expand our shared wisdom.

A simple "thank you" can make someone’s day—leave your kudos in the comments below!

On DEV, spreading knowledge paves the way and fortifies our camaraderie. Found this helpful? A brief note of appreciation to the author truly matters.

Let’s Go!