Infinite Grid Shader
· 16 mins

Explore the live demo
The WebAssembly renderer loads only after you choose to run it.
Learning to create an infinite grid procedurally using GLSL shaders.
This article contains my notes, lessons and findings, and is intended for personal reference. Therefore, you might find errors, misjudgments, and other issues, which are fine by me because my intention was to learn by teaching myself. In a way, I was being my own rubber duck.
Our goal
This research started when I came across 3D Graphics Rendering Cookbook → Ch5 and tried to understand how the author created their infinite grid.
The more I tried to understand it, the harder it became, as there were so many concepts involved in creating just a simple grid.
The equations used in the fragment shader were unclear to me. Finally after writing this blog, everything has become much clearer, except for texture filtering. I understand the concept and can also figure out solutions, but sometimes it’s more a matter of trial and error until I get to the correct answer.
There’s also a video tutorial about this implementation. I’m sharing it because it might be more helpful to others, although I still find the video difficult to follow.
Our journey starts with a simple grid
Credit for this part goes to SimonDev. His shader course taught me how easily we can draw a grid using the following shader code.
float grid_cell_size = 0.5;
float thickness = 0.05;
vec2 cell_uv = fract(uv / grid_cell_size);
vec2 cell_uv_centered = abs(cell_uv - 0.5);
float dist_from_center_per_cell = 1.0 - 2.0 * max2(cell_uv_centered);
float edge = 1.0 - smoothstep(0.0, thickness, dist_from_center_per_cell);
out_color = vec4(vec3(edge), 1.0);Here, grid_cell_size means the width of a cell in world units. If this value
were intended to represent frequency instead, multiplying uv by it would also
make sense.
The main difference here is that our grid exists on a plane (quad) with perspective transformations.

However, we now have aliasing issues, which have been the hardest part for me to understand and solve.

It’s not only the jagged lines that we need to address with filtering techniques. Resolution of the procedural pattern needs to change with distance as well. The farther we go, the smaller the grid becomes, and the more of the grid pattern a single pixel must represent.
I feel that filtering and LOD need each other to make the renders beautiful, and we will understand them separately.
Screen-Space Derivatives on a Perspective Grid
I will not speak too confidently in this section because screen-space derivatives are still confusing to me. You can find details on derivatives in these other blogs:
- Basics & Applications
- What is fwidth()
- Distinctive derivative differences might also be helpful. I have not finished reading it yet.
- Cem Yuksel’s 3D Transformations
- I know this is not directly related, but understanding how view space is transformed into screen space will surely help.
- The behaviour of screen-space derivatives changes drastically when working on a flat X/Y plane covering the entire screen, with no perspective distortion or depth involved, compared to 3D space that is transformed into 2D screen space.
The first useful thing to understand is what one pixel represents. Watch this Pixels video first.

Throughout this post, when I say
uv, I mean a world-space position on the XZ grid plane inside the fragment shader.It is not a normal texture
UV. A clearer name would probably begrid_pos, butuvis short and can represent any X/Y plane.
du = dFdx(uv)dv = dFdy(uv)dudv = vec2(length(vec2(du.x, dv.x)), length(vec2(du.y, dv.y)))I will use:
pxfor screen space units ~1pxrepresents a unit distance in screen space.unitfor world-space units ~1unitrepresents a unit distance in the spatial domain.
The following details are references and reminders for me on what’s correct and what’s not.
- In simple terms:
dfdx(v)is(x+1) - xanddfdy()is(y+1) - y, where+1means one pixel in +X or +Y direction. In other words, they both represent rate of change in the X/Y direction. So if we take the derivative of a scalar, it will be the rate of change for that scalar in X/Y direction, while a derivative of a vector will be rate of change in X/Y direction, calculated separately for each component. - Derivatives can be positive, negative or zero.
abs()is useful when visualizing only their magnitude. - Never (unless you know what you are doing), merge the derivatives of different component or axes of a vector into a single scalar. For example
length(du)is very different fromlength(vec2(du.x, dv.x)), both have different meanings and purposes. The same applies when choosing betweenmax()andlength()when working with derivatives.

Visualizing du and dv
- The number of pixels available to display one unit of world-space length generally decreases as the geometry moves farther from a perspective camera. Once shapes or pattern details occupy less than a pixel (the screen’s sampling resolution) they may begin to break apart or flicker. This is called
aliasing, and filtering is required to produce a more stable result.

The following result looked good, but it was not what I wanted. I was trying to
make the grid lines approximately one pixel wide and initially assumed that length(dudv) directly meant one pixel in world space.
As I learned more, I realised that this assumption was incorrect.
dFdx(), dFdy(), fwidth, and many other custom combinations like length(dFdx(uv)) etc. they all represent some amount of pixel region. These regions keep on changing as we move our camera.
Each way of combining dFdx() and dFdy() means something different, and represents a different pixel region. So it’s important to chose the right pixel region when working with derivatives, as per your own needs.

Grid using length(dudv) as grid line size
Looks like spider web (I should try turning it into an actual spiderweb in future) 🕸️❤️.
What I have realized is that, when working with derivatives of vectors, each component’s derivative should generally be preserved separately, especially when the result is used in component-wise calculations.
Reducing a vector derivative to a single scalar using functions such as
length()ormax(), and then applying that scalar uniformly to every component, loses directional information. This is appropriate only when the reduction is intentional and matches the specific calculation being performed.
A recap of our goal
Before moving forward, let us finalise what we are trying to achieve.

After many failures, I realised that the first version of Simon’s simple-grid approach was not enough for a perspective grid that could be zoomed in and out.
Why? If you experiment with Blender’s viewport, you will notice that:
- The grid’s level of detail changes as you scroll closer to or farther from the grid plane. It has multiple grids, larger and smaller, to provide different details in world space units.
- It doesn’t look aliased, cause details that are too small to display are hidden from the user completely.
- The grid-line thickness remains visually stable. Basically, if you look closely, grid line widths doesn’t change.
The Best Darn Grid Shader (Yet) blog & Inigo’s Grid with filtering formula shows filtered grids, but they have their own limitations. We are not working within a limited view space; our camera can pan/tilt/move anywhere in the world, making it practically impossible to remove aliasing completely from the grids.
I’ve wasted ton of time figuring out why my grids didn’t look good. I kept jumping between blogs/videos etc., which only made me more confused.
The thing is: With perspective comes aliasing (my spidey-sense tingles 😜), and that aliasing cannot be removed completely. So the only solution that remains is to hide them smartly, which is done using LODs and falloffs.
This is what 3D Graphics Rendering Cookbook - Ch5 solves internally. It’s an exact match, but is still partially unclear to me (by the end of this article I won’t be).
At this point, we have good enough knowledge on the derivatives and have basic understanding on how simple grids can be drawn. Moving forward we will try to understand 2 very complex topics: Filtering and Level of Detail
Rewriting our Perspective Grid

The above is just a graph representing what we want to draw on our quad. Each jump of value 1 represents our line, while 0 represents the background.

vec2 cell = mod(uv, cell_size);
out_color = vec4(vec3(cell / dudv, 0.0), 1.0);We can normalise the result by dividing it by cell_size or just clamp the values between [0, 1] (let’s call it clamped_result). Then we need to remove regions where both components have the value 1, represented as (1, 1).
My first idea was XOR.

I used xor operator, which doesn’t look wrong above. But, it is. We will find out.
The grid looks right, right!? However, there’s a flaw in it. Yeah using XOR operator removes the (1, 1) regions, but that is all it does. We forgot that we also need to covert (0, 0) (which occurs at grid-line intersections) to 1.
A more accurate pattern signal would be:
vec2 inverted = abs(1.0 - clamped_result);
float signal = max(inverted.x, inverted.y);
// -(11) (revised origin) -> 1,
// 01 -> 1, 10 -> 1,
// 00 (all other regions except edges and origin) -> 0All our lines now have value 1, while background now points to 0.
Filtering & Anti Aliasing
What does filtering mean:
Initially, the term filtering confused me, as its everyday meaning is to let wanted things through and remove unwanted things. What does filtering even mean in the context of GPUs and computer graphics? Why is blurring an image called filtering? 🤔After going through Signal Processing lecture by Cem Yuksel , various blogs and asking ChatGPT, it clicked.
It’s the same. Anti-aliasing filters high-frequency changes (sharp edges) and preserves low-frequency changes (blurred/smooth edges), which helps improve the image quality.
Filtering isn’t limited to removing high frequencies. If we remove low frequencies and keep high frequencies, that can produce an edge-detection filter. The kernel-effects section in LearnOpenGL Framebuffers helps explain how these convolution kernels are applied to textures.
But how do we apply kernels on procedural patterns
First, we do not normally have direct access to the computed outputs of neighbouring fragments during the same rendering pass, although we can evaluate the procedural function at nearby coordinates.
Even if we did, performing a direct convolution (applying kernels) could be computationally expensive in a procedurally generated texture.
Depending on the kernel size, we might need to evaluate the pattern at several neighbouring positions (e.g. 3x3 kernel would require 9 evaluations per pixel, in the same fragment shader)
Before moving forward
Applying anti-aliasing in a procedurally generated texture, may or may not involve integrals. It can be as simple as usingsmoothstep, or as complex as using integrals.We will first take the hard path, cause that’s what I took, and wasted a ton of time understanding why we might need integrals to compute the average color of a procedural function over a pixel’s region. Since this is not the only way of getting average color for a pixel, you can skip this section if you want
The following example by Inigo in this section are heavily based on Calculus. So it becomes a requirement to at least have some basic understanding on differentials and integrals. I highly recommend going through the following 2 courses:
Apart from the above two, understanding what Filtering is also helpful:
- Realtime Rendering - check Aliasing & Anti-Aliasing section
- PBR Image Reconstruction & PBR Texture Sampling & Antialiasing
All the above are referential materials, I haven’t completed them as well. A quick read may help you understand the concepts better.
Analytical Filtering
I was going through the following blogs to understand this better:
- Fast Filter-Width Estimates, we will ignore this one. Keeping a reference for future.
- High Quality Filtering → check
24.3 Analytical Antialiasing and Texturing- We will use this one, we will modify the stripe function so that it becomes a continuous function of the
UVposition coordinates. (I cheated here, cause the signal pattern for stripes is closely related to checkerboard, I re-used Inigo’s mechanism on this one as well)
- We will use this one, we will modify the stripe function so that it becomes a continuous function of the
- Analytical Filtering of Checkerboard & Procedural texture filtering
- This one pushed me to understand calculus, that helped improve my knowledge around this topic.
- He talks about this in his Integration section, where he used something called analytical filtering. Instead of kernel matrices we use a mathematical
signal functionwhose filtered value can be solved analytically - NOTE: I learned this the hard way. Analytical filtering doesn’t necessarily mean integrating the signal, it can be any formula that helps reduce aliasing problem. Whatever the simplest solution is for a specific procedural pattern, wins.
A few really important statements from these blogs:
‟we’ll chose the third form, since it is easily extended into a smooth functionˮ
‟the point of picking this expression for the checkers pattern is that we can hopefully analytically filter the square signalsand get some filtered checkers pattern.ˮ
This is a very important statement. Choosing the right continuous function that takesuv(location) as input can make the required integral easier to derive. In some cases, integrals can be avoided because a simpler method of anti-aliasing the generated pattern is available. For e.g check my Polka dot pattern in the recording below‟instead of computing the value of the procedural function at a single point, it is necessary to compute the average value of the function over an area.ˮ
‟To antialias a function, we need to integrate the function over that pixel.ˮ
The above two statements, help understand importance of integrating the continuous function over the pixel region and then dividing by the region’s area to obtain its average value, that helps filter/anti-alias/blur our image on that pixel location.
We first need to understand why we need to get the average for every pixels? Check the screenshot below:


I got tired of writing, so I’ve been drawing and recording in this section 😅 I hope you guys can still understand the idea behind this form of filtering.
The most important lesson I learned while working on this section—the part that took me the longest to understand—was that:
- Analytical filtering does not always require an integral. Maybe you guys didn’t get confused like me, but my brain was stuck for a very long time, on why we needed integral here.
- Integral is important and more important is to understand the concept of averaging your pattern around a pixel region (as I explained in my diagrams)
- Finding a Pattern signal is the most important part. It can make the remaining filtering calculations much easier. Think in this way, can you describe a pattern as a continuous function returning floating point numbers that can be used to direct which color to use for a pixel. Simon did the same when we worked on the simple grid.
Finally I tried testing my learnings and the following is what I was able to crack. The stripe pattern uses the same signal as the checkerboard so it wasn’t that hard to draw. The challenge came when I tried to find a polka dot pattern on my own. I will leave that for you as an exercise to figure out how to render such a pattern with analytical filtering in-built in you pattern shader.
Coming back to our grid
I could not find a better solution than the one provided by the Cookbook, so I won’t deviate from its formula for now. We can try to understand the formula better instead of creating our own. I may revisit this again in future to possibly find an alternative formula with smoothstep(), but I am not yet experienced enough with screen-space derivatives, which makes all this possible.
In the Cookbook, the author uses these 2 formulas to anti-alias the line:
dudv * 4.0: which increases the region of line, making it rectangular rather than just a 1px line, which cannot be filtered.- and shifting the line to center, as can be seen below

float draw_grid(vec2 pos, vec2 dudv, float cell_size) {
dudv *= 4.0;
pos += dudv / 2.0F;
vec2 line = satv(mod(pos, cell_size) / dudv);
vec2 centered_line = 1.0 - abs(line * 2.0 - 1.0);
return max2(centered_line);
}The above is the same formula from the cookbook, but adjusted to my understanding.

Understanding Level of Detail (LOD)
Compared to filtering this one was easy to understand, and should be straightforward, cause everything related to LOD (in the book) is linked to just one function →
log(). I’m not sure if that’s the only way, maybe there are more ways of doing this.The logarithm allows us to divide a large field into discrete LOD ranges.
Also, when I say log(), it’s base is
10(in code I’m usinglog10()representing it). In case we ever had to use natural log, I’ll useln()to represent it.
If you see our last image it still doesn’t look good, even after the lines are anti-aliased and all. First of all the filtering can be improved for sure. Check Inigo’s procedural filters which does look far better, but our requirements were different, so that’s that.
LOD to the rescue 😅. The main formula for LOD is the following:
float lod_level = log10(length(dudv) / region);where, region can be anything providing the limit/bounds for our log(), in our case it represents the Quad size where we draw our texture.

Mesh Simplification
As per Realtime Rendering book, LOD can be divided into 3 stages: Generation, Selection & Switching
Generation can be pre-calculated or dynamic. Our Grid is a procedural pattern rendered on a quad, so it’s simplification will also have to be dynamic. For usual textures, Mip-Maps already solve this problem internally (given that texture & sampler were properly configured)
Our next requirement is to get the right cell size for the respective LOD:
float cell_size0 = grid_cell_size * pow(10.0, floor(lod_level));
float cell_size1 = cell_size0 * 10.0;
float cell_size2 = cell_size0 * 100.0;The tricky part to understand here is that:
When the LOD changes, our lower-detail cell_size replaces the higher one, meaning closer to the camera cell_size0 represents grid lines spaced 0.5 world units apart, while in next LOD cell_size0 will start representing whatever cell_size1 was representing in the previous LOD.
Once we have our cell size, we can now calculate our grid signals for each size, and render them
float signal0 = draw_grid(pos, dudv, cell_size0);
float signal1 = draw_grid(pos, dudv, cell_size1);
float signal2 = draw_grid(pos, dudv, cell_size2);
float signal = max(signal2, max(signal1, signal0));
out_color = mix(bg_color, grid_color, signal);Please note that I’ve simplified the logic above. You can check the original source for this here: Cookbook GLSL code
I’ve modified the original source a bit to my own liking, and it looks something like this:

This result is pretty close to what Blender renders, and I like it. Not sure on Blender’s own logic, but it’s lines are even more thinner and stable, I feel like they use GPU lines, and not triangles (lines with thickness), not sure. Anyway, the only thing I don’t like about my entire research is that I couldn’t find my own filtered line signal. It took me a ton of time to understand all these concepts, so I skipped it for now. Anyway I’m happy that I was finally able to understand this shader code.
Special thanks to:
- SimonDev
- For his wonderful course on shaders. I still keep revisiting that course every now and often
- Cem Yuksel
- For sharing his great lectures on youtube for us to learn from
- Inigo
- His work is on another level. Hard to understand, but one’s you start understanding his work, it hits you differently
- Bruno Simon
- It’s because of him my journey in Rendering and 3D Graphics started. Do checkout his course