Skip to content

Instantly share code, notes, and snippets.

@d7samurai
Last active October 17, 2024 20:36
Show Gist options
  • Save d7samurai/9f17966ba6130a75d1bfb0f1894ed377 to your computer and use it in GitHub Desktop.
Save d7samurai/9f17966ba6130a75d1bfb0f1894ed377 to your computer and use it in GitHub Desktop.
Minimal D3D11 bonus material: pixel art antialiasing

Minimal D3D11 bonus material: pixel art antialiasing

A minimal Direct3D 11 implementation of "antialiased point sampling", useful for smooth fractional movement and non-integer scaling of pixel art AKA "fat pixel" aesthetics.

Also view below side-by-side point sampling comparison on YouTube (video is zoomed in to counter implicit downsampling & compression artifacts and make aa effect more apparent) or check out the Shadertoy.

skull

The actual sampler is set to bilinear filtering (the default D3D11 sampler state) in order to utilize single texture-read hardware interpolation, then emulating point sampling in the shader and applying AA at the fat pixel boundaries. Use with premultiplied alpha textures* and keep a one pixel transparent border around each sprite/tile.

This is the algorithm (see shaders.hlsl for context):

float2 pix = floor(p.tex) + min(frac(p.tex) / fwidth(p.tex), 1) - 0.5;

For sharper aa, try:

float2 pix = floor(p.tex) + smoothstep(0, 1, frac(p.tex) / fwidth(p.tex)) - 0.5;

Note that the D3D setup here cuts a lot of corners to keep it small relative to the pixel shader containing the algorithm. For a more conventional D3D11 setup reference, see the original Minimal D3D11


TexPrep

texprep cmd

*I recently made a small (~17 KB) command line utility for this, called texprep.exe, available for download at the bottom of this Gist (note: the executable it is unsigned and so might be - falsely! - flagged as malware by e.g. Windows Defender).

It reads most common image formats, converts to 32-bit (ARGB) and saves it out to either -png, -bmp or -bin (a raw sequence of B, G, R, A, ... bytes that can be imported into your application without the need for decoding). New! in version 1.1.x is the option to export to -txt, which creates a text file with the image data encoded as an int array[].

You can specify multiple input files. An output format will apply to subsequent files until another is specified. Default is -png.

Similarly, -pm1 turns premultiplied alpha ON and -pm0 turns it OFF. Premultiplied alpha processing is OFF by default.

Examples:

C:\>texprep -bmp -pm1 mario.png
 texprep : reading 'mario.png' (640x640) .. premultiplying alpha .. writing 'mario_pm1.bmp' .. done.
C:\>texprep -bin onedrive.ico
 texprep : reading 'onedrive.ico' (40x40) .. writing 'onedrive_40x40_pm0.bin' .. done.
C:\>texprep -bin cursor.cur -pm1 red50.png -png -pm0 splash.tif tiger.jpg
 texprep : reading 'cursor.cur' (256x170) .. writing 'cursor_256x170_pm0.bin' .. done.
 texprep : reading 'red50.png' (129x128) .. premultiplying alpha .. writing 'red50_129x128_pm1.bin' .. done.
 texprep : reading 'splash.tif' (3000x700) .. writing 'splash_pm0.png' .. done.
 texprep : reading 'tiger.jpg' (1920x1280) .. writing 'tiger_pm0.png' .. done.
C:\>texprep -txt adventurer.gif
 texprep : reading 'adventurer.gif' (898x505) .. writing 'adventurer_pm0.txt' .. done.

The latter will produce a text file that looks something like this:

// adventurer.gif

#define TEXTURE_WIDTH      898
#define TEXTURE_HEIGHT     505

int texture[] =
{
    0xff2c2c2c, 0xff2c2c2c, 0xff2c2c2c, 0xff2c2c2c, 0xff2c2c2c, 0xff2c2c2c, ... 
    ...
};

Example use case here: Minimal D3D11 sprite renderer

#pragma comment(lib, "user32")
#pragma comment(lib, "d3d11")
#pragma comment(lib, "d3dcompiler")
#include <windows.h>
#include <d3d11.h>
#include <d3dcompiler.h>
unsigned long long skullTexture[] = // 17 x 25 pixels, 1 byte per pixel
{
0x0000000000000000, 0x0000000000000000, 0xdd00000000000000, 0x00000000b2e0dede, 0xe1dc000000000000, 0x00afaeaeaec8c8c8,
0xc7e3000000000000, 0x89b09999c3ddddc3, 0xc7dd0000000000ae, 0x9b99afb0c6dbddc6, 0xc400000000af899b, 0xb0aeaec6c6dedcc6,
0xdd0000008a9c99af, 0xadadaec4dedcdcc4, 0x0000599b9aaeafaf, 0xadadc6c6ddddc6c6, 0x005c9a9aafafadae, 0xadacc6c3e0ddc600,
0x5c9d5ab0b0aeadad, 0xaddedcdcc6ae0000, 0x9f5ac4c4c7c6adad, 0x895e89ae5d00005c, 0x5b56578dc6aeaeb0, 0x18185c3300005a5a,
0x1818198cae8c1716, 0x195a330000305b31, 0x171617af5a331718, 0x8ec60000315a5b30, 0x165b8b8a58321718, 0xe000005b8c5a3132,
0x8a17dd8d5c188db1, 0x000089ad8a5a1658, 0x3219da888eaf8b5b, 0x00323034898b8d5b, 0x16aeae1719590000, 0x00301717af8d3089,
0xad5a32dc00000000, 0x005a1959ad8c8bb0, 0x8d198a0000000000, 0x58318b17e217dc5a, 0x8a8a000000000000, 0x5b3230185a5b2f18,
0xae00000000000033, 0xb02fdc30de18ddaf, 0x000000000000335a, 0xb0e1aeb1afafae00, 0x0000000000005daf, 0xadafb1afb0000000,
0x0000000000005b5c, 0x898b8b0000000000, 0x0000000000005a5c, 0x0000000000000000, 0x0000000000000000, 0x0000000000000000,
};
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
WNDCLASSA wndClass = { 0, DefWindowProcA, 0, 0, 0, 0, 0, 0, 0, "d7" };
RegisterClassA(&wndClass);
HWND window = CreateWindowExA(0, "d7", 0, 0x91000000, 0, 0, 0, 0, 0, 0, 0, 0);
D3D_FEATURE_LEVEL featureLevels[] = { D3D_FEATURE_LEVEL_11_0 };
DXGI_SWAP_CHAIN_DESC swapChainDesc = { { 0, 0, {}, DXGI_FORMAT_B8G8R8A8_UNORM }, { 1 }, 32, 2, window, 1 };
IDXGISwapChain* swapChain;
ID3D11Device* device;
ID3D11DeviceContext* deviceContext;
D3D11CreateDeviceAndSwapChain(0, D3D_DRIVER_TYPE_HARDWARE, 0, D3D11_CREATE_DEVICE_BGRA_SUPPORT, featureLevels, 1, 7, &swapChainDesc, &swapChain, &device, 0, &deviceContext);
swapChain->GetDesc(&swapChainDesc);
ID3D11Texture2D* framebuffer;
swapChain->GetBuffer(0, __uuidof(ID3D11Texture2D), (void**)&framebuffer);
ID3D11RenderTargetView* framebufferRTV;
device->CreateRenderTargetView(framebuffer, 0, &framebufferRTV);
D3D11_TEXTURE2D_DESC textureDesc = { 17, 25, 1, 1, DXGI_FORMAT_R8_UNORM, { 1 }, D3D11_USAGE_DYNAMIC, 8, 65536 };
ID3D11Texture2D* texture;
device->CreateTexture2D(&textureDesc, 0, &texture);
ID3D11ShaderResourceView* textureSRV;
device->CreateShaderResourceView(texture, 0, &textureSRV);
ID3DBlob* cso;
D3DCompileFromFile(L"shaders.hlsl", 0, 0, "mainvs", "vs_5_0", 0, 0, &cso, 0);
ID3D11VertexShader* mainVS;
device->CreateVertexShader(cso->GetBufferPointer(), cso->GetBufferSize(), 0, &mainVS);
D3DCompileFromFile(L"shaders.hlsl", 0, 0, "mainps", "ps_5_0", 0, 0, &cso, 0);
ID3D11PixelShader* mainPS;
device->CreatePixelShader(cso->GetBufferPointer(), cso->GetBufferSize(), 0, &mainPS);
D3D11_VIEWPORT framebufferVP = { 0, 0, (float)swapChainDesc.BufferDesc.Width, (float)swapChainDesc.BufferDesc.Height, 0, 1 };
((byte*)skullTexture)[0xe5] = (byte)((framebufferVP.Height / framebufferVP.Width) * 0xff);
deviceContext->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLESTRIP);
deviceContext->VSSetShader(mainVS, 0, 0);
deviceContext->VSSetShaderResources(0, 1, &textureSRV);
deviceContext->RSSetViewports(1, &framebufferVP);
deviceContext->PSSetShader(mainPS, 0, 0);
deviceContext->PSSetShaderResources(0, 1, &textureSRV);
deviceContext->OMSetRenderTargets(1, &framebufferRTV, 0);
while (true)
{
MSG msg;
while (PeekMessageA(&msg, 0, 0, 0, PM_REMOVE)) { if (msg.message == WM_KEYDOWN) return 0; DispatchMessageA(&msg); }
D3D11_MAPPED_SUBRESOURCE msr;
deviceContext->Map(texture, 0, D3D11_MAP_WRITE_DISCARD, 0, &msr);
for (int i = 0; i < 25; i++) memcpy(((byte*)msr.pData) + i * msr.RowPitch, ((byte*)skullTexture) + i * 17, 17);
deviceContext->Unmap(texture, 0);
((byte*)skullTexture)[0xd7]++;
deviceContext->Draw(4, 0);
swapChain->Present(1, 0);
}
}
struct pixel { float4 pos : SV_POSITION; float2 tex : TEX; }; // tex = uv * texturesize
///////////////////////////////////////////////////////////////////////////////////////////////////
Texture2D<float> skulltexture : register(t0);
SamplerState nullsampler : register(s0);
///////////////////////////////////////////////////////////////////////////////////////////////////
pixel mainvs(uint vertexid : SV_VERTEXID)
{
uint2 index = { vertexid & 2, (vertexid << 1 & 2) ^ 3 };
float2 ratio = float2(17.0f / 25.0f, 1.0f / skulltexture[uint2(8, 13)]);
float4 coord = ratio.xyxy * (smoothstep(-1, 1, cos(skulltexture[uint2(11, 12)] * -6.2585f)) + 1) * float2(-1, 1).xyyx / 16;
pixel p = { float4(float2(coord[index.x], coord[index.y]), 0, 1), float2(17, 25) * (index >> 1) };
return p;
}
float4 mainps(pixel p) : SV_TARGET
{
float2 pix = floor(p.tex) + min(frac(p.tex) / fwidth(p.tex), 1) - 0.5; // aa point sampling. THIS IS THE MAIN EVENT
return skulltexture.Sample(nullsampler, pix / float2(17, 25));
}
/*
float4 mainps(pixel p) : SV_TARGET // regular point sampling vs aa point sampling
{
float2 pix;
if (p.tex.x < 8.5f - fwidth(p.tex.x)) pix = floor(p.tex) + 0.5; // regular point sampling
else if (p.tex.x > 8.5f) pix = floor(p.tex) + min(frac(p.tex) / fwidth(p.tex), 1) - 0.5; // aa point sampling
else return 0;
return skulltexture.Sample(nullsampler, pix / float2(17, 25));
}
*/
@marpe
Copy link

marpe commented Dec 27, 2022

Thanks, this was helpful 👍 Any tips on how to prevent adjacent pixels "bleeding" into others when using this technique to render a tile map?

@d7samurai
Copy link
Author

d7samurai commented Dec 28, 2022

an alternative to rendering each tile as a separate antialiased sprite would be to first render the tiles normally, pixel-aligned, to a separate texture, then transform and render that to the screen with aa applied. in general, remember to use premultiplied alpha textures and keep a single-pixel transparent border around them or (for single-sprite textures) set the texture address mode to BORDER and provide a 0-value there

@marpe
Copy link

marpe commented Dec 29, 2022

ah, rendering normally and then with aa applied sounds reasonable. I experimented with adding a border, but that will instead produce gaps for tiles that are meant to be rendered right next to each other (better explained by the screenshot). extending the tiles into the border to prevent the gaps just feels a bit tedious, so I'll try out your suggestion.
pixel-gap

@d7samurai
Copy link
Author

d7samurai commented Dec 29, 2022

sure, rendering to an intermediate texture is perhaps the most straightforward in your case. and yes, if you add borders, you would have to render the sprites with a corresponding overlap (which, to be fair, shouldn't be more complicated than subtracting (1, 1) from their regular pixel coordinates.

@mmozeiko
Copy link

For rendering texture from atlas simply clamp coordinates to +0.5/-0.5 texel from borders of tile. Exact same sampling code will work, just clamp on uv needed. For example - if you have 4x4 texture where top right corner from (2,0)-(4,2) is one tile, clamp uv to (2.5,0.5)-(3.5,1.5) interval. No padding will be needed. Tiles can touch other tiles on exact pixel borders.

@d7samurai
Copy link
Author

d7samurai commented Dec 29, 2022

while the above is true for traditional atlas rendering it won't work for this technique, which relies upon sampling into neighboring pixels for aa interpolation. however, if you choose to first render your tiles into an intermediate texture using point sampling, it'll let you skip those borders.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment