Dit artikel onderzoekt de technische optimalisaties die het mogelijk maakten om het originele SimCity te draaien op hardware met een kloksnelheid van slechts 16 MHz.
De kern van deze optimalisatie, geanalyseerd via de open-source code van Micropolis, is het concept van frame-distributie. In plaats van alle simulatiesystemen (zoals verkeer, brand en criminaliteit) in elk frame bij te werken, spreidt het spel deze taken over 16 verschillende frames. Hierdoor wordt de CPU-belasting per frame drastisch verlaagd zonder dat de speler dit merkbaar merkt in de gameplay.
De auteur stelt dat dit principe nog steeds breed toepasbaar is in moderne game-development. Voorbeelden hiervan zijn:
- Assetto Corsa: Waarbij reflecties (cubemaps) niet in één keer, maar in delen per frame worden bijgewerkt.
- Unreal Engine (Lumen): Waarbij verlichting niet in elk frame volledig opnieuw wordt berekend.
De belangrijkste les is dat ontwikkelaars kritisch moeten kijken naar welke code daadwerkelijk in elk frame moet worden uitgevoerd.
Hoe je een city builder draait op 16 MHz
Wanneer men denkt aan indrukwekkende technische prestaties in retrogames, wijzen de meeste mensen naar grafische mijlpalen die zijn gezet door actiegames zoals Doom of Quake. Dat is terecht, maar ik ben persoonlijk veel meer onder de indruk van de vroege voorbeelden van het city builder-genre. Het simuleren van volledige economieën op grote schaal met een fractie van de huidige rekenkracht lijkt onmogelijk, zeker nu moderne builder-games zelfs op moderne machines worstelen.
Dit hield ontwikkelaars in het verleden echter niet tegen om city builder-games te ontwikkelen voor aanzienlijk zwakkere hardware. De originele SimCity vermeldt bijvoorbeeld de Intel 386SX als de CPU in de systeemeisen. Een processor die bij de lancering draaide op een prachtige 16 MHz.
(Opmerking: een eerdere versie van deze tekst nam foutief aan dat het spel draaide op 12,5 MHz; dit was de kloksnelheid van de basisversie van de CPU, niet de SX-versie.)
Het idee van het draaien van een volledig spel, zeker in het traditioneel CPU-intensieve genre van de city builders, is vanuit modern perspectief pure magie.
De optimalisaties van Maxis
Waarom kon Maxis een volledige stadssimulatie draaien op deze antieke hardware, terwijl tegenwoordig zelfs eenvoudige idle-games meerdere gigahertz aan CPU-kracht vereisen?
Natuurlijk zijn daar veel redenen voor, waarvan de meeste extern zijn (zoals het OS-ecosysteem), maar dat betekent niet dat er geen slimme optimalisaties in de gamecode zelf zijn doorgevoerd. Gelukkig is deze broncode in 2008 publiek gemaakt en uitgebracht onder de GPL-licentie onder de naam Micropolis. Laat je niet verwarren door de naam; dit is nog steeds de code van het originele spel, maar omdat alleen de code open source is gemaakt en niet het handelsmerk, moest het een andere naam krijgen. Zelfs onder een andere naam is dit een kostbare bron, omdat we precies kunnen zien hoe het spel zo goed wist te draaien.
Zoals vaak het geval is bij het analyseren van softwareprestaties, kunnen we 90 procent van de code negeren. Onze focus ligt op de kern van de stadssimulatie: de componenten die systemen bijwerken zoals verkeer, brand, stroom en criminaliteit.
Er is inderdaad één enkele functie die verantwoordelijk is voor dit alles: de Simulate() functie in s_sim.c.
Zoals de commentaar in de code impliceert, wordt deze functie elk frame aangeroepen. De functie roept vervolgens de update-functies van de verschillende gamesystemen aan, zoals CrimeScan(), FireAnalysis(), CollectTax, enzovoort.
Het knelpunt is dat het aanroepen van al die functies in elk frame de arme CPU uit 1989 zou overbelasten. De oplossing: niet elke functie in elk frame aanroepen.
De kunst van het overslaan
SimCity slaat simpelweg het grootste deel van de simulatie over. Heb je de mod16 parameter van de Simulate functie gezien, daar waar je normaal gesproken een deltaTime zou verwachten? Dit getal loopt in een lus van 0 tot 15 en wordt vervolgens gebruikt om te bepalen welk deel van de simulatie in dit specifieke frame wordt uitgevoerd. De code hiervoor ziet er ongeveer zo uit:
(Opmerking: dit is niet de exacte broncode; ik heb enkele zaken verwijderd om de uitleg duidelijker te maken, maar het komt zeer dichtbij.)
Dit maakt de simulatie overduidelijk veel sneller om te berekenen, maar hoe beïnvloedt dit de gameplay? In het geval van SimCity zou ik zeggen: nauwelijks. Gemiddeld duurt het 8 frames voordat de simulatie van een aspect reageert op de actie van de speler, in het slechtste geval 15 frames. Voor een shooter zou deze inputlag een ramp zijn, maar voor de simulatie van een stad? Ik zou zelfs beargumenteren dat het prettiger kan aanvoelen dan een onmiddellijke reactie.
Breder toepasbaar: Frame-distributie
Tot nu toe lijkt deze optimalisatie zeer specifiek voor één type spel, maar het is eigenlijk het startpunt voor veel optimalisaties.
Laten we kijken naar een ander voorbeeld, ditmaal van Assetto Corsa (2014). Als racegame is een van de visuele vereisten het renderen van auto's met een glanzend effect. Om deze reflecties te renderen, gebruikt het spel de veelvoorkomende techniek van regelmatig bijgewerkte cubemaps.
Omdat het bijwerken van een volledige cubemap in elk enkel frame een veel te kostbare taak zou zijn, gebruikt het spel een vergelijkbare truc als SimCity: het werkt niet alle zes de zijden van de cubemap tegelijk bij, maar slechts een paar, afhankelijk van de instelling voor de reflectiekwaliteit.
De gebruikersinterface van de instellingen vermeldt dit niet expliciet, maar als we in het .ini-bestand kijken, zien we het effect in de variabele genaamd FACESPERFRAME.
Tot nu toe hebben we ons gehouden aan het concept van het verdelen van werk over meerdere frames door langere taken op te splitsen in vaste delen. Voor andere taken is het slimmer om de grootte en inhoud van die delen tijdens runtime te bepalen. Voor renderingtaken kan dit worden bepaald door factoren zoals schermgrootte of de tijd sinds de laatste update.
Dit verklaart waarom de Lumen-technologie van Unreal er zo goed uitziet: het hoeft niet alle verlichting in elk frame opnieuw te berekenen. Dit is waarschijnlijk ook de reden waarom de resultaten soms vreemd kunnen ogen, bijvoorbeeld wanneer reflecties niet volledig zijn bijgewerkt.
Hoewel dergelijke optimalisaties voor frame-distributie het meest voorkomen in renderingcode, hoop ik dat het voorbeeld van SimCity aantoont dat ze ook in andere gebieden kunnen werken. De volgende keer dat je aan gameplay-code werkt, vraag jezelf dan af: "Moet ik al deze code echt in elk frame uitvoeren?"
Hoe je een city builder draait op 16 MHz
Wanneer men denkt aan indrukwekkende technische prestaties in retrogames, wijzen de meeste mensen naar grafische mijlpalen die zijn gezet door actiegames zoals Doom of Quake. Dat is terecht, maar ik ben persoonlijk veel meer onder de indruk van de vroege voorbeelden van het city builder-genre. Het simuleren van volledige economieën op grote schaal met een fractie van de huidige rekenkracht lijkt onmogelijk, zeker nu moderne builder-games zelfs op moderne machines worstelen.
Dit hield ontwikkelaars in het verleden echter niet tegen om city builder-games te ontwikkelen voor aanzienlijk zwakkere hardware. De originele SimCity vermeldt bijvoorbeeld de Intel 386SX als de CPU in de systeemeisen. Een processor die bij de lancering draaide op een prachtige 16 MHz.
(Opmerking: een eerdere versie van deze tekst nam foutief aan dat het spel draaide op 12,5 MHz; dit was de kloksnelheid van de basisversie van de CPU, niet de SX-versie.)
Het idee van het draaien van een volledig spel, zeker in het traditioneel CPU-intensieve genre van de city builders, is vanuit modern perspectief pure magie.
De optimalisaties van Maxis
Waarom kon Maxis een volledige stadssimulatie draaien op deze antieke hardware, terwijl tegenwoordig zelfs eenvoudige idle-games meerdere gigahertz aan CPU-kracht vereisen?
Natuurlijk zijn daar veel redenen voor, waarvan de meeste extern zijn (zoals het OS-ecosysteem), maar dat betekent niet dat er geen slimme optimalisaties in de gamecode zelf zijn doorgevoerd. Gelukkig is deze broncode in 2008 publiek gemaakt en uitgebracht onder de GPL-licentie onder de naam Micropolis. Laat je niet verwarren door de naam; dit is nog steeds de code van het originele spel, maar omdat alleen de code open source is gemaakt en niet het handelsmerk, moest het een andere naam krijgen. Zelfs onder een andere naam is dit een kostbare bron, omdat we precies kunnen zien hoe het spel zo goed wist te draaien.
Zoals vaak het geval is bij het analyseren van softwareprestaties, kunnen we 90 procent van de code negeren. Onze focus ligt op de kern van de stadssimulatie: de componenten die systemen bijwerken zoals verkeer, brand, stroom en criminaliteit.
Er is inderdaad één enkele functie die verantwoordelijk is voor dit alles: de Simulate() functie in s_sim.c.
Zoals de commentaar in de code impliceert, wordt deze functie elk frame aangeroepen. De functie roept vervolgens de update-functies van de verschillende gamesystemen aan, zoals CrimeScan(), FireAnalysis(), CollectTax, enzovoort.
Het knelpunt is dat het aanroepen van al die functies in elk frame de arme CPU uit 1989 zou overbelasten. De oplossing: niet elke functie in elk frame aanroepen.
De kunst van het overslaan
SimCity slaat simpelweg het grootste deel van de simulatie over. Heb je de mod16 parameter van de Simulate functie gezien, daar waar je normaal gesproken een deltaTime zou verwachten? Dit getal loopt in een lus van 0 tot 15 en wordt vervolgens gebruikt om te bepalen welk deel van de simulatie in dit specifieke frame wordt uitgevoerd. De code hiervoor ziet er ongeveer zo uit:
(Opmerking: dit is niet de exacte broncode; ik heb enkele zaken verwijderd om de uitleg duidelijker te maken, maar het komt zeer dichtbij.)
Dit maakt de simulatie overduidelijk veel sneller om te berekenen, maar hoe beïnvloedt dit de gameplay? In het geval van SimCity zou ik zeggen: nauwelijks. Gemiddeld duurt het 8 frames voordat de simulatie van een aspect reageert op de actie van de speler, in het slechtste geval 15 frames. Voor een shooter zou deze inputlag een ramp zijn, maar voor de simulatie van een stad? Ik zou zelfs beargumenteren dat het prettiger kan aanvoelen dan een onmiddellijke reactie.
Breder toepasbaar: Frame-distributie
Tot nu toe lijkt deze optimalisatie zeer specifiek voor één type spel, maar het is eigenlijk het startpunt voor veel optimalisaties.
Laten we kijken naar een ander voorbeeld, ditmaal van Assetto Corsa (2014). Als racegame is een van de visuele vereisten het renderen van auto's met een glanzend effect. Om deze reflecties te renderen, gebruikt het spel de veelvoorkomende techniek van regelmatig bijgewerkte cubemaps.
Omdat het bijwerken van een volledige cubemap in elk enkel frame een veel te kostbare taak zou zijn, gebruikt het spel een vergelijkbare truc als SimCity: het werkt niet alle zes de zijden van de cubemap tegelijk bij, maar slechts een paar, afhankelijk van de instelling voor de reflectiekwaliteit.
De gebruikersinterface van de instellingen vermeldt dit niet expliciet, maar als we in het .ini-bestand kijken, zien we het effect in de variabele genaamd FACESPERFRAME.
Tot nu toe hebben we ons gehouden aan het concept van het verdelen van werk over meerdere frames door langere taken op te splitsen in vaste delen. Voor andere taken is het slimmer om de grootte en inhoud van die delen tijdens runtime te bepalen. Voor renderingtaken kan dit worden bepaald door factoren zoals schermgrootte of de tijd sinds de laatste update.
Dit verklaart waarom de Lumen-technologie van Unreal er zo goed uitziet: het hoeft niet alle verlichting in elk frame opnieuw te berekenen. Dit is waarschijnlijk ook de reden waarom de resultaten soms vreemd kunnen ogen, bijvoorbeeld wanneer reflecties niet volledig zijn bijgewerkt.
Hoewel dergelijke optimalisaties voor frame-distributie het meest voorkomen in renderingcode, hoop ik dat het voorbeeld van SimCity aantoont dat ze ook in andere gebieden kunnen werken. De volgende keer dat je aan gameplay-code werkt, vraag jezelf dan af: "Moet ik al deze code echt in elk frame uitvoeren?"