VERC: Real-Time "TRON 2.0" Glow in Half-Life Last edited 4 years ago2019-04-21 12:37:17 UTC

You are viewing an older revision of this wiki page. The current revision may be more detailed and up-to-date. Click here to see the current revision of this page.
I'm sure many of us have seen screenshots of TRON 2.0 and noticed the glowing effect that's overlaid onto everything bright. Well, it's possible to have the same effect in Half-Life, too, using vertex and pixel shaders with OpenGL. This article will walk you through the steps in getting it working, and will suggest some alternative effects that could be done using the system we set up here.

To whet your appetite, here's a comparison of scenes before and after the glow is applied. The images on the left are without the effect, and the images on the right are with the effect:
User posted image
Also, here's an example of a "TRON 2.0"-esque map with four bars of bright colour without the glow and with the glow:
Without the glowWithout the glow
With the glowWith the glow
Due to the way that this effect is being done, it will only work if the user is using OpenGL as their rendering API. In addition, their graphics card must have support for 4 texture units and fairly primitive vertex and pixel shaders. The effect was tested on an NVIDIA GeForce4 Ti 4200, and should work on the equivalent hardware of ATI.

Now, onward. To do the grunt work of the effect, we will be using vertex and pixel shaders written in Cg (C for graphics), a high-level shading language developed by NVIDIA. You'll need the Cg Toolkit to be able to compile and run the effects. Make sure you configure Visual C++ to look for include and library files in the appropriate directories of the Cg Toolkit installation.

First things first, we'll create the vertex and pixel shaders that we'll be using to do the effects. Create a folder within your mod directory called "cgshaders" - we'll be putting the shaders inside this folder. Download the 6 .cg files from here and place them inside this folder. They do the following operations: Ok, now that the shaders are in place, it's time to do the programming. Firstly, you'll need to add the "opengl32.lib", "cg.lib" and "cgGL.lib" libraries into the project's link settings (Project->Settings->Link->Object/library modules).

Now, open up the client DLL project and go into tri.cpp. You'll need to add the following include directives:
#include <windows.h>
#include <gl/gl.h>
#include <gl/glext.h>
#include <cg/cg.h>
#include <cg/cgGL.h>
Then, after the extern "C" { ... } block, add the following include and global variables:
#include "r_studioint.h"

extern engine_studio_api_t IEngineStudio;

bool g_bInitialised = false;

PFNGLACTIVETEXTUREARBPROC glActiveTextureARB = NULL;
PFNGLMULTITEXCOORD2FARBPROC glMultiTexCoord2fARB = NULL;

CGcontext g_cgContext;
CGprofile g_cgVertProfile;
CGprofile g_cgFragProfile;

CGprogram g_cgVP_GlowDarken;
CGprogram g_cgFP_GlowDarken;

CGprogram g_cgVP_GlowBlur;
CGprogram g_cgFP_GlowBlur;

CGprogram g_cgVP_GlowCombine;
CGprogram g_cgFP_GlowCombine;

CGparameter g_cgpVP0_ModelViewMatrix;
CGparameter g_cgpVP1_ModelViewMatrix;
CGparameter g_cgpVP1_XOffset;
CGparameter g_cgpVP1_YOffset;
CGparameter g_cgpVP2_ModelViewMatrix;

unsigned int g_uiSceneTex;
unsigned int g_uiBlurTex;
Don't worry if you don't have a clue what they're going to store - they'll all be explained in due course. Before we dive into the initialization code, add this utility function code after the globals (it'll make the initialization code less messy):
bool LoadProgram(CGprogram* pDest, CGprofile profile, const char* szFile)
{
     const char* szGameDir = gEngfuncs.pfnGetGameDirectory();
     char file[512];
     sprintf(file, "%s/%s", szGameDir, szFile);

     *pDest = cgCreateProgramFromFile(g_cgContext, CG_SOURCE, file, profile, "main", 0);
     if (!(*pDest)) {
          MessageBox(NULL, cgGetErrorString(cgGetError()), NULL, NULL);
          return false;
     }

     cgGLLoadProgram(*pDest);

     return true;
}
All this does is apply a full path to the specified file (with szFile being relative to the modification's directory), then load that file as a Cg program using the supplied profile (which will be either a vertex or pixel shader profile). The program is then loaded into OpenGL if Cg successfully compiles it.

Now we're ready to create the initialization function. I'll give the code in sections and describe what they're doing as we go along.
void InitScreenGlow(void)
{
     if (IEngineStudio.IsHardware() != 1)
          return;

     gEngfuncs.pfnRegisterVariable("cg_blur_steps", "4", 0);
We check whether we're running in OGL mode, and bail out if we're not. Then, the first thing to do is register a CVAR that we'll use to control whether blurring is enabled and if so, how much the image will be blurred to create the glow. The value of this CVAR is how many times the Gaussian blur will be applied to the 'intensified' image of the scene.
// OPENGL EXTENSION LOADING

glActiveTextureARB = (PFNGLACTIVETEXTUREARBPROC)wglGetProcAddress("glActiveTextureARB");
Next, we get the function pointer for the function that controls multitexturing (using the GL_ARB_multitexure OpenGL extension). It enables us to set which texture unit we're changing the properties of, such as whether texturing is enabled on that unit and which texture is active.
// TEXTURE CREATION

unsigned char* pBlankTex = new unsigned char[ScreenWidth*ScreenHeight*3];
memset(pBlankTex, 0, ScreenWidth*ScreenHeight*3);

glGenTextures(1, &g;_uiSceneTex);
glBindTexture(GL_TEXTURE_RECTANGLE_NV, g_uiSceneTex);
glTexParameteri(GL_TEXTURE_RECTANGLE_NV, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_RECTANGLE_NV, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexImage2D(GL_TEXTURE_RECTANGLE_NV, 0, GL_RGB8, ScreenWidth, ScreenHeight, 0, GL_RGB8, GL_UNSIGNED_BYTE, pBlankTex);

glGenTextures(1, &g;_uiBlurTex);
glBindTexture(GL_TEXTURE_RECTANGLE_NV, g_uiBlurTex);
glTexParameteri(GL_TEXTURE_RECTANGLE_NV, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_RECTANGLE_NV, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexImage2D(GL_TEXTURE_RECTANGLE_NV, 0, GL_RGB8, ScreenWidth/2, ScreenHeight/2, 0, GL_RGB8, GL_UNSIGNED_BYTE, pBlankTex);

delete[] pBlankTex;

g_bInitialised = true;
We now need to set up two textures - one to store the unadulterated image of the rendered map, and the other to contain the blurred, 'glow' version that will be combined with the first texture. ScreenWidth and ScreenHeight are two macros defined elsewhere in the HLSDK that give the current width and height of the screen. Notice that we're using a different type of texture to the normal GL_TEXTURE_2D type; instead, we're making use of the GL_NV_texture_rectangle OpenGL extension to enable us to have non-power-of-2 dimensions to our textures. This extension is supported on nearly all graphics cards that people use these days.

Also notice that the blur texture is half the dimensions of the scene texture, in order to save on fillrate. The effect does not suffer, as we are blurring the image anyway - we don't need exact details.
// CG INITIALISATION

g_cgContext = cgCreateContext();
if (!g_cgContext) {
     MessageBox(NULL, "Couldn't make Cg context", NULL, NULL);
     return;
}
A Cg context is needed to enable us to load Cg programs, so the next stage is to create one (and error out if we can't).
// VERTEX PROFILE

g_cgVertProfile = cgGLGetLatestProfile(CG_GL_VERTEX);
if (g_cgVertProfile == CG_PROFILE_UNKNOWN) {
     MessageBox(NULL, "Couldn't fetch valid VP profile", NULL, NULL);
     return;
}

cgGLSetOptimalOptions(g_cgVertProfile);
After creating the Cg context, we ask Cg to retrieve the most advanced vertex program profile available on the graphics card. These determine exactly what functionality can be achieved in the vertex shaders, and tells Cg how it should compile them when we load them. If we can't find a decent profile, then we error out.
// VP LOADING

if (!LoadProgram(&g;_cgVP_GlowDarken, g_cgVertProfile, "cgprograms/glow_darken_vp.cg"))
     return;

if (!LoadProgram(&g;_cgVP_GlowBlur, g_cgVertProfile, "cgprograms/glow_blur_vp.cg"))
     return;

if (!LoadProgram(&g;_cgVP_GlowCombine, g_cgVertProfile, "cgprograms/glow_combine_vp.cg"))
     return;
Now that the vertex profile is set up, we can proceed to load and compile the vertex programs that we need. There are three vertex programs, as described earlier in this article. We use the LoadProgram utility function that we defined earlier to make the process easier.
// VP PARAM GRABBING

g_cgpVP0_ModelViewMatrix = cgGetNamedParameter(g_cgVP_GlowDarken, "ModelViewProj");

g_cgpVP1_ModelViewMatrix = cgGetNamedParameter(g_cgVP_GlowBlur, "ModelViewProj");
g_cgpVP1_XOffset = cgGetNamedParameter(g_cgVP_GlowBlur, "XOffset");
g_cgpVP1_YOffset = cgGetNamedParameter(g_cgVP_GlowBlur, "YOffset");

g_cgpVP2_ModelViewMatrix = cgGetNamedParameter(g_cgVP_GlowCombine, "ModelViewProj");
The final stage for loading the vertex programs is to grab handles to the uniform parameters that we need to specify in the vertex programs. Each program has a 4x4 matrix called "ModelViewProj" (which we set to the projection matrix multiplied by the modelview matrix when we come to use the program), and the second program also has two scalars, "XOffset" and "YOffset", which specify how much we should offset the source texture in each unit to do the blurring. (Look at the vertex program to see how it's used.)
// FRAGMENT PROFILE

g_cgFragProfile = cgGLGetLatestProfile(CG_GL_FRAGMENT);
if (g_cgFragProfile == CG_PROFILE_UNKNOWN) {
     MessageBox(NULL, "Couldn't fetch valid FP profile", NULL, NULL);
     return;
}

cgGLSetOptimalOptions(g_cgFragProfile);
Now that the vertex programs are loaded, it's time to do the same with the fragment programs (also known as pixel shaders in Direct3D terminology). We do the same for the fragment profile as we did for the vertex profile - try to find out what functionality is available, and if there's nothing useful, then error out.
     // FP LOADING

     if (!LoadProgram(&g;_cgFP_GlowDarken, g_cgFragProfile, "cgprograms/glow_darken_fp.cg"))
          return;

     if (!LoadProgram(&g;_cgFP_GlowBlur, g_cgFragProfile, "cgprograms/glow_blur_fp.cg"))
          return;

     if (!LoadProgram(&g;_cgFP_GlowCombine, g_cgFragProfile, "cgprograms/glow_combine_fp.cg"))
          return;
}
The last part of the function loads the fragment programs themselves, again using the utility function LoadProgram that we created earlier. There are no uniform parameters that we need to specify to the fragment programs, so once we've loaded them, we're done with all the initialization.

Now that's done, we can move onto the rendering functionality that is called every frame to actually do the glow effect. Firstly, a couple more utility functions to make our life a bit easier in the rendering function itself. The next function we'll add is one that simply draws a quad with dimensions of 1x1 across the screen:
void DrawQuad(int width, int height)
{
     glBegin(GL_QUADS);

     glTexCoord2f(0,0);
     glVertex3f(0, 1, -1);
     glTexCoord2f(0,height);
     glVertex3f(0, 0, -1);
     glTexCoord2f(width,height);
     glVertex3f(1, 0, -1);
     glTexCoord2f(width,0);
     glVertex3f(1, 1, -1);

     glEnd();
}
For those who have used a rendering API such as OpenGL or Direct3D before, the texture coordinates may look a little different to the normal [0,1] range. This is due to the type of texture that will be used to store the image of the scene - instead of a regular 2D texture, we're using a rectangular texture via the GL_NV_texture_rectangle (or GL_EXT_texture_rectangle) OpenGL extensions, as mentioned earlier. They allow dimensions that're not powers of two, but texture coordinates have to be specified in the [0,width], [0,height] ranges rather than [0,1].

The other utility function we'll add is one that performs a blur pass:
void DoBlur(unsigned int uiSrcTex, unsigned int uiTargetTex, int srcTexWidth, int srcTexHeight, int destTexWidth, int destTexHeight, float xofs, float yofs)
{
     cgGLBindProgram(g_cgVP_GlowBlur);
     cgGLBindProgram(g_cgFP_GlowBlur);

     glActiveTextureARB(GL_TEXTURE0_ARB);
     glEnable(GL_TEXTURE_RECTANGLE_NV);
     glBindTexture(GL_TEXTURE_RECTANGLE_NV, uiSrcTex);

     glActiveTextureARB(GL_TEXTURE1_ARB);
     glEnable(GL_TEXTURE_RECTANGLE_NV);
     glBindTexture(GL_TEXTURE_RECTANGLE_NV, uiSrcTex);

     glActiveTextureARB(GL_TEXTURE2_ARB);
     glEnable(GL_TEXTURE_RECTANGLE_NV);
     glBindTexture(GL_TEXTURE_RECTANGLE_NV, uiSrcTex);

     glActiveTextureARB(GL_TEXTURE3_ARB);
     glEnable(GL_TEXTURE_RECTANGLE_NV);
     glBindTexture(GL_TEXTURE_RECTANGLE_NV, uiSrcTex);

     cgGLSetParameter1f(g_cgpVP1_XOffset, xofs);
     cgGLSetParameter1f(g_cgpVP1_YOffset, yofs);

     glViewport(0, 0, destTexWidth, destTexHeight);

     DrawQuad(srcTexWidth, srcTexHeight);

     glBindTexture(GL_TEXTURE_RECTANGLE_NV, uiTargetTex);
     glCopyTexImage2D(GL_TEXTURE_RECTANGLE_NV, 0, GL_RGB, 0, 0, destTexWidth, destTexHeight, 0);
}
The first thing the function does is to activate the blurring vertex and fragment programs, so that any rendering from this point on goes through them. The next step is to bind the source texture to four texture units (so that the fragment program can sample the texture four times in four different places). The "XOffset" and "YOffset" uniform parameters of the blur vertex program are set, and the viewport is restricted to a rectangle the size of the destination texture's width and height. A quad is drawn covering the screen, with the texture coordinates such that all of the source texture is mapped onto the quad. Then, the target texture is bound, and the contents of the frame buffer is copied into the target texture.

How this function is used will become apparant when we go through the function that does the actual effect, which is what we need to specify next. As for the initialization function, I'll go through it step-by-step, explaining as we go.
void RenderScreenGlow(void)
{
     if (IEngineStudio.IsHardware() != 1)
          return;

     if (!g_bInitialised)
          InitScreenGlow();

     if ((int)gEngfuncs.pfnGetCvarFloat("cg_blur_steps") == 0)
          return;
We see if we're in OGL mode, and if we're not, then bail out. Then, we check to see if we've done the initialization at all, and if not, execute the initialization function. Then, we check to see whether the "cg_blur_steps" CVAR (which is what we use to control how much blurring is done to the glow texture) is set to 0 - if it is, then we bail out, because no glow should be applied.
// STEP 1: Grab the screen and put it into a texture

glActiveTextureARB(GL_TEXTURE0_ARB);
glEnable(GL_TEXTURE_RECTANGLE_NV);

glBindTexture(GL_TEXTURE_RECTANGLE_NV, g_uiSceneTex);
glCopyTexImage2D(GL_TEXTURE_RECTANGLE_NV, 0, GL_RGB, 0, 0, ScreenWidth, ScreenHeight, 0);
The first thing to do is to take the current contents of the framebuffer (which contains the map rendered as it would normally be) and store it in the scene texture, so that we can use it as a source to operate on, and so that we have it stored so that we can combine the glow with it in the final step of the rendering.
// STEP 2: Set up an orthogonal projection

glMatrixMode(GL_MODELVIEW);
glPushMatrix();
glLoadIdentity();

glMatrixMode(GL_PROJECTION);
glPushMatrix();
glLoadIdentity();
glOrtho(0, 1, 1, 0, 0.1, 100);

glColor3f(1,1,1);
We then set up an orthogonal projection. Simply put, an orthogonal projection is something that turns the screen into a 2D plane, giving depth no meaning. We set it so that the top-left is at (0,0) and the bottom-right is at (1,1) (so our DrawQuad utility function draws the quad across the entire screen). We also set the modelview matrix to the identity matrix (meaning that there won't be any rotation or translation of vertices), and set the current colour to white to avoid any weird blending from the colour being off-white.
// STEP 3: Initialize Cg programs and parameters for darkening mid to dark areas of the scene

cgGLEnableProfile(g_cgVertProfile);
cgGLEnableProfile(g_cgFragProfile);

cgGLBindProgram(g_cgVP_GlowDarken);
cgGLBindProgram(g_cgFP_GlowDarken);

cgGLSetStateMatrixParameter(g_cgpVP0_ModelViewMatrix, CG_GL_MODELVIEW_PROJECTION_MATRIX, CG_GL_MATRIX_IDENTITY);
We enable the vertex and fragment profiles so that vertex and fragment programs will be run, rather than using OpenGL's fixed-function pipeline. The darkening programs are then loaded (see earlier for an explanation of what they do) and the "ModelViewMatrix" parameter of the vertex program is set to the required OpenGL matrix.
// STEP 4: Render the current scene texture to a new, lower-res texture, darkening non-bright areas of the scene

glViewport(0, 0, ScreenWidth/2, ScreenHeight/2);

glActiveTextureARB(GL_TEXTURE0_ARB);
glBindTexture(GL_TEXTURE_RECTANGLE_NV, g_uiSceneTex);

DrawQuad(ScreenWidth, ScreenHeight);

glBindTexture(GL_TEXTURE_RECTANGLE_NV, g_uiBlurTex);
glCopyTexImage2D(GL_TEXTURE_RECTANGLE_NV, 0, GL_RGB, 0, 0, ScreenWidth/2, ScreenHeight/2, 0);
The viewport is set to half the screen size (as the blur texture is half the screen size), and the scene texture is bound to the first texture unit. A quad is then drawn across the entire screen with the scene texture on it, and this is copied to the blur texture. The end result of this process is to have the original scene image in the blur texture at half the resolution and with dark to medium areas of colour made even darker.
// STEP 5: Initialise Cg programs and parameters for blurring

cgGLBindProgram(g_cgVP_GlowBlur);
cgGLBindProgram(g_cgFP_GlowBlur);

cgGLSetStateMatrixParameter(g_cgpVP1_ModelViewMatrix, CG_GL_MODELVIEW_PROJECTION_MATRIX, CG_GL_MATRIX_IDENTITY);
The next step is to enable the blurring vertex and fragment programs and set the "ModelViewMatrix" parameter of the vertex program, as per the darkening vertex program earlier.
// STEP 6: Apply blur

int iNumBlurSteps = (int)gEngfuncs.pfnGetCvarFloat("cg_blur_steps");
for (int i = 0; i < iNumBlurSteps; i++) {
     DoBlur(g_uiBlurTex, g_uiBlurTex, ScreenWidth/2, ScreenHeight/2, ScreenWidth/2, ScreenHeight/2, 1, 0);
     DoBlur(g_uiBlurTex, g_uiBlurTex, ScreenWidth/2, ScreenHeight/2, ScreenWidth/2, ScreenHeight/2, 0, 1);
}
This stage is the one that does the grunt-work of blurring the darkened image of the scene to generate the glow that will be applied. We first blur horizontally, then blur the result of that vertically to give a Gaussian blur, and repeat the process however many times specified in the "cg_blur_steps" CVAR. The end result of this is a blurred version of the darkened image of the scene, with the amount of blurriness dependent on what "cg_blur_steps" is set to.
// STEP 7: Set up Cg for combining blurred glow with original scene

cgGLBindProgram(g_cgVP_GlowCombine);
cgGLBindProgram(g_cgFP_GlowCombine);

cgGLSetStateMatrixParameter(g_cgpVP2_ModelViewMatrix, CG_GL_MODELVIEW_PROJECTION_MATRIX, CG_GL_MATRIX_IDENTITY);

glActiveTextureARB(GL_TEXTURE0_ARB);
glEnable(GL_TEXTURE_RECTANGLE_NV);
glBindTexture(GL_TEXTURE_RECTANGLE_NV, g_uiSceneTex);

glActiveTextureARB(GL_TEXTURE1_ARB);
glEnable(GL_TEXTURE_RECTANGLE_NV);
glBindTexture(GL_TEXTURE_RECTANGLE_NV, g_uiBlurTex);
Now that we have our glow texture, all that's left is to combine it with the original scene. We enable the combining vertex and fragment programs and set the "ModelViewMatrix" parameter of the vertex program, like we did with the other two. The original image of the scene is bound to the first texture unit, and the darkened, blurred, lower-resolution version is bound to the second unit.
// STEP 8: Do the combination, rendering to the screen without grabbing it to a texture

glViewport(0, 0, ScreenWidth, ScreenHeight);

DrawQuad(ScreenWidth/2, ScreenHeight/2);
We then render a quad across the entire screen (after setting the viewport to cover the whole frame buffer), which will be processed with the vertex and fragment programs to give the final image of the scene in the framebuffer. We don't need to store it in a texture, so we just leave it on-screen.
     // STEP 9: Restore the original projection and modelview matrices and disable rectangular textures on all units

     glMatrixMode(GL_PROJECTION);
     glPopMatrix();

     glMatrixMode(GL_MODELVIEW);
     glPopMatrix();

     cgGLDisableProfile(g_cgVertProfile);
     cgGLDisableProfile(g_cgFragProfile);

     glActiveTextureARB(GL_TEXTURE0_ARB);
     glDisable(GL_TEXTURE_RECTANGLE_NV);

     glActiveTextureARB(GL_TEXTURE1_ARB);
     glDisable(GL_TEXTURE_RECTANGLE_NV);

     glActiveTextureARB(GL_TEXTURE2_ARB);
     glDisable(GL_TEXTURE_RECTANGLE_NV);

     glActiveTextureARB(GL_TEXTURE3_ARB);
     glDisable(GL_TEXTURE_RECTANGLE_NV);

     glActiveTextureARB(GL_TEXTURE0_ARB);
}
The final step is to restore the original projection and modelview matrices, disable the use of vertex and fragment programs and disable rectangle textures on all four texture units.

All that's left now is to call the function from the right place. There are a few places that we can do it, but the only place where everything in the scene will get blurred is if we place it just before the HUD is rendered. Open up cdll_int.cpp and add the following just before the InitInput prototype:
// IMAGE-SPACE GLOW
extern void InitScreenGlow(void);
extern void RenderScreenGlow(void);
Then, find the implementation of HUD_Redraw and add this just before gHUD.Redraw is called:
// IMAGE-SPACE GLOW
RenderScreenGlow();
If you compile and run, you should get the glow effect as desired. However, if you change the screen resolution, the textures don't resize as they should. So, find the implementation of HUD_VidInit and add this after VGui_Startup is called:
// IMAGE-SPACE GLOW
InitScreenGlow();
This will make it re-generate the textures when the screen resolution is changed. The glow effect is now (finally) complete.

The multi-pass system employed here is suitable for a variety of effects. Here are some suggestions for other effects that could be done:
This article was originally published on the Valve Editing Resource Collective (VERC).
TWHL only archives articles from defunct websites. For more information on TWHL's archiving efforts, please visit the TWHL Archiving Project page.

Comments

You must log in to post a comment. You can login or register a new account.