Voodoo Graphics en een Gaming PC uit de late jaren 90 recreëren op een FPGA
Ik heb de afgelopen maand functies toegevoegd en de prestaties verbeterd in z486_MiSTer, waarbij ik me voornamelijk heb gericht op games uit de eerste helft van de jaren 90. Wanneer ik een paar jaar verder keek, kwam ik bij een andere verandering die ik wilde verkennen: de komst van 3D-videokaarten.
De eerste kaart die een sterke indruk op me maakte, was de Voodoo. De game was Need for Speed II SE. De vloeiende texturen, de mist en de snelheid van het geheel zorgden ervoor dat het aanvoelde als een nieuwe generatie PC-gaming. Zou ik dat nu kunnen recreëren op een FPGA, nu de z486 CPU bestaat?
Het resultaat van deze omweg is zSST, een SystemVerilog-implementatie van de 3dfx Voodoo Graphics, ook wel bekend als de SST-1. Gecombineerd met mijn z486 CPU en de omliggende PC-hardware vormt dit de z486 XL: een DOS PC met Voodoo-graphics die draait in de programmeerbare logica van een Xilinx KV260-board. Tomb Raider draait nu met zijn originele 3dfx-renderer.
zSST implementeert de meeste centrale Voodoo-functies: voorbereide driehoeken, texture filtering en mipmapping, depth- en alpha-tests, mist, blending, dithering, framebuffer-toegang en buffer-swaps. Het ondersteunt zowel de fixed-point als de floating-point setup-interfaces. Hardware-testen met games is momenteel geconcentreerd op Tomb Raider; bredere compatibiliteit en latere Voodoo-generaties zijn projecten voor een andere dag.
De CPU en renderer draaien op 100 MHz op de KV260. Dit board heeft voldoende logica, DSP-blokken, on-chip geheugen en DDR-bandbreedte voor het gecombineerde ontwerp. De DE10-Nano heeft onvoldoende ruimte voor deze grafische toevoeging. De KV260 maakt gebruik van het ingebouwde DDR-geheugen; er is geen externe SDRAM-module toevoegbaar.
Uitgaan van het programmeermodel
Gelukkig is er veel materiaal om mee te werken. 3dfx bracht de Glide-broncode uit in 1999, voordat NVIDIA de kernactiva van de grafische afdeling overnam in december 2000. De overgebleven Glide-broncode en de SST-1 specificaties leggen uit hoe software driehoeken voorbereidt, de pixel-pipeline configureert en texturen en framebuffers beheert.
De specificatie is een gedragsdoel in plaats van een schakelschema. Het beschrijft wat er moet gebeuren wanneer software naar een register schrijft, maar laat veel implementatiekeuzes open. 86Box biedt nuttige referenties voor ingewikkeld rendergedrag. Het eerdere Voodoo-werk van MAME is een ander onderdeel van deze preserveringsgeschiedenis. Daarnaast leverde SpinalVoodoo bijzonder nuttige Glide-traces en referentie-screenshots voor het testen.
Van driehoeken naar 3D: één pixel per klokcyclus
Voodoo Graphics zet driehoeken om in pixels en laat een groot deel van het 3D-werk over aan de host-CPU. De opdrachtinterface is verrassend compact: vijf hoofdregisters sturen de accelerator aan.
| Register | Actie |
|---|---|
triangleCMD | Start het renderen van een voorbereide driehoek. |
ftriangleCMD | Start een driehoek via de floating-point setup-interface. |
nopCMD | Spoelt de pipeline leeg; reset optioneel de statistiektellers. |
fastfillCMD | Vult een clipped rechthoek met kleur en/of dieptegegevens. |
swapbufferCMD | Wisselt de getoonde buffer, direct of gesynchroniseerd met de verticale retrace. |
Beide driehoek-commando's lanceren dezelfde rendering-pipeline. Andere registers bevatten coördinaten, gradiënten en de render-status, terwijl memory-mapped regio's zorgen voor texture-uploads en directe framebuffer-toegang. De belangrijkste tekenprimitief is simpelweg een voorbereide driehoek.
Voor game-ontwikkelaars biedt Glide een gebruiksvriendelijkere interface:
void grDrawTriangle(const GrVertex a, const GrVertex b, const GrVertex *c);
Vóór deze aanroep transformeert de host-CPU de 3D-geometrie, berekent de vertex-belichting, clipped deze en projecteert het op het scherm. Glide bereidt vervolgens de driehoek in schermruimte voor en bepaalt de parametergradiënten — de incrementen die worden gebruikt om waarden over het oppervlak te interpoleren — en schrijft het driehoek-commando om het renderen te starten. In tegenstelling tot latere GPU's zoals de GeForce 256, heeft de SST-1 geen hardware transform-and-lighting engine.
Toch blijft er veel werk over voor de accelerator. De rasterizer bepaalt welke pixelcentra binnen de driehoek liggen en interpoleert hun kleur, diepte en texture-coördinaten. De texture-unit haalt texels op en filtert deze; de framebuffer-unit combineert kleuren, past zichtbaarheidstesten en mist toe, blendt dit met het bestaande beeld en schrijft het resultaat.
Een pixel doorloopt verschillende fasen voordat hij klaar is, terwijl er voortdurend nieuwe pixels kunnen binnenkomen. De originele kaart verdeelt dit werk over twee ASIC's: de FBI (Frame Buffer Interface) en de TREX (de texture mapping unit, meestal de TMU genoemd). Bij een grafische klok van 50 MHz is de geadverteerde piek één getextureerde, diepte-geteste output-pixel per klokcyclus: 50 miljoen pixels per seconde.
Eén pixel per klokcyclus betekent niet dat een pixel in één klokcyclus klaar is. Het betekent dat verschillende fasen gelijktijdig aan verschillende pixels kunnen werken: terwijl één pixel wordt voorzien van een texture, kan een eerdere pixel worden geblend en een andere worden weggeschreven. Zodra de pipeline vol is, kan deze idealiter elke klokcyclus een pixel accepteren en afronden, mits het geheugen dit bijhoudt.
Dat is de kracht van een fixed-function pipeline. Een software-renderer voert veel instructies uit voor elke pixel; dedicated hardware overlapt dat werk over een constante stroom pixels. Voodoo bracht rijk getextureerde 3D-games tot leven met een vloeiende 30 FPS of meer, wat een groot deel was van de populariteit ervan.
De pixel-pipeline bouwen
Vergeleken met een x86 CPU is het rekenpad aangenaam regelmatig. Laten we een pixel volgen van zijn geïnterpoleerde parameters via texturing en kleurbewerkingen naar de framebuffer, beginnend bij de manier waarop de getallen worden gerepresenteerd.
Vaste-kommapunten achter een floating-point interface
Floating-point rekenkunde is centraal in moderne GPU-programmering. De SST-1 bevindt zich op een interessant overgangspunt: software kan floating-point waarden indienen, maar de rendering-machinerie werkt grotendeels met vaste-kommapunten (fixed point) — integers met een impliciete schaalfactor.
| Setup waarde | Fixed-point registerformaat |
|---|---|
| Scherm X en Y | 12.4 |
| Rood, groen, blauw, alpha | 12.12 |
| Diepte Z | 20.12 |
| Texture S/W en T/W | 14.18 |
| Reciproque W | 2.30 |
Hier betekent 12.4: twaalf bits vóór het binaire punt (inclusief het teken) en vier fractie-bits. Een schermcoördinaat van 10.5 wordt dus opgeslagen als het integer 168: vermenigvuldig met 16 om het te coderen, deel door 16 om de waarde terug te vinden. Deze fractie-bits stellen de rasterizer in staat om vertices tussen pixelcentra te verwerken.
De fvertex, fstart en floating-point gradiëntregisters accepteren IEEE single-precision waarden. De SST-1 converteert deze naar zijn interne fixed-point representatie, en zSST volgt dit contract. Zodra de driehoek is voorbereid, betekent het voortbewegen langs een scanlijn grotendeels het toevoegen van een vooraf berekende increment aan elke geïnterpoleerde parameter. Veel van het pixel-per-pixel werk wordt zo eenvoudige integer-optelling.
Vier texels voor één pixel
Voor perspectief-correcte texturing interpoleert de TMU S/W, T/W en 1/W, en deelt vervolgens de eerste twee door de derde om de texture-coördinaten terug te vinden. Dit zorgt ervoor dat een vloer- of muurtextuur in perspectief blijft terwijl het oppervlak zich verwijdert. De TMU selecteert ook een mip-level: een kleinere versie van de texture voor pixels die een groter gebied van het oppervlak beslaan. Dit vermindert aliasing en flikkeringen in de verte.
Bilineaire filtering combineert vervolgens vier naburige texels (de pixels van de texture) rond de sample-positie. Eerst worden de bovenste twee horizontaal geblend, daarna de onderste twee, en tot slot wordt er verticaal tussen deze twee resultaten geblend. De fractionele positie bepaalt de weging, wat resulteert in een vloeiende overgang tussen texel-kleuren in plaats van een abrupte sprong. Vier texture-reads produceren dus één gefilterd sample.
In zSST pipeline een front-end van vier fasen de perspectief- en level-of-detail berekeningen. Adresgeneratie en cache-lookup leveren de texels, en twee geregistreerde decode-fasen zetten hun opgeslagen formaten om in kleuren voor filtering en texture-combinatie. Palette-gebaseerde en NCC-gecodeerde textures hebben verschillende decoderingsregels, maar voeden uiteindelijk dezelfde pixelstroom.
Kleur, tests, mist en blending
Zodra de texture- en framebuffer-data beschikbaar zijn, gebruikt het FBI-pixelpad van zSST zes geregistreerde fasen:
| Fase | Hoofdwerkzaamheden |
|---|---|
| F0 | Bronnen selecteren, chroma key controleren, Z/W dieptewaarden voorbereiden. |
| F1 | De kleur- en alpha-combine functies toepassen. |
| F2a | Alpha/diepte testen en de mist-factor opzoeken. |
| F2b | Mist toepassen. |
| F3 | Bestemmingskleur reconstrueren en alpha blending uitvoeren. |
| F4 | Converteren naar framebuffer-precisie, ditheren en schrijfmaskers toepassen. |
Deze fasegrenzen zijn gekozen om aan het klokdoel van de FPGA te voldoen. De SST-1 specificatie beschrijft de operaties, maar onthult niet de exacte pipeline-registers van de originele ASIC. Door bijvoorbeeld de mist-lookup te scheiden van de mist-applicatie, wordt een lang rekenpad in één klokcyclus voorkomen, terwijl het vermogen om één pixel per klokcyclus te accepteren behouden blijft.
Het resultaat wordt naar de back-buffer geschreven. Een retrace-gesynchroniseerde buffer-swap toont vervolgens het voltooide beeld zonder dat de buffers halverwege de scan-out wisselen. De originele Voodoo was een 3D-only add-on die de output van de gewone VGA-kaart doorliet wanneer hij inactief was. z486 XL maakt een analoge selectie tussen de VGA-output van de PC en de display-output van zSST binnen het FPGA-systeem.
De grootste uitdaging: de aanvoer vanuit het geheugen
De zSST pixel-pipeline bleek relatief eenvoudig te implementeren, althans in vergelijking met de CPU-pipelines van de z486. De pipeline gevoed houden bleek echter veel moeilijker. Een bilineair sample heeft vier texels nodig van afzonderlijke adressen. Een diepte-geteste, geblend pixel heeft ook de bestaande diepte en kleur nodig, gevolgd door het wegschrijven van de nieuwe waarden. Het één voor één uitvoeren van deze toegangen vernietigt de doorvoer snel. Ik heb uiteindelijk meer tijd besteed aan het ontwerpen, afstemmen en debuggen van het geheugensysteem dan aan de rekenpipeline.
Hoe de originele kaart pixels leverde
De taakverdeling is zichtbaar op een Diamond Monster 3D. De bovenste 3dfx-chip is de TMU, de onderste de FBI, elk met vier EDO RAM-chips aan de rechterkant. De bovenste groep bevat texturen; de onderste groep bevat kleur- en diepte/alpha-buffers.
Zowel de FBI als de TMU hebben een dedicated 64-bit geheugenpad. Aan de texture-zijde zorgt vierweg-interleaving ervoor dat de banken onafhankelijke adressen kunnen lezen, waardoor de vier buren voor bilineaire filtering parallel kunnen worden geleverd. De specificatie belooft dezelfde doorvoer als point sampling, zonder dat er duplicate texels hoeven te worden opgeslagen.
Maar wat als twee naburige texels in dezelfde chip terechtkomen? De truc is om texels te distribueren in een herhalend tweedimensionaal patroon, in plaats van het beeld in vier grote regio's te splitsen. Door aan elke combinatie van even of oneven kolom en rij een bank toe te wijzen, wordt het principe duidelijk: elk 2×2 venster bevat bank A, B, C en D. Twee opeenvolgende kolommen hebben een tegengestelde pariteit, evenals twee opeenvolgende rijen. Alle vier de combinaties komen precies één keer voor, zodat elke bank één texel levert zonder conflict.
Texture-randen en kleine mip-levels vereisen iets meer zorg. SST-1 gebruikt texture-afmetingen die machten van twee zijn, waardoor wrapping het afwisselende patroon behoudt voor afmetingen van twee of meer. Bij clamped edges, of in mip-levels van slechts één texel breed of hoog, hergebruiken sommige samples dezelfde texel. Het centrale inzicht blijft: snelle bilineaire filtering hangt af van het zo inrichten van het geheugen dat de rekenunit al zijn inputs tegelijkertijd ontvangt.
De FBI past een soortgelijk idee toe op het kleur- en diepte/alpha-geheugen. Zijn interleaved pad ondersteunt een piek van één gerenderde pixel per klokcyclus, of twee pixels per klokcyclus voor clears. Door aan aangrenzende pixels samen te werken, worden de lees/schrijf-kosten over een scanline verspreid.
Bij 50 MHz heeft elk 64-bit pad een theoretische bandbreedte van 400 MB/s: 800 MB/s in totaal, maar gereserveerd voor verschillende taken. De TMU kan geen ongebruikte FBI-bandbreedte lenen, en vice versa. Deze dedicated bussen en zorgvuldig ingerichte banken doen me denken aan de NES- en SNES-ontwerpen uit projecten zoals SNESTang: het maximale uit het geheugen halen betekent dat je ontwerpt rondom precies wanneer en waar elke waarde nodig is.
Wat verandert op een FPGA SoC
De geheugenindeling van Voodoo legt uit hoe de pipeline bezet bleef, maar ik kan dat ontwerp niet simpelweg transplanteren naar de KV260. Het board heeft veel meer geheugenbandbreedte, maar geen dedicated EDO-geheugen gekoppeld aan de rendering-units. In plaats daarvan heeft de FPGA toegang tot gedeeld DDR-geheugen via de AXI-poorten van het Zynq processing system. Linux, de FPGA PC en de display scanout concurreren allemaal om dat geheugen.
Het doel is hetzelfde — de pixel-pipeline gevoed houden — maar de manier om dit te bereiken moet veranderen. De originele SST-1 heeft dedicated texture- en framebuffer-bussen. zSST deelt zijn renderer-poort en gebruikt buffering om DDR-latentie te tolereren.
Onze KV260-metingen laten zien waarom bandbreedte alleen niet genoeg is. Een 128-bit poort op 100 MHz heeft een theoretische bandbreedte van 1,6 GB/s. Met één openstaande aanvraag bereikt een 4 KiB read 1.370 MiB/s, maar een 64-byte read bereikt slechts 189 MiB/s. De eerste data doet er doorgaans ongeveer 280 ns over om aan te komen — ongeveer 28 klokcycli bij 100 MHz — met incidenteel veel langere wachttijden.
Een renderer die op elke kleine read wacht voordat hij de volgende verzendt, zal het grootste deel van de tijd inactief zijn. zSST heeft genoeg onafhankelijk werk "in flight" nodig om deze wachttijden te overbruggen.
Caches, replay en een reorder buffer
Het gevoed houden van de pipeline vereist zowel minder DDR-toegangen als minder tijd besteed aan het wachten daarop. De eerste stap is caching. Naburige schermpixels samplen vaak overlappende delen van een texture, dus onlangs opgehaalde texels kunnen worden hergebruikt vanuit on-chip RAM. De texture-cache van zSST bevat 8 KiB in 64-byte lijnen; elke fetch haalt ook naburige texels op die volgende pixels waarschijnlijk nodig hebben.
Een cache-miss kost nog steeds veel klokcycli, maar onafhankelijke texture-samples hoeven daar niet op te wachten. zSST houdt tot acht cache-line fetches openstaand en gebruikt een replay-queue om samples met ontbrekende data te parkeren en opnieuw te proberen wanneer deze arriveren. Ondertussen kunnen samples waarvan de texels al in de cache staan, doorgaan. Prefetching geeft een voorsprong op toekomstige reads.
Hierdoor kan een latere cache-hit klaar zijn voordat een eerdere miss is opgelost. Een reorder buffer (ROB) van 64 entries verzamelt deze resultaten en geeft ze vrij in hun oorspronkelijke volgorde. Dit principe is bekend uit CPU's: nuttig werk doen tijdens een lange wachttijd, en vervolgens de volgorde herstellen voordat de resultaten downstream worden doorgegeven.
De framebuffer-zijde gebruikt aparte 4 KiB kleur- en diepte/alpha read-caches, terwijl write-combiners naburige 16-bit updates verpakken in 128-bit aanvragen. Hier is de volgorde cruciaal: blending of dieptetests kunnen een waarde nodig hebben die een eerdere pixel heeft gewijzigd maar nog niet naar het DDR-geheugen heeft geschreven. Forwarding levert de openstaande waarde direct. Framebuffer-updates treden in volgorde in werking, en statuswijzigingen die voltooid werk vereisen, wachten tot dit is afgehandeld. Geheugenaanvragen kunnen overlappen, maar latere pixels moeten nog steeds de effecten van eerdere pixels zien.
FBI en TMU delen de 128-bit AXI-poort van de renderer, HP2. De PC gebruikt HP0 en de display scanout gebruikt HP3, waardoor hun aanvraag-queues gescheiden blijven, ook al delen ze uiteindelijk allemaal hetzelfde DDR-geheugen.
Evaluatieresultaten
Ik meet de renderer apart van de complete PC. De simulatie-benchmark stuurt commando's via de front-end van zSST en test de TMU, FBI, de gedeelde arbiter en een DDR-timingmodel. Read-data arriveert na minimaal 26 klokcycli, met deterministische variatie en incidentele langere vertragingen; writes zijn ook snelheid-gelimiteerd. De volledige renderer-tests staan 32 openstaande reads toe.
Bij 100 MHz bereikt zSST 78,5 miljoen pixels per seconde (MPix/s) voor getextureerde driehoeken, en 72,8 MPix/s met dieptetest en blending. De gepubliceerde schattingen voor de Voodoo 1 bij zijn native 50 MHz zijn 43 en 37 MPix/s voor vergelijkbare functies. Dit is geen directe appel-met-appel vergelijking: de triangle-workloads verschillen en de originele schattingen bevatten ook mist, mipmapping en Gouraud-shading. De vergelijking toont het geschatte fill-rate bereik, niet een gemeten versnelling ten opzichte van de originele kaart.
Hoge fill-rates vertalen zich niet automatisch naar hoge game-FPS. Op het board produceerde Tomb Raider Level 2 ongeveer 237 displayed buffer-swaps in circa 20 seconden — ongeveer 12 per seconde, gemeten op basis van swaps in plaats van een engine FPS-teller. Voorlopige metingen wijzen op een CPU-bottleneck: deze moet nog steeds de game draaien, geometrie voorbereiden en commando's indienen. Concurrentie om het gedeelde DDR-geheugen kan ook bijdragen. Er is nog veel te optimaliseren in de complete machine.
Voor non-Voodoo games draait de huidige 100 MHz z486 XL Doom op maximale detailinstellingen op 38,5 FPS en Quake 1.06 op 8,1 FPS. Dat is ongeveer 20% sneller dan de 85 MHz DE10-Nano build — circa 23% voor Doom en 19% voor Quake. Een 512 KiB write-back L2-cache in UltraRAM helpt de CPU om beter gebruik te maken van het DDR-geheugen.
In de geïntegreerde XCK26-build beslaat zSST ongeveer 29.500 LUTs, 28.100 flip-flops, 14 RAMB36-blokken, 8 RAMB18-blokken en 97 DSP-slices. Het gecombineerde PC- en grafisch ontwerp voldoet aan de timing bij 100 MHz.
Slotwoord
Het meest belonende is om te zien hoe originele Glide-software hardware aanstuurt die ik zelf in RTL heb gebouwd. Ik verwachtte dat de rendering-rekenkunde het moeilijkste deel zou zijn; het efficiënt aanvoeren van data kostte echter meer werk. De zorgvuldig interleaved EDO van Voodoo en de caches en queues van zSST lossen hetzelfde probleem op onder zeer verschillende beperkingen: een snelle pixel-pipeline is alleen nuttig als hij iets te doen heeft.
Zowel zSST als z486 XL zijn open source beschikbaar. Voor wie al een KV260 bezit, biedt de z486 XL SD-image de nodige Linux-ondersteuning en applicaties om eigen DOS-disk images te lanceren.
Groetjes,