Blog



11 December 2013

Simulating the ZX Spectrum look using OpenGL

Not a guide to binary-complete emulation. Jeez!

A few weeks back - right after the TIGA Game Hack we attended - was the very first Sinclair ZX Spectrum oriented game jam, or very simply, the Speccy Jam. I was too busy (and knackered) to even look at entering at the time, so had to give it a miss. However, I recently had a completely free weekend...

 

Spurred on a little by Hayden Scott-Baron's recent and wonderful screenshot saturday posts, I decided to make an attempt at achieving the iconic "attribute clash" effect seen on the Speccy - using our internal tools, of course. That's C++ and OpenGL (a lot of wrapper classes), but I'll try to keep it abstract so it's of wider use.

The Attribute Overlay

I should start by making sure you know what the limitations of the Spectrum were. The resolution is 256 x 192 after taking in to account overscan for CRT TVs.

 

OMG THE COLOURS!
Image from GFXZone, I hope that's OK...

 

The way colours were handled on the Spectrum was a very unique and limiting way. The screen resolution was divided in to cells of 8 x 8 pixels (that's a 32 x 24 grid), and each of these cells could contain only two colours, and these colour had to be of the same "brightness level".

 

Each cell's colour data could be stored in 8 bits (and hence it's an 8-bit computer) - 3 for the background colour, 3 for the foreground colour, 1 for brightness and 1 more which would tell the foreground and background colours to switch at regular intervals. 6kb of colour data in total.

 

Color number Binary value BRIGHT 0 (RGB) BRIGHT 1 (RGB) Color name
0 000 #000000 #000000 black
1 001 #0000CD #0000FF blue
2 010 #CD0000 #FF0000 red
3 011 #CD00CD #FF00FF magenta
4 100 #00CD00 #00FF00 green
5 101 #00CDCD #00FFFF cyan
6 110 #CDCD00 #FFFF00 yellow
7 111 #CDCDCD #FFFFFF white

ANYWAY!

I went on a bit of an adventure, trying ~4 different approaches, but the following is the one I found to be fastest. I didn't use proper optimisation or performance-measuring tools... experimenting was more fun ! Other approaches will be at the bottom somewhere.

Best Approach

After all that binary talk, you'd have expected me to do that, right? Nope, early optimisation is evil and all that.

 

Game Variables:

FBO* m_renderTarget;
std::vector<Color* > m_attributeColoursDim;
std::vector<Color* > m_attributeColoursBright;
std::vector<unsigned char> m_attributeBlocksBG;
std::vector<unsigned char> m_attributeBlocksFG;
std::vector<bool> m_attributeBlocksSet;

static const unsigned int COLOR_BLACK   = 0;
static const unsigned int COLOR_BLUE    = 1;
static const unsigned int COLOR_RED     = 2;
static const unsigned int COLOR_MAGENTA = 3;
static const unsigned int COLOR_GREEN   = 4;
static const unsigned int COLOR_CYAN    = 5;
static const unsigned int COLOR_YELLOW  = 6;
static const unsigned int COLOR_WHITE   = 7;

On Initialisation:

// Make a new FBO. Wraps glGenFramebuffers, glGenTextures, glFramebufferTexture2D.
m_renderTarget = new FBO(256, 192);

// All the Speccy colours.
m_attributeColoursDim.push_back(new Color("#000000")); // black
m_attributeColoursDim.push_back(new Color("#0000CD")); // blue
m_attributeColoursDim.push_back(new Color("#CD0000")); // red
m_attributeColoursDim.push_back(new Color("#CD00CD")); // magenta
m_attributeColoursDim.push_back(new Color("#00CD00")); // green
m_attributeColoursDim.push_back(new Color("#00CDCD")); // cyan
m_attributeColoursDim.push_back(new Color("#CDCD00")); // yellow
m_attributeColoursDim.push_back(new Color("#CDCDCD")); // white
m_attributeColoursBright.push_back(new Color("#000000")); // black
m_attributeColoursBright.push_back(new Color("#0000FF")); // blue
m_attributeColoursBright.push_back(new Color("#FF0000")); // red
m_attributeColoursBright.push_back(new Color("#FF00FF")); // magenta
m_attributeColoursBright.push_back(new Color("#00FF00")); // green
m_attributeColoursBright.push_back(new Color("#00FFFF")); // cyan
m_attributeColoursBright.push_back(new Color("#FFFF00")); // yellow
m_attributeColoursBright.push_back(new Color("#FFFFFF")); // white

// Linear array for the attribute grid. 2d arrays can go home.
for(unsigned int i = 0; i < 768; ++i) { 
    m_attributeBlocksBG.push_back(COLOR_BLACK); 
    m_attributeBlocksFG.push_back(COLOR_WHITE); 
    m_attributeBlocksSet.push_back(true);
}

That's the grid all set up, but we haven't done anything with it yet. Let's make some helper functions first.

// Change Y here because the texture in the FBO is rendered upside-down!
void setTileColourFG(unsigned char x, unsigned char y, unsigned char color) {
    y = 23 - y;
    m_attributeBlocksFG[(y * 32) + x] = color;
}
void setTileColourBG(unsigned char x, unsigned char y, unsigned char color) {
    y = 23 - y;
    m_attributeBlocksBG[(y * 32) + x] = color;
} 
void setTileColourSet(unsigned char x, unsigned char y, bool bright) {
    y = 23 - y;
    m_attributeBlocksSet[(y * 32) + x] = bright;
}

We can now change the data structures, but goddamn, how does the rendering work? Ok, ok. I've got two functions which I call before and after I've rendered an entire scene, and another to actually render things.

void startZXRender(GameContainer* container, Renderer* r);
void stopZXRender(GameContainer* container, Renderer* r);
void render(GameContainer* container, Renderer* r);

Everything between startZXRender() and stopZXRender() gets taken in to super 8-bit mode! Everything rendered there should be white, no colours at all.

void render(GameContainer* container, Renderer* r) 
{
    startZXRender(container, r);
        m_player->render(container, r);
        m_otherObjects->renderAll(container, r);
    stopZXRender(container, r);
}

The startZXRender() function draws 768 coloured squares directly to the screen. These are the background squares and are passed to the GPU in one lump (see abstracted batch functions). I draw them from bottom to top because the data is upside-down. It's worth noting my viewport is set up so (0, 0) is the top left and (256, 192) is the bottom right. It's also worth noting there is no z-depth buffer (or it is disabled) so things render in the same order as in the code.

void startZXRender(GameContainer* container, Renderer* r)
{
    int x = 0;
    int y = 184;
    r->getBatch()->setEnabled(true);
    for(unsigned int i = 0; i < m_attributeBlocksBG.size(); ++i)
    {
        bool set = m_attributeBlocksSet.at(i);
        unsigned char ab = m_attributeBlocksBG.at(i);

        Color* c = m_attributeColoursDim.at(ab);
        if (set) {
            c = m_attributeColoursBright.at(ab);
        }
        r->setDrawColor(*c);    
        r->fillRect(x, y, 8, 8);
        x += 8;
        if (x == 256) { x = 0; y -= 8; }
    }
    r->getBatch()->render(); 
    r->getBatch()->setEnabled(false);

    m_renderTarget->bind(); 
    m_renderTarget->bind_2d(); 
}

The FBO bind() function does a few things that I've hidden away to never think about again. Obviously it binds the FBO, but it also sets the glViewport to the size of FBO (256 x 256), calls glClear with a totally transparent colour, and pushes glLoadIdentity on to the model-view matrix.

 

You should have a way of reverting to the previous viewport/matrix-stack state because you'll probably need it later. FBO bind_2d() sets up the orthographic projection to be the same size as the FBO too. This has to be done otherwise if you scale the texture the pixels go all wonky.

 

If you've not dealt with Framebuffer objects before and these words are going over the top of your head, here and here might be a good place to start.

 

One thing I learnt out of all of this is that glClear() can be used with a transparent colour. I was previously clearing to black and trying to use different blend modes and multitexturing... Doh!

 

Next up is to stop the render-to-texture stuff. FBO unbind() unbinds the FBO and sets the glViewport and orthographic projection back to the window size. Rendering to texture makes everything upside down (because that's the default in OpenGL) so again I have to render from bottom to top. That's another 768 squares but this time they are coloured and textured.

void stopZXRender(GameContainer* container, Renderer* r) {

    m_renderTarget->unbind_2d();
    m_renderTarget->unbind();

    unsigned int t = m_renderTarget->getTextureId();
    int x = 0;
    int y = 192;
    float tx = 0.0f;
    float ty = 0.25f;
    float txeach = (1.0f / 32.0f);
    float tyeach = (0.75f / 24.0f); 
    r->getBatch()->setEnabled(true);
    for(unsigned int i = 0; i < m_attributeBlocksFG.size(); ++i)
    {
        bool set = m_attributeBlocksSet.at(i);
        unsigned char ab = m_attributeBlocksFG.at(i);

        Color* c = m_attributeColoursDim.at(ab);
        if (set) {
            c = m_attributeColoursBright.at(ab);
        }
        r->setDrawColor(*c);
        r->texturedQuad(t,
            x, y, x, y-8, x+8, y, x+8, y-8,  
            tx, ty, tx, ty+tyeach, tx+txeach, ty, tx+txeach, ty+tyeach
        );

        x += 8;
        tx += txeach;
        if (x == 256) { x = 0; y -= 8; } 
        if (tx == 1.0f) { tx = 0.0f; ty += tyeach; }
    }
    r->getBatch()->render(); 
    r->getBatch()->setEnabled(false);

}

OpenGL applies a "tint" each of the vertices because we are passing colour data with the texture. All of the sprites are pure white, so the colours will tint perfectly. That's it!

 
In Pictures

1) Render 768 coloured squares -- the background grid.
The background grid!

 

2) Render the scene to the texture in the framebuffer.
Framebuffer contents, kinda.
Use only the colour white. Note the background is transparent.

 

3) Render 768 textured and coloured squares -- the foreground grid.
The foreground grid.

 

Summary

Does any of that sound bonkers? Have you done it differently and want to share your approach? Do you have any questions? Anything? Shout this way on Twitter or Facebook, or talk to us by other means.

 

Cheers! :)
- Ashley

 
Ooer, here are some Funky Vines!

Simulate a loading screen by changing grid values over time.

The "test project".

Other Approaches

I tried these first, and thankfully they didn't work at all or very well, because they'd have needed different shaders for versions of GLSL, and wouldn't have worked in our Flash export (without painfully converting to AGAL...), and so forth.

  • Screen-space pixel shader in one pass - did not work.
    • Reason: Implementation limit of 4096 (e.g., number of built-in plus user defined active uniforms components) exceeded, fragment shader uses 6152 total uniforms.
  • Screen-space pixel shader in two passes - works but very slowly.

This was a learning experience for me, and it'd be cool for other people to learn too, so in the interest of that, here's all the shader code!

 

OpenGL 3.2 GLSL 1.5 Vertex Shader:

OpenGL 3.2 GLSL 1.5 Fragment Shader:

C++ Binding code (crudely abstracted):

If you're still reading, you're crazy and you should subscribe to our Mailing List. BRILLIANT. Thank you!