GPU Shader - Flashing Bricks

GPU Shader - Flashing Bricks

The GPU or Graphics Processing Unit is an essential part of today's computers and smart phones. It is a co-processor that frees the CPU from the burden of drawing images to the screen. A developer provides the GPU with all of the information it needs (3D coordinates, polygons, textures / images, lighting information, etc.) and the GPU will efficiently render the result to the screen.

30 years ago, when I first developed my ball and paddle brick bashing game, MacBrickout, GPUs weren't plentiful like they are today. Not only did I have to program the CPU to do all of the usual game-play stuff like moving the objects around the screen, keeping score and detecting collisions between objects and bricks but I also had to draw those game objects, painstakingly copying their images to screen memory pixel by pixel, 60 times a second. It was quite a challenge for one CPU to keep the game running smoothly, especially when handling a large number of moving objects.

I recently decided to revive my old game and update it to use the Unity game engine. Unity provides a host of functionality that makes game development fun. In particular, Unity makes it easy to write GPU shaders. A shader is a fundamental program that tells the GPU how to render the surface of one or more polygons. Shaders can describe how to render fire, water, wood, grass, rough sand, smoke, a shiny marble or pretty much any material you can dream up.

I wrote a shader that renders the metallic gold and gray bricks in the game and thought I'd share how the shader works to hopefully dispel some mystery surrounding GPUs.

You can write shader code by typing text commands into the computer and then compiling it to run on the GPU - similar to how you would write traditional code for a CPU. But node based shader editors allow you to write your programs visually and they make it much more fun to write and experiment with shaders. Here's a simple shader that draws a gold brick or gray brick based on whether or not an input parameter, isGold is true or false.

No alt text provided for this image

Each block in the above image is called a "node". Nodes are self-contained blocks of functionality with inputs on the left and outputs on the right. You can drag spaghetti noodles between the inputs and outputs to hook the nodes together. In the above example, the Compare node tests if A is equal to B. If so, then the True value is passed through. Otherwise the False value is passed through. Since isGold is 0 and B on the compare node is 1, they are not equal. Therefore the BodyGrayColor is passed through to the Albedo field of the Testbed shader I created. Albedo describes the color that will be drawn by the shader. Here are three bricks that are drawn using the above shader:

No alt text provided for this image
Three Gray Bricks?????????????????????Three Gold Bricks

In order to meet the requirements for flashing bricks in MacBrickout, we'll need to add some more functionality to the shader. First let's understand the requirements.

Flashing Brick Requirements

  1. MacBrickout shall render gray and gold bricks
  2. Bricks shall have a 3D bevel effect around the edges
  3. Bricks shall be able to be any size
  4. Bricks shall be able to flash on command. Flashing is accomplished by highlighting certain components of the brick in sequence as diagramed below.

No alt text provided for this image
There are three flash stages, each lasting 0.1 second

We've already achieved the first requirement (gray and gold bricks). Let's tackle the second requirement and draw the beveled edge.

Beveled Edge

No alt text provided for this image
Rectangle Node is a good start

The shader editor provides a large library of nodes to choose from and after some poking around I found a Rectangle node that would be perfect for rendering the bevel feature. The Rectangle node outputs a 1 or a 0 depending if the part of the surface being rendered is inside or outside of the rectangle. I have temporarily disconnected the body color nodes and connected the rectangle node directly to the Albedo input of the shader. When the Rectangle node outputs a 1, the RGB inputs to the Albedo field will all be 1 which is rendered as white. For an output of 0, the RGB inputs are all set to 0 which is rendered as black. There are three inputs to the Rectangle node: Width, height and UV. UVs are texture coordinates which I'll cover a little later. Width and Height are simple to understand. The values range from 0.0 to 1.0 and determine how much of the brick area is covered by the rectangle. Since both are set to 0.9, 90% of the space from the center of the object to the edge will be inside the rectangle and thus will be rendered white. If I change those values to 0.5, then the white rectangle occupies only 50% of the space of the entire brick.

No alt text provided for this image
Rectangle Width & Height = 0.9??????Rectangle Width & Height = 0.5

You may have noticed that as the brick changes size, so does the black area where the bevel will appear. It scales proportionally with the brick. We want the bevel to stay the same thickness no matter what size the brick is (Third requirement above). We'll need to adjust the width and height inputs to the rectangle node based on the width and height of the brick.

In Unity, every Game Object (including each brick) has a transform which describes where in 3D space the object lives as well as its size and 3D rotation. Each object's transform is passed to the GPU and we can access that transform's scale (aka its size) pretty easily.

No alt text provided for this image
Rectangle width is inversely proportional to object scale

I added some logic that feeds into the input of the Rectangle node. An input parameter called BorderThickness was added and set to 2 units. It gets multiplied by 2 to account for the fact that there is a border on both sides of the brick (left and right, top and bottom). Then we divide that border thickness by the object's size and invert the result with a OneMinus node. There's a lot to unpack here so let's run through the math with an example brick that is 40 units wide by 20 units tall. In both the horizontal and vertical calculations, BorderThickness is multiplied by 2. Look at the flow above and see if you can glean how it relates to the two formulas below.

Width = 1 - ((BorderThickness * 2) / 40) = 0.9

Height = 1 - ((BorderThickness * 2) / 20) = 0.8

For a side that is 40 units in length, the rectangle will occupy 90% of the total space. For a side that is 20 units in length, the rectangle will occupy 80% of the total space. This ensures that as the brick grows in size, its border thickness is proportionally adjusted so that the border remains 2 units thick around all four sides.

No alt text provided for this image
Different size bricks with same border thickness

Using the Rectangle node and a little bit of logic, we've established a body region and a bevel region of the brick. Now let's colorize the body and bevel. The upper left bevel should be drawn in a lighter color and the lower right bevel should be drawn in a darker color. Where the dark and light bevels meet, a nice diagonal seam should appear.

Here's a simplified block diagram outlining how we decide what color to use when rendering different parts of a brick.

No alt text provided for this image

A brick is made up of 4 regions (see image above). Regions 1 and 2 span the full height of the brick but only encompass the width of the left and right bevels respectively. Those regions are drawn mostly in one color except for the very bottom or Region 1 and very top of Region 2 where the color is split diagonally between dark and light. Region 3 is the body of the brick and is drawn in the body color. Region 4 encompasses the top and bottom bevel areas that aren't drawn as part of Regions 1 and 2.

Remember the Rectangle node in the shader? The first test in the flow above checks to see if we're inside that rectangle (white area where output of the node is 1). If we are then draw the body color. Otherwise we're drawing a part of the beveled area so some more tests are necessary.

Before moving further, let's learn about UV coordinates as they are essential in deciding when to draw the light and dark colors in the bevel.

No alt text provided for this image

UV coordinates tell the GPU what part or fragment of the brick is being drawn. The GPU uses the UV coordinates to determine which color to output. Remember that GPUs aren't the same as CPUs. They're massively parallel devices that invoke numerous shaders thousands of times per second to paint every pixel on the screen. When a shader is called on a particular material for a given object, the UV coordinates identify what part of that object is needing to be drawn. They range in value from 0.0 to 1.0. In this case where we're drawing bricks, the U coordinate starts at 0.0 on the left side of the brick and increases to 1.0 when we reach the right side of the brick. Likewise, the V coordinate starts at 0.0 at the bottom of the brick and increases to 1.0 at the top of the brick. If the UV coordinates were (0.0, 1.0), that would be the top-left corner of the brick and I would know to draw the highlighted bevel color. If the UV coordinates were (1.0, 0.0), that would be the bottom-right corner of the brick and I would know to draw the darkened bevel color. A UV coordinate of (0.5, 0.5) is smack in the center of the brick where the body color should be drawn.

The remainder of the logic in the flow above outputs the dark color or the light color by taking into account the brick size (scale.x and scale.y) and the UV coordinates. Here is the updated shader. The lighter bevel color is derived from the body color by multiplying it by 1.4. Likewise, to get the darker bevel color, we multiply the body color by 0.6.

The diagonal seams at the top-right and bottom-left corners of the brick are created by comparing the U and V coordinates against each other. Looking at the bottom corner of the brick, if the U coordinate is greater than the V coordinate then we draw the darker bevel color. Otherwise we draw the lighter bevel color.

No alt text provided for this image
Shader that draws bricks of any size with light/dark bevel

Here is how the bricks look

No alt text provided for this image

The final requirement is to add the ability to flash the bricks. In the game, the gold and gray bricks flash whenever hit by a ball.

Flashing

Recall that there are three steps to the flash sequence. Step 1 highlights the top and left bevel. Step 2 highlights the center body and Step 3 highlights the bottom and right bevel. If I were developing this functionality for a traditional CPU, I might write a function to kick off the flash sequence. Inside that function, I would perform Step 1 and then set a timer for 0.1 seconds. When the timer expired, I would perform Step 2 and set another timer. When that timer expired, I would perform Step 3 then fire another timer. Finally, when that timer expired, I would restore the brick to its original appearance. I could then call this function on any brick in the scene to cause it to flash.

GPUs operate differently. There is no faculty inside a GPU to create and run an arbitrary number of timers. In 1/60th of a second, the shader can be called 1,000 times with the only difference between calls being the UV coordinates specifying what fragment of the brick is being rendered. In our shader, we can create a FlashTime input parameter. This allows the CPU to specify the time when it wants the brick to flash. We can subtract the time stamp from the current system time and then do some comparisons to determine which flash step we're in:

No alt text provided for this image

The compare nodes help us determine the flash stage. The first two check to see if the time difference is between 0.0 and 0.1 seconds. If so, they will output a "1". If both output a "1" then the And node will output a "1" and we'll know we're in the first stage (An And node is a logic operation that will only output a "1" if both inputs are "1", otherwise it will output a "0"). The second set of Compare nodes checks to see if the time difference is between 0.1 and 0.2S. The next And node outputs a "1" if we are. Finally, the third group outputs a "1" if the time difference is between 0.2 and 0.3S. To summarize, the logic is as follows:

  • Flash Stage 1: Top group outputs a "1", all others output a "0"
  • Flash Stage 2: Middle group outputs a "1", all others output a "0"
  • Flash Stage 3: Bottom group outputs a "1", all others output a "0"
  • Any other time, all three groups output a "0"

Now we can route the flasher outputs to some compare nodes that either show the normal color or a white flash color based on a "1" or "0" input.

No alt text provided for this image
Compare nodes decide which color to output

Here is the completed shader that meets all four of the requirements listed above:

No alt text provided for this image
Complete brick flashing shader

Before wrapping up, I should talk about GPU Instanced Properties. Every brick uses the same shader. Without GPU Instancing, when I write the current time stamp to the FlashTime parameter, all bricks will flash at the same time. Also, all bricks will appear the same color. GPU Instancing allows me to use one shader but have it reference separate instances of certain properties when rendering each object. The instanced properties use more memory in the GPU because instead of having one global FlashTime and one BodyColor property, there is instead one set of properties for each brick in the scene. By marking those properties as "Instanced" properties in the node editor, I can now have gray and gold bricks on the screen at the same time and flash them all independently. It's worth pointing out that one has to be careful when using instanced properties as they can eat up available memory on the GPU, especially when there are thousands of objects on the screen.

Hopefully GPUs and shaders are a little less mysterious now. We used a rectangle node to separate the brick body from the bevel and scaled the width and height of the rectangle proportionally with the brick so that the beveled region stays the same thickness no matter what size the brick is. The Diagonals section compares the U and V coordinates to determine if we should draw the lighter or darker bevel color. The color section has several Compare nodes to help determine what color should be output. Those Compare nodes are driven by the Flasher section, the Rectangle node and the Diagonals section.

You may be wondering why I chose to draw the bricks in 2D with a fake 3D bevel effect rather than use actual 3D beveled bricks. I initially played around with some 3D bricks but found that I didn't like how the bricks looked when stacked next to each other. There were two problems. First, since the bricks were actually 3D, they had depth that could be seen between the gaps of the bricks which made the gaps less pronounced. Second, due to camera perspective, the bevels appeared to be different thicknesses based on where the brick was on screen with relation to the camera.

If you're interested in game development or want to see MacBrickout in action, you can follow me here on LinkedIn or on Twitter (X) or download MacBrickout (for Mac and PC) from here: https://www.MacBrickout.com


要查看或添加评论,请登录

Carson Whitsett的更多文章

  • Why Interviewing Is Harder for Senior Engineers

    Why Interviewing Is Harder for Senior Engineers

    When an interviewer asks, “What’s your greatest weakness?” my answer is simply: “Interviewing.” Haha.

    3 条评论
  • Bringing Ideas to Life: Turning Inspiration into Action

    Bringing Ideas to Life: Turning Inspiration into Action

    Have you ever had an idea pop into your head that made you think, “I should work on that someday”? Then, as life…

  • Debugging is a Waste of Time (and Money)

    Debugging is a Waste of Time (and Money)

    As a software developer, have you ever thought about how much time you actually spend debugging your code? Or as a…

    1 条评论
  • Pac-Man and Time Dilation

    Pac-Man and Time Dilation

    The year was 1905 when Albert Einstein published his theory of special relativity. The theory states, in a nutshell…

  • The Career Pivot

    The Career Pivot

    Career pivots, a journey I’ve navigated several times, invoke excitement, fear, anticipation, anxiety, hope and a…

    7 条评论
  • Happy Decade Ditty Labs

    Happy Decade Ditty Labs

    A decade. That's a long time.

    1 条评论
  • Job Seekers Add Some Color

    Job Seekers Add Some Color

    Finding your dream job is not easy. There are hundreds of thousands of jobs out there and an equal number of job…

  • The Crux of the Problem

    The Crux of the Problem

    Time is synonymous to money. Take either one: You can spend it, waste it, lose it, have too much or too little of it…

    1 条评论
  • Estimate Accurately, Complete On Time

    Estimate Accurately, Complete On Time

    Estimating the number of hours it will take to complete a project is a difficult task. If that project is a software…

    2 条评论
  • On taking the next step in your career

    On taking the next step in your career

    There are a lot of opportunities out there. Recently I updated my LinkedIn profile’s career interests section and…

    1 条评论

社区洞察

其他会员也浏览了