通过 OpenGL 1.x 升级
Upgrading Our Way Through OpenGL 1.x

原始链接: https://bumbershootsoft.wordpress.com/2025/09/27/upgrading-our-way-through-opengl-1-x/

本文详细描述了一次回顾 OpenGL 版本的旅程,目标是复现 DirectX 9 中简单的图形能力——具体来说,是基于像素的屏幕显示和加速精灵。作者从 DirectX 9 开始,探索在着色器成为核心之前的最后一个“固定功能”图形流水线。 项目随后转向系统地使用 OpenGL 重现此功能,从 1992 年的 OpenGL 1.0 最小规范开始,并逐步进行到后续版本。最初的步骤包括仅使用 OpenGL 1.0 调用将图像显示为纹理。 后来的版本(1.1-1.5)引入了诸如纹理对象以管理多个纹理、顶点数组以高效处理几何体以及顶点缓冲对象以将数据移动到 GPU 等改进。 作者还阐述了历史背景,指出早期的 OpenGL 实现受到硬件能力(小纹理尺寸、2 的幂次方尺寸)的限制,并且 API 随着图形卡的日益强大而不断发展。文章最终以一个可用的 OpenGL 1.5 实现告终,该实现能够显示可调整大小的图像,为未来探索基于着色器的 OpenGL 版本奠定了基础。

黑客新闻 新 | 过去 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 升级我们的 OpenGL 1.x (bumbershootsoft.wordpress.com) 55 分,PaulHoule 发表于 1 天前 | 隐藏 | 过去 | 收藏 | 讨论 考虑申请YC冬季2026批次!申请截止至11月10日 指南 | 常见问题 | 列表 | API | 安全 | 法律 | 申请YC | 联系 搜索:
相关文章

原文

Back in 2020, I started taking a look at DirectX 9. That was interesting to me because it was the final form of the old “fixed function” graphics pipeline—DX10 was a major rupture in the API, making GPU shaders the fundamental unit of graphics work and discarding much of the older workflows.

This turned into two projects: a pixel-based library that allowed C programs to write like a DOS program that owned the whole screen, and an accelerated sprite-like library that could do color and alpha blending in sensible ways. At the time, I observed that “the approach to getting the display out is completely different from how I did it with OpenGL under GTK3, and even noticeably different from the way you’d have done it with OpenGL back in 2002 when DirectX 9 was first released.”

I never did, however, actually go look at what it would take to do it with OpenGL, in 2002 or otherwise. In the meantime, I’ve discovered that the Khronos Group publishes a History of OpenGL including all previous versions. I’ve kind of been meaning to revisit those old simple-graphics systems for awhile, and this will make a fine excuse.

In fact, we can do a little better. We can actually start at the very beginning, writing a skeletal version of the pixel-screen display that only uses the 1992 OpenGL 1.0 API, and then work our way up to the final 4.6 revision from 2017, picking up useful or newly-necessary capabilities as we go.

1992: OpenGL 1.0

The very first OpenGL spec was very minimal. Apparently, this was intentional—it was designed mostly to be a foundation based on a fraction of SGI’s IRIS GL, solid enough to extend but basic enough that third parties might actually implement it. In the event, they stripped out so much that almost everything we’d recognize as old-school 3D programming isn’t really there until 1.1. Nevertheless, we have enough options here that we can ask for our sample display with only calls and options 1.0 supports. We’ll start here and build our way up.

Our prep work, too, is minimal; we need simply load our image into the texture. Textures offer a great many pixel formats these days, but OpenGL 1.0 only offered a few. Happily for us, one of those formats (GL_RGBA and GL_UNSIGNED_BYTE) is the precise format that stb_image provides when it loads a file. We’ll be able to use the results of stbi_load() directly.

void renderInit(unsigned char *img, int w, int h)
{
    glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, img);
}

As you can see, we only need two calls here. The first sets a configuration option: GL_UNPACK_ALIGNMENT. OpenGL uses this value to decide whether or not it may assume that each row of pixels is aligned to 2, 4, or 8-byte boundaries. As it happens, a malloc()ed block of memory will almost always be 16-byte aligned these days, so this doesn’t matter, but technically a bare unsigned char array doesn’t have to be aligned at all and we can ensure correct behavior with a single call. If we were using GL_RGB instead of GL_RGBA, on the other hand, our pixels would only be 3 bytes long instead of 4 and our pixel rows could end up on weird boundaries. Configuring this properly is much more important in that case. (The stb_image library can give us both, depending on whether we ask for 3 or 4 channels.)

The second call, glTexImage2D(), actually loads the texture into memory. Most of these arguments are self-explanatory, but a few are opaque or ambiguous:

  • GL_TEXTURE_2D is confirming that we’re talking about normal texture images here.
  • The first zero indicates that we’re editing the image itself and not one of the subsidiary images used for trilinear mipmap filtering. We won’t be relying on that at all here.
  • GL_RGB is the format of the texture itself inside the system. We’re the whole screen so there’s no alpha channel. There’s nothing behind us, after all.
  • The next two parameters are the dimensions.
  • The 0 after this refers to a feature that was never hardware-accelerated and now no longer exists. Modern docs say this argument must always be zero but you used to be able to put an independent 1-pixel border around textures, and this argument would turn that on.
  • GL_RGBA and GL_UNSIGNED_BYTE describe the data format of the source data; here there’s 4 channels including alpha and each channel is an unsigned byte. That’s what stbi_load() gives us, when we ask for 4 channels, and it’s what we can use here.
  • The last argument is a pointer to the array of data directly.

We only have to do this once, after our graphics context is properly initialized. The rest of the code we need involves actually drawing the frame, and we’ll be doing all of this once per frame. We’ll start out each frame by clearing the frame buffer:

glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
glClear(GL_COLOR_BUFFER_BIT);
glColor4f(1.0f, 1.0f, 1.0f, 1.0f);

Clearing the screen is a matter of setting the clear color and then calling glClear() with the appropriate flag set. In a 3D scene, we would probably also clear the depth buffer, since that’s used to make sure objects don’t get rendered on top of objects in front of them. A 2D image like this can ignore it.

As long as we’re here, we also set the foreground color to white. You may notice that this function ends with 4f; this, like the i suffix on glPixelStorei, is describing the types of the arguments that are used. If you’re used to languages like C++ or Java, you could understand this as a poor man’s function overloading, but given that these argument types often will accept both floating point and integer numbers, spelling this stuff out avoids mistakes.

Technically, the foreground shouldn’t matter; we’re going to draw a textured rectangle and that texture should dictate the color of every pixel on its own. At this point, however, textures are by default multiplied with the underlying color and so making the color white gives us the true colors of the image. (For an example of drawing images with different background colors, including the messier question of how to deal with alpha values, see my earlier Direct3D 9 work.)

Now we need to configure the texture logic. This involves multiple calls to glTexParameteri to set up four configuration options for our render. All of them have sensible defaults, but for our purposes, every single one of those defaults is wrong:

glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);

The first two settings describe what happens when we try to sample a texture outside of its defined range of [0,1). This was expected to happen—the early docs assume that textures would probably cap out at, like, 64 pixels to a side—so you’d have some grass or brick texture and then set your texture coordinates so it would repeat across your wall or hill 3 times in each dimension. We, on the other hand, are using much larger textures and we want them to be locked to the edges if things try to go beyond it. That’s what GL_CLAMP does for us.

The last two settings control what strategy is used to handle magnifying or shrinking the texture depending on the actual size of the textured geometry. For now, our best option is nearest-neighbor pixel selection, which will get us nice sharp pixel edges, albeit at the cost of inconsistent pixel sizes. A trap for the unwary: the MIN_FILTER defaults to using mipmaps. If you don’t define those, as we didn’t, failing to configure this option will just make the renderer give up and you’ll likely just get a black screen.

Finally, we draw the geometry. This involves submitting vertices into a rendering space that is a 2-unit cube in all dimensions, ranging from (-1,-1,-1) to (1,1,1). For us, we want to fill a flat 2D space, so this is just a rectangle that ranges from (-1,-1,0) in the lower left to (1,1,0) in the upper right. We’ll also need texture coordinates, and those are measured differently: to match the data we got from our image loader, we’ll want these to range from (0,0) in the upper left down to (1,1) in the lower right. I’ve done this before in OpenGL 3.2, with some extra shader trickery to handle aspect correction, and I sort of waved off pre-shader work as “the old school.” Today, we’ll be rendering it old-school.

As it happens, it’s most effective to render rectangles as a pair of triangles, but if we’re careful about the order we submit our vertices, we can use special geometry types called “triangle strips” or “triangle fans” to get a rectangle out of just our four corners, instead of specifying the six corners that two triangles would require. That part is actually still true in the modern world (if you’re curious about the “being careful about the order we submit our vertices” thing, that varies from system to system but the search term you want is “polygon winding”) but actually delivering the vertex data in old-school OpenGL is very unusual compared to everything else.

Delivering geometry is accomplished with a series of function calls. First, a piece of geometry is initiated with a call to glBegin() that specifies what kind of thing we’re drawing. For our rectangle, we’ll be drawing a triangle strip. We then repeatedly call the glVertex4f() function to deliver the vertex locations one at a time, and call glEnd() once we’re done.

Of course, vertices have more data associated with them than just locations. To change those, we call similar functions that change the vertex configuration. Each call to glVertex then snapshots that state and submits it alongside the location. We’ve got two bits of ancillary data: the color, which we set once at the top and won’t be touching again, and the texture coordinates for each point, which we’ll need to reconfigure for each vertex before we submit it. Our actual geometry calls look like this:

glEnable(GL_TEXTURE_2D);
glBegin(GL_TRIANGLE_STRIP);
glTexCoord2f(1.0f, 1.0f);
glVertex4f(1.0f, -1.0f, 0.0f, 1.0f);
glTexCoord2f(1.0f, 0.0f);
glVertex4f(1.0f, 1.0f, 0.0f, 1.0f);
glTexCoord2f(0.0f, 1.0f);
glVertex4f(-1.0f, -1.0f, 0.0f, 1.0f);
glTexCoord2f(0.0f, 0.0f);
glVertex4f(-1.0f, 1.0f, 0.0f, 1.0f);
glEnd();
glDisable(GL_TEXTURE_2D);

This API is called immediate mode and it’s the only one that OpenGL 1.0 supports. It doesn’t get deprecated until the “modern age” of 3.x, and its tremendous flexibility does mean that it’s common for people to still miss it.

Also notice that we have to explicitly enable and disable texturing. Though I have in the past noted that relying on shaders for everything means that we’re actually asking the graphics card to do less work overall, it is still true that OpenGL won’t do most things unless you explicitly ask for them.

This wraps up our rendering logic. If we embed these two blocks of code into a suitable window management framework, we find that we are in business:

Screenshot: The Bumbershoot Software logo, displayed at 2x magnification inside a "Pixmap Shell" window.

We’ll get to the framework code in a future article, once we’ve finished our tour. For now, let us skip ahead five years.

1997: OpenGL 1.1

OpenGL 1.1 is released in 1997, shortly after the release of GLQuake. This seems to have been the killer app that got manufacturers to implement usable subsets of OpenGL in the first place, and it also does seem to be accepted as the point that it was considered a real alternative to Direct3D. Even though GLQuake itself predates the finalization of the OpenGL 1.1 standard, it got by with various official extensions that eventually became part of the main spec in some form. In that era there were a sizable number of “MiniGL” drivers that would implement exactly enough of the standard and its extensions to let you play particular games—being able to just download appropriate, fully-standards-compliant drivers for consumer video cards is still quite a ways into the future here.

For our project, there are two major features from 1.1 that we want to use: Texture Objects and Vertex Arrays.

Texture Objects

To be honest, I was astonished to discover that my “1.0” code above worked at all. I fully expect that most of my readers with OpenGL experience—old-school or modern—reacted to it the same way I did: “Wait, you fool! You didn’t create any textures, nor did you actually enable them on a texturing unit!”

That’s right. I didn’t. OpenGL 1.0 doesn’t have those. There is exactly one 2D texture, you turn it on and off with glEnable() and glDisable(), and you change with the texture is via uploading new images to it with glTexImage2D().

This is pretty obviously completely untenable for any sort of realtime system that is using multiple textures at once, and OpenGL’s solution to this is “texture objects”. It is still the case, at this point, that at any given time there is only one texture, but we may have multiple other textures “on deck,” as it were. glBindTexture() will make some other texture the new active texture. Since these presumably live in the graphics card’s VRAM, we don’t get to refer to them directly; instead we use small positive integers to refer to them. These integers are allocated and deallocated with glGenTextures() and glDeleteTextures(), but no real resources are used until the texture is bound and then defined with a glTexImage2D call.

Our renderInit() function now needs to generate a texture and bind it before creating it, but the 1.0 calls themselves remain unchanged:

GLuint screenTexture;

int renderInit(const unsigned char *img, int w, int h)
{
    glGenTextures(1, &screenTexture);
    glBindTexture(GL_TEXTURE_2D, screenTexture);
    glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, img);
    glBindTexture(GL_TEXTURE_2D, 0);
    return 1;
}

Similarly, when rendering, we’ll want to call glBindTexture(GL_TEXTURE_2D, screenTexture); to select the texture before configuring it with the glTexParameteri() calls, and we’ll unselect it with glBindTexture(GL_TEXTURE_2D, 0); on the way out. We don’t have to care about ordering with the glEnable(GL_TEXTURE_2D); calls; those only control whether texture data is sent to the rendering system.

Partial Updates

We don’t need this yet, but 1.1 also adds the call glTexSubImage2D() which lets us update just a portion of a texture image. Our ultimate goal here is to create some kind of pixel display that gets scaled and presented properly via modern graphics hardware, and this function could come in handy then if our edits are small and we want to minimize our actual data transfer.

Arrays of Data

OpenGL 1.1 also gives us some alternatives to immediate mode. A lot of geometry stays relatively fixed, so it would be nice to just load it all up in bulk and then draw it all at once, instead of going through the odd little song and dance that immediate mode requires of us. Our vertex data could look something like this, for instance:

GLfloat vertexArray[] = {
    1.0f, -1.0f, 0.0f, 1.0f,
    1.0f, 1.0f, 0.0f, 1.0f,
    -1.0f, -1.0f, 0.0f, 1.0f,
    -1.0f, 1.0f, 0.0f, 1.0f
};

GLfloat texCoordArray[] = {
    1.0f, 1.0f,
    1.0f, 0.0f,
    0.0f, 1.0f,
    0.0f, 0.0f
};

The “Vertex Arrays” feature permits this. The various kinds of data that vertices can hold each have a function and a “client state” associated with them. Functions like glVertexPointer(), glColorPointer(), and glTexCoordPointer() let us associate arrays with the current drawing state much like binding textures, and then we may turn these on and off with glEnableClientState() and glDisableClientState(). Once those are done, the actual geometry may be drawn with glDrawArrays(). The code between glBegin() and glEnd(), including those calls, is replaced with these other calls:

glVertexPointer(4, GL_FLOAT, 0, vertexArray);
glTexCoordPointer(2, GL_FLOAT, 0, texCoordArray);

glEnableClientState(GL_VERTEX_ARRAY);
glEnableClientState(GL_TEXTURE_COORD_ARRAY);

glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);

glDisableClientState(GL_VERTEX_ARRAY);
glDisableClientState(GL_TEXTURE_COORD_ARRAY);

The glWhateverPointer() functions take four arguments: the number of values per vertex, the type of the value, a “stride” value for interleaved arrays (which these aren’t so we leave it as zero), and then a pointer to the array itself. After enabling the arrays, glDrawArrays() now consumes the argument we formerly gave to glBegin() along with the starting index and the number of indices to render.

More complex geometry where vertices are repeatedly reused are likely to find the glDrawElements() function more useful than glDrawArrays(); that one accepts an array of indices to consult instead of just charging through them in order.

Interleaving our Arrays

It’s kind of inconvenient to need multiple arrays for our vertex data. It would be more self-contained—especially if we have a bunch of bits of geometry that we want to load or save as units—if each vertex had all the attribute data it needed right next to each other. Something like this:

GLfloat attribArray[] = {
    1.0f, -1.0f, 0.0f, 1.0f,  1.0f, 1.0f,
    1.0f, 1.0f, 0.0f, 1.0f,   1.0f, 0.0f,
    -1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 1.0f,
    -1.0f, 1.0f, 0.0f, 1.0f,  0.0f, 0.0f
};

Happily, we may absolutely do this. Remember that “stride” value from before? If it isn’t zero, that represents the number of bytes between vertices in our array. With this interleaving of vertex and texture coordinates, we have 6 single-precision floating-point values per vertex, and that translates out to 24 bytes. Furthermore, the texture coordinates begin four indices into the attribute array as a whole. If we define the array as above, the only other code we need to change is our pointer definitions:

glVertexPointer(4, GL_FLOAT, 24, attribArray);
glTexCoordPointer(2, GL_FLOAT, 24, &attribArray[4]);

and with this everything else works swimmingly. For particular arrangements of vertices, there’s a shortcut function glInterleaveArrays(), but frankly that list of supported formats is depressingly short and the basic pointer-assignment functions are pretty great as it is. I think I have a sense of why Direct3D 9 named its version of that the “Flexible Vertex Format,” though.

We’re using a single array of GLfloats for all our attributes, but we don’t have to do this. The various kinds of attribute pointers we use here do not have to all be of the same type, so it’s most common in industrial-scale code to have arrays like this actually be arrays of C structs. That works just as well, and in the same manner (you’ll just need to use offsetof to get your displacements).

1998: OpenGL 1.2

OpenGL 1.2 fixes a bug that we haven’t hit here, and it does so by extending the API.

It turns out that GL_CLAMP almost never actually does what you want. We’re magnifying our image with a nearest-neighbor algorithm here, but if you do bilinear scaling instead, GL_CLAMP will blend your border pixels not with copies of themselves but with some texture-wide “border color”. This is essentially never what you want, and OpenGL 1.2 adds an option named GL_CLAMP_TO_EDGE that behaves the way you’d actually expect it to.

2001: OpenGL 1.3

OpenGL 1.3 introduces multitexturing. In OpenGL 1.1, we noted that “at any given time there is only one texture, but we may have multiple other textures on deck.” As the 21st Century dawns, this stops being true. The graphics system now has multiple texture units, and each one may bind its own texture out of the same pool. glBindBuffer() and friends still work as usual, but if we wish to be explicit that we are relying only on the default texture unit, we may announce that with glActiveTexture(GL_TEXTURE0). I add some calls to that just before our texture work in both our functions, but there are no other changes in operation at this point.

GLQuake made use of this feature as well, but it did so from an SGI-specific extension that isn’t API-compatible with what OpenGL 1.3 does.

2003: OpenGL 1.5

Our initial immediate-mode rendering logic had the geometry effectively fall out of the logic of the program as it was run. The next version pulled its data from tables stored in the CPU’s own memory. The next step is to take those tables and put them in the GPU’s memory where it may access them more easily. OpenGL calls these vertex buffer objects, and they are introduced in 1.5, the last edition of OpenGL 1.x.

Vertex Buffer Objects turn out to have an API that shares a lot in common with 1.1’s texture objects. Like textures, they are fundamentally GPU resources, so we create handles to them with a dedicated function glGenBuffers() and activate them with glBindBuffer(). The GL_ARRAY_BUFFER target holds vertex information and is the one we are working with here; renderers that rely on glDrawElements() instead of glDrawArrays() will also need to bind a (different!) buffer to GL_ELEMENT_ARRAY_BUFFER to hold what was originally the array of indices. The equivalent of glTexImage2D() and glTexSubImage2D are glBufferData() and glBufferSubData(). Our initialization gets a new global variable and a new set of initialization lines:

GLuint attribBuffer;

glGenBuffers(1, &attribBuffer);
glBindBuffer(GL_ARRAY_BUFFER, attribBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(attribArray), attribArray, GL_STATIC_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);

When a GL_ARRAY_BUFFER is bound, calls to the glWhateverPointer() functions take a byte offset into this array instead of a pointer to that array. That changes the rendering code’s array initialization function to this:

glBindBuffer(GL_ARRAY_BUFFER, attribBuffer);
glVertexPointer(4, GL_FLOAT, 24, 0);
glTexCoordPointer(2, GL_FLOAT, 24, (const void *)16);

(Note the cast at the end there. The function signature still says this argument is const void * and while it’s happy to pretend that 0 is NULL it gets very bent out of shape indeed at the idea of “16” being a reasonable pointer value.)

When I did the pixel-map project for DirectX 9, its final form involved rendering the pixel map as a texture, and its rendering implementation has to juggle Direct3D’s own versions of vertex buffer objects on its own. We haven’t written the actual pixel-map logic yet, but this is the point where the sophistication of our renderer hits parity with my Direct3D original.

Resizable Windows and Aspect Correction

So far I’ve been assuming that the window is exactly the size of the texture we are displaying, or at least an integral multiple of it. This is fine for focusing on the basic rendering commands, but it does not meet what I consider the minimum bar for an accelerated 2D graphics display these days. I expect to be able to have windows of arbitrary sizes, and to rescale them by hand at realtime, and to have a “fullscreen” mode that lives in harmony with the rest of the system desktop. Most of this work is the responsibility of the framework code, but important parts of it still rest within the OpenGL-level rendering code.

I did the math for this years ago, and while that math was being done to power a vertex shader under OpenGL 3.2, the underlying rendering system is consistent all the way back to 1.0, and the math still works. In summary:

  • When the window size is changed, OpenGL must be informed of this. This is accomplished via the call glViewport(0, 0, width, height).
  • Divide the width of both the image and the window size by their respective heights to get their aspect ratios. The equations here are Ai = wi / hi for the image we’re displaying and Av = wv / hv for the window viewport we’re displaying it in.
  • When delivering the vertex data to the renderer, each of the vertices must be adjusted by a per-dimension scaling factor S. This is essentially a two-dimensional vector. If Av < Ai, then Sx is 1 and Sy = Av / Ai. Otherwise, Sy is 1 and Sx = Ai / Av.

We have an impressive array of options for how to actually carry out that vertex scaling. When delivering vertex data in immediate mode, we can simply alter the values that we send to the glVertex4f() call. Similarly, our 1.1 code that relies on glDrawArrays() can edit the arrays that it’s using as the argument to glVertexPointer(). That stops working once we bring in GPU-side vertex buffers in 1.5; we’d need to reload the buffers with glBufferData() each time the screen size changed. That’s ugly, though to be fair it’s also basically what I did when I did this in DirectX 9. My excuse there was that anything that resized the window was likely to destroy all my GPU-side resources anyway, so I might as well beat the rush and recreate the whole buffer from scratch. OpenGL (and, for that matter, every subsequent version of Direct3D) papers over all that unpleasantness for us; as far as we are concerned, anything we offer to the GPU goes where it needs to and we never have to care about its lifecycle again.

None of these options, however, are the idiomatic one. 3D objects in a scene are assumed (by OpenGL and Direct3D) to be defined in some coordinate space that’s local to that object. In order for the object to appear in the correct size, location, and orientation in the scene as a whole, and for the points in that scene to then be sensibly translated into the screen coordinates that actually get drawn to, each vertex needs to be transformed on the way to the underlying system. OpenGL 1.x manages this with a pair of matrix multiplications: a modelview matrix that manages scaling, locating and orienting the object in space, and a projection matrix that takes whatever viewable region of the world is being rendered and mapping it into that length-2 cube that is the underlying rendering space. Scaling the rectangle that we’re rendering to our window is a perfect job for the modelview matrix, and since we’re dealing with that length-2 cube directly otherwise, we may consider the projection matrix to be the identity matrix.

As OpenGL evolves, we end up with fewer and fewer support functions for managing these matrices, but we can express our desires pretty intuitively here in 1.x:

    glMatrixMode(GL_PROJECTION);
    glLoadIdentity();
    glMatrixMode(GL_MODELVIEW);
    glLoadIdentity();
    glScalef(s_x, s_y, 1.0f);

This code goes in the rendering region, just before the actual call to glDrawArrays(). The s_x and s_y variables here are computed using the formulas we provided up top.

Our First Checkpoint

We haven’t done any real encapsulation yet in the main program, so the part of the code that we’re looking at here is just a bunch of global variables and a couple of support functions. Here are our global variables as of the 1.5 edition:

GLfloat attribArray[] = {
    1.0f, -1.0f, 0.0f, 1.0f,  1.0f, 1.0f,
    1.0f, 1.0f, 0.0f, 1.0f,   1.0f, 0.0f,
    -1.0f, -1.0f, 0.0f, 1.0f, 0.0f, 1.0f,
    -1.0f, 1.0f, 0.0f, 1.0f,  0.0f, 0.0f
};

GLuint screenTexture, attribBuffer;
int currentSizeW = 0, currentSizeH = 0, requestedSizeW = 0, requestedSizeH = 0;
GLfloat a_i;

Here’s the complete initialization code, which creates and populates the texture and the vertex buffer:

int renderInit(const unsigned char *img, int w, int h)
{
    glGenTextures(1, &screenTexture);
    glActiveTexture(GL_TEXTURE0);
    glBindTexture(GL_TEXTURE_2D, screenTexture);
    glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, img);
    glBindTexture(GL_TEXTURE_2D, 0);

    glGenBuffers(1, &attribBuffer);
    glBindBuffer(GL_ARRAY_BUFFER, attribBuffer);
    glBufferData(GL_ARRAY_BUFFER, sizeof(attribArray), attribArray, GL_STATIC_DRAW);
    glBindBuffer(GL_ARRAY_BUFFER, 0);

    a_i = (GLfloat)w / (GLfloat)h;

    return 1;
}

And finally, the rendering code, complete with viewport adjustment:

void render(void)
{
    if (currentSizeW != requestedSizeW || currentSizeH != requestedSizeH) {
        currentSizeW = requestedSizeW;
        currentSizeH = requestedSizeH;
        glViewport(0, 0, currentSizeW, currentSizeH);
    }
    GLfloat a_v = (GLfloat)currentSizeW / (GLfloat)currentSizeH;
    GLfloat s_x = 1.0f, s_y = 1.0f;
    if (a_v < a_i) {
        s_y = a_v / a_i;
    } else {
        s_x = a_i / a_v;
    }

    glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
    glClear(GL_COLOR_BUFFER_BIT);
    glColor4f(1.0f, 1.0f, 1.0f, 1.0f);
    glActiveTexture(GL_TEXTURE0);

    glBindBuffer(GL_ARRAY_BUFFER, attribBuffer);
    glVertexPointer(4, GL_FLOAT, 24, 0);
    glTexCoordPointer(2, GL_FLOAT, 24, (const void *)16);
    glEnableClientState(GL_VERTEX_ARRAY);
    glEnableClientState(GL_TEXTURE_COORD_ARRAY);

    glEnable(GL_TEXTURE_2D);
    glBindTexture(GL_TEXTURE_2D, screenTexture);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST);

    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);

    glDisable(GL_TEXTURE_2D);
    glBindTexture(GL_TEXTURE_2D, 0);
    glDisableClientState(GL_VERTEX_ARRAY);
    glDisableClientState(GL_TEXTURE_COORD_ARRAY);
    glBindBuffer(GL_ARRAY_BUFFER, 0);
}

Old APIs, New Hardware

As I prepare to move on to the shader-based OpenGL versions, I do feel like I need to add an important caveat to the work we’ve done here so far. To wit, while the code I’ve written conforms to earlier versions of the standard, these programs almost certainly would not have worked until the early 2000s. As we’ve seen in the evolution of this code, older APIs aren’t so much replaced as the system gets more sophisticated as they are situated in new and more complex execution contexts. That means that most of these calls don’t explicitly have limits baked into the APIs.

This means that the actual limits that an OpenGL program faces aren’t known until runtime. One may simply attempt to set things up and see if they fail, or one may use functions from the glGet() family of functions to actually interrogate specific limits. GL_MAX_TEXTURE_SIZE is an instructive one—this is the largest number of pixels a 2D texture may have on either of its dimensions. While the specification sets minimum acceptable values for these maxima, hardware is permitted to exceed it and will cheerfully do so.

It’s a good, thing, too, because these limits are harsh and remain so for many years. For the entirety of OpenGL 1.x and 2.x, the only actual guarantee on your texture size is 64×64! It isn’t until version 3.0 in 2008 that we have this minimum raised to 1024 pixels, and it isn’t until 4.1 in 2010 where a single texture can be big enough to properly represent a 1080p display. (The 4.1 minimum is 16,384 pixels, which is pretty ridiculous, but at least should keep everyone happy for awhile.) Most of my hardware these days supports OpenGL 4.6 and as such offers these 16K textures; the Pi 400 does not, but despite only supporting OpenGL 2.1, it reports that it will cheerfully accept textures up to 4,096 pixels on a side.

One other aspect of textures in the 1990s is that they were intended to be accelerated by very primitive hardware. This manifested as an additional requirement that texture dimensions all be powers of two. I’ve cheated a little in this test program, in that my actual logo I display here is 256×256, but starting with OpenGL 2.0 in 2004 non-power-of-two textures become mandatory.

The older systems really did not expect us to be using textures as a proxy for the entire screen. We saw this with DirectX 9 as well; our first experiments with it involved creating special pixel-based structures called “surfaces”, independent of textures, and drawing them directly into the window’s framebuffer with a function named StretchRect(). Old-school OpenGL has a glDrawPixels() function that offers the sort of bulk pixel operations we’re really sort of aiming for here, but it doesn’t give the flexibility that DX9 does.

On the other hand, that StretchRect() function didn’t actually work so we really just got steered to where we wanted to go anyway.

The practical situation was also quite good; even in 2003, the year OpenGL 1.5 was released, the OpenGL backend for The Ur-Quan Masters was happily demanding multiple 1024×512 RGBA textures to represent the various screens of the display. As consumer 3D hardware grew in power, the notional minimums rapidly became forgotten relics. The power-of-two restrictions held a bit longer.

Next time: OpenGL 2 and beyond!

(EDIT, 6 Oct 2025: I got some detailed feedback about this article and the next from stb himself, particularly regarding stb_image interoperation. I have updated and clarified some parts of these articles to address parts that were unclear or inaccurate.)

联系我们 contact @ memedata.com