Image 1 — How i create a working OpenGL 3.3 renderer with normal maps, Blinn-Phong specular, and post-fx with no C++ knowledge and no source code, using an AI coding assistant
Image 2 — How i create a working OpenGL 3.3 renderer with normal maps, Blinn-Phong specular, and post-fx with no C++ knowledge and no source code, using an AI coding assistant
Image 3 — How i create a working OpenGL 3.3 renderer with normal maps, Blinn-Phong specular, and post-fx with no C++ knowledge and no source code, using an AI coding assistant
Image 4 — How i create a working OpenGL 3.3 renderer with normal maps, Blinn-Phong specular, and post-fx with no C++ knowledge and no source code, using an AI coding assistant
Image 5 — How i create a working OpenGL 3.3 renderer with normal maps, Blinn-Phong specular, and post-fx with no C++ knowledge and no source code, using an AI coding assistant
▲ 12 r/GraphicsProgramming+1 crossposts

How i create a working OpenGL 3.3 renderer with normal maps, Blinn-Phong specular, and post-fx with no C++ knowledge and no source code, using an AI coding assistant

Unreal Tournament 2004 is an undisputed classic. But graphically it's frozen in the era of Direct3D 8 and flat textures — the Fixed-Function Pipeline. I always wanted to see what the legendary Unreal Engine 2 could look like with proper normal maps and honest Blinn-Phong specular, something closer to Unreal Tournament 3.

1 - diffuse only, 2 - normal, 3 - Blinn-Phong specular

In the fan and modding community there are plenty of failed attempts to bolt normal maps onto UT2004, but it was always considered impossible — no source code, fundamental engine incompatibility with modern techniques, and the sheer complexity of low-level graphics development. Then AI and agentic coding tools changed the picture.

After OldUnreal released their patch moving the game to full 64-bit and adopting OpenGL 3.3 with broad compatibility across both vintage and modern hardware, I finally had a real foothold for the experiment.

There were a few obvious obstacles going in:

I don't know C++. Not its idioms, not its syntax — I can barely read someone else's C++ code, let alone write it.

I had no game source code. (Yes, leaks from Unreal Championship 2 and Unreal Warfare exist, but the codebase differs substantially from UT2004.)

Zero knowledge of the engine's internal architecture or its quirks.

On top of that, buying a Claude Code or Codex subscription wasn't an option. I ended up working with Gemini Flash through the command-line Antigravity CLI — the cost of entry being roughly $6/year.

Still, after three months of constant experimentation, reverse engineering, and hundreds of Access Violation crashes, I managed to write a native DLL renderer module — ENDrv.dll. Here's the story of how it was done: through Antigravity CLI, UE2 architecture hacks, a lot of patience, and more sleepless nights than I'd like to count.

---

Stage 1. Flying Blind: Reconstructing the VTable and Getting the First Frame

Any renderer for Unreal Engine 2 is an external dynamic library that must implement the engine's internal URenderDevice interface. In plain terms, the renderer DLL is the bridge between the game engine and the GPU. The engine communicates with the library through virtual function tables — VTables.

In C++, the order of functions in a VTable is critical down to the byte. If you're off by even four bytes on a single function's offset, instead of calling the frame-draw routine the game jumps into a random region of memory and crashes instantly.

[ UE2 Engine ]  --->  ( VTable: offset +0x18 )  --->  [ ENDrv.dll ]
                +--       Off by 4 bytes?       ---> CRASH (Access Violation)

The first two weeks were pure guesswork: running UT2004 binaries through a disassembler, studying the UC2 and Unreal Warfare source leaks, trying to work out which functions UT2004 expected and at exactly which offsets, rebuilding the library and launching the game every five to ten minutes, logging crashes, and nudging offsets based on the fault addresses.

The game launched for the first time to a solid black screen without crashing — only after I managed to correctly guess six of the key base functions out of 32 (at that point I could only confirm 14 functions with any confidence). No image yet, but the DLL was accepted by the engine. That was the first real breakthrough: not only did the game stop crashing, but you could hear it working — clicking through menus, launching a match "blind," and hearing bots run and shoot each other. It was alive.

The next few days went into establishing vertex streaming and getting any geometry to appear on screen in wireframe mode. The stub functions that were just returning True or Null had to become real implementations. Things were complicated further by the fact that the engine was designed around DirectX, while OpenGL has its own coordinate system conventions and state-management model. At first all I got was a mess of random lines, but after dozens of iterations the DLL finally produced a correct wireframe.

---

Stage 2. From Fixed-Function to GLSL: Materials, Combiners, and Static Lighting

The original UT2004 handled materials through the old GPU state machine — the Fixed-Function Pipeline with its Combiners, Shaders, and FinalBlends. Modern OpenGL 3.3 Core Profile has no fixed pipeline at all: everything has to be written in GLSL shaders.

About four to six weeks went into reconstructing material logic from scratch. The UC2 and Unreal Warfare sources were nearly useless here — worse than useless, actually, because the AI kept getting confused by them. At times I had to pipe every agent response through a second AI just to sanity-check the logic and catch hallucinations. Eventually I decided to ignore the foreign source code entirely and work everything out independently.

The hard part wasn't "just drawing a texture." The UE2 material system is a full node graph. A single UShader material can have separate slots for Diffuse, Opacity, Specular, SpecMask, SelfIllumination, and SelfIllumMask — each of which can itself be another material. The Diffuse slot might contain a UCombiner blending node that has Material1, Material2, and a Mask, each of which can in turn be another Combiner, a texture with animated UVs (TexPanner, TexRotator, TexScaler), or a cube map. All of that had to be unrolled into GLSL, which doesn't support recursion or dynamic loops from that era — only a linear graph with a fixed number of steps.

The solution was a "combiner register" system: before the main color calculation, the shader sequentially evaluates up to 32 nodes and stores the results in an array. Each node knows the indices of its inputs (a texture slot or another register), the blend operation (Multiply, Add, AlphaBlend, Modulate, etc.), and its mask. On top of that: 16 texture slots with independent UV transform matrices, cube map reflection support, projective textures (Projector), animated panoramas, and FinalBlend mode with alpha testing. Near the end of the texture work I found the 32-bit game source code — but it wasn't much help since the original was entirely tied to Direct3D 8 / Fixed-Function. It did help fix a couple of nasty logic bugs involving complex layered textures, alpha-channel materials, and depth buffer issues.

One example of a creative workaround: UTexture field offsets are unknown without source code, so the code locates them at runtime by scanning memory and checking logical conditions. Once found, they're cached permanently.

Post-processing was integrated in about a week after that with almost no friction, since screen-space effects (Depth of Field, Bloom, Chromatic Aberration) are fairly straightforward to write in GLSL and have little dependency on engine internals.

Bloom on Bio Rifle's globs

---

Stage 3. The Hardest Part: Normal Maps, BSP, and Optimization

UShader with specular and normal map on BSP geometry

The whole point of the project was proper PBR-style lighting and normal maps. And this turned out to be even harder than the original VTable guesswork.

For normal mapping to work, the renderer needs per-polygon tangent and bitangent vectors from the engine — and the engine simply doesn't provide them. Getting the effect into the shader was actually quick: screen-space derivatives (dFdx / dFdy) let you compute a tangent basis directly in the fragment shader, eliminating the need to change the game's vertex format. But the performance was catastrophic: a couple dozen meshes with normal maps in the frame would bring the game to its knees.

The core problem: all the static lighting in the game (there's almost no dynamic lighting at all) is baked into the level at build time — texture color and brightness are modulated by lightmap textures and per-vertex color. Roughly speaking, it's like applying a gradient to a texture in Photoshop's Multiply blending mode. The renderer doesn't "see" any light sources at all — it just draws textures onto polygons. But since the DLL is a native module running inside the game process, it has direct access to everything happening under the engine's hood: light sources can be found and their parameters read directly.

Recalculating every light source per pixel every frame for all geometry was too expensive — especially since the engine's architecture hard-limits it to a single CPU core. A deep caching strategy was necessary: all geometry preserves its original static lighting (lightmaps and vertex colors) baked by the map authors 25 years ago. The dynamic per-pixel GLSL calculation only handles the normal map delta and the Blinn-Phong specular highlight.

Final color = (Lightmap * Diffuse) + (NormalMap_Affect * DirectLight) + BlinnPhong_Specular

Where NormalMap_Affect isn't the full light value, but only the "uplift" from the surface relief — how much the normal direction deviates from flat geometry. The lightmap stays as the foundation: the 25-year-old author lighting doesn't go anywhere, it just gets a relief "cap" on top.

Even that wasn't enough for acceptable framerates: with a few hundred meshes carrying normal maps on a level, FPS would collapse to 10-20. The entire pipeline needed its own caching layer. In effect, on top of the already-baked static lighting, we additionally bake normal map data during the first few frames of a map load.

The BSP Problem

If caching for StaticMesh geometry came together relatively quickly, BSP wall surfaces fought back with everything they had. Unreal Engine 2's architecture slices BSP surfaces into dynamic nodes during the frame itself. The engine resisted caching at every turn: texture coordinates would drift, caches would invalidate every frame, and the normal map and specular effects would flicker in and out alongside a full zoo of visual artifacts — or an FPS collapse into single digits. I had to roll back to week-old backups several times and redesign the architecture from scratch. The problem was eventually solved by building a light-source state hash table and an array of 128 static-dynamic lighting slots directly in the shader, tied to the geometry streaming.

---

Just shipping the renderer upgrade wasn't enough without a proper visual showcase, so I ported two maps from UT3 to UT2004. Compared to building the renderer itself, this was a walk in the park. The UT3 maps were chosen deliberately: they have clean UV unwraps and high-quality next-gen normal maps — exactly what was needed for the demo. The BSP geometry, static meshes, their properties, and placement were ported in five to ten minutes with a couple of CLI prompts. The meshes were converted from pskx to ASE format using a 3ds Max script that the AI assembled in about five minutes, after reading the UModel documentation itself and extracting everything needed into a separate folder. Importing models and textures into UnrealED went the same way. The only thing that actually required real hands-on effort was assembling the shaders in the editor. Two evenings later there was a near-complete port of both maps. The goal was never full playability — just a great-looking screenshot — so missing lifts and scripted events will stay missing. If you're interested in the game and these maps, they're definitely finishable, and stripped-down versions without normal maps for the original renderer would be easy to put together.

---

In last version i add support for MSAA 2/4/8, Anisotropic filtering, settings for draw distance normal maps, restore emitters rendering, improve normal maps stability and quality.

Testing dual normal maps for water shader

Results

Three months of work. No C++ knowledge going in. No game source code. The result is ENDrv — a working OpenGL 3.3 renderer for Unreal Tournament 2004 that runs on original unmodified maps and brings proper surface lighting to a 22-year-old engine.

What it actually does:

- True per-pixel Blinn-Phong specular and normal mapping via screen-space derivatives — no vertex format changes, no engine modification required.

- Dual Normal Map support for convincing animated water surfaces.

- Post-processing pipeline: Depth of Field, Bloom, and Chromatic Aberration — Bloom works on many original maps with zero asset changes.

- Playable framerates on original content, thanks to a multi-level OpenGL state caching system and a hybrid lighting model that layers dynamic per-pixel calculation on top of the original baked lightmaps.

Modern AI in the hands of someone with a working understanding of 3D graphics, basic engine architecture, and a large enough reserve of stubbornness can compensate for a complete lack of knowledge of any specific programming language.

---

tl;dr: Spent three months reverse-engineering UT2004's renderer interface with no C++ knowledge and no source code, using an AI coding assistant ($6/year). The result is a working OpenGL 3.3 renderer with normal maps, Blinn-Phong specular, and post-processing effects that runs on original unmodified maps.

u/SoMuchCo — 1 day ago