Texture Edge Bleed — Fix Dark Halos on Transparent Sprites
Bilinear filtering and mipmaps average the colour of transparent texels into your edges. Edge bleed fills those texels with the sprite's own colour, and alpha is never written.
A dark (or white) outline around a transparent sprite, leaf or decal means the texture's fully transparent pixels still hold black or white RGB, and bilinear filtering or smaller mipmap levels average that hidden colour into the visible edge. The fix is to fill those pixels with the edge's own colour — edge bleed, also called dilation or alpha padding — or to let the engine do it on import (Godot's Fix Alpha Border, Unity's Alpha is Transparency). Nerulio's Edge bleed pushes RGB 2, 4, 8 or 16 px into texels with alpha 0 and never writes alpha.

- Channel PNGsMeasured· numpy
- Normal mapMeasured· numpy
- Packed mask / ORMMeasured· Pillow
- Engine importUNVERIFIED
Where the halo comes from
A PNG stores colour and alpha separately (straight alpha), so a texel with alpha 0 still has an RGB value. Most programs write (0, 0, 0) or (255, 255, 255) there. You never see it at 1:1, because alpha 0 hides it.
Filtering and mipmaps mix neighbours. A bilinear sample halfway between an opaque edge texel and a transparent one averages both colours and both alphas; a mip level averages each 2 × 2 block of the level above. The result has the right, half-way alpha but a colour pulled towards the hidden black or white, so the edge darkens or glows — more at every smaller mip level, which is why the outline often appears only when the object is far away or scaled down.
Edge bleed replaces the hidden colour. Nerulio grows the colour one ring per round: every transparent texel next to one that already has colour takes the average RGB of those neighbours (all 8 directions), so after N rounds everything within N px of the sprite carries its edge colour. Texels with alpha above 0 are never touched and no alpha byte is written. Premultiplied-alpha textures avoid the problem in another way: their colour is already multiplied by alpha, so a transparent texel adds nothing.
How far to bleed. In a box-filtered mip chain a texel of level k covers 2ᵏ × 2ᵏ source texels, so a transparent texel mixed into an edge block is at most 2ᵏ − 1 px from the sprite. A 4 px bleed therefore keeps the levels down to 1/4 clean, 8 px down to 1/8 and 16 px down to 1/16 — the three levels the Lab's mipmap preview shows.
- Straight alpha
- Colour and alpha stored independently; RGB exists even where alpha is 0.
- Edge bleed (dilation)
- Copying edge colour outwards into transparent texels without changing their alpha.
- Mip level
- A pre-shrunk copy (1/2, 1/4, 1/8 …) the GPU uses when the texture is drawn small.
- Premultiplied alpha
- Colour already multiplied by alpha; hidden colour cannot leak.
Example: one edge block, before and after
A red sprite texel (210, 40, 30, 255) sits next to a transparent texel that holds black. Mip level 1 averages the 2 × 2 block (two opaque, two transparent):
Without bleed (210+210+0+0)/4, (40+40+0+0)/4, (30+30+0+0)/4, alpha (255+255+0+0)/4
= (105, 20, 15, 128) half transparent and half as bright: the dark rim
With bleed transparent texels now hold (210, 40, 30, 0)
= (210, 40, 30, 128) half transparent, same red
Texels filled around a 24 × 24 opaque square (bleed N px: (24 + 2N)² − 24²):
2 px → 208 4 px → 448 8 px → 1024 16 px → 2560The 4 px figure is the one the Lab measured on its test sprite: 448 texels changed, all with alpha 0, and not a single alpha byte changed.
What it does
- RGB pushed outwards 2/4/8/16 px from texels that carry alpha into transparent ones; alpha untouched.
- Mipmap preview (1/4 … 1/16) with the same box filter a GPU uses, so an undilated edge visibly darkens.
- Power-of-two resize with a quality resampler (nearest, down, up, fit).
- Batch export: resize → bleed → encode, bleed last so resizing cannot undo it.
How to do it
- Drop the sprite or texture (PNG).
- Choose how many pixels to bleed.
- Compare the mipmap preview before and after.
- Download.
Check the result
- In Fix › Mipmaps, the edge at 1/4 … 1/16 should keep the sprite's colour after bleeding instead of turning darker.
- The result note says how many transparent texels were filled; open the saved PNG in the Channels stage: the A channel is unchanged and RGB under alpha 0 now carries the edge colour.
- In the engine, view the object small or far away with mipmaps on: the outline should be gone.
Troubleshooting
| Symptom | Likely cause | How to check | Fix |
|---|---|---|---|
| Dark or white outline only when the object is small or far away | Mip levels average the hidden RGB into the edge | Fix › Mipmaps: compare 1/4, 1/8, 1/16 before and after bleeding | Bleed at least as far as the smallest level you see (4 px → 1/4, 8 px → 1/8, 16 px → 1/16), or turn on the engine's import option |
| A thin fringe at 1:1 when the sprite moves, rotates or is scaled | Bilinear filtering samples between the edge and the transparent neighbour | Zoom the engine view: the fringe follows the silhouette | Bleed 2 px or more; for pixel art use Nearest filtering instead (blurry pixel art in Godot) |
| The bleed disappeared after resizing the texture | The resize went through a canvas, which discards colour under alpha 0 | Open the resized file in the Channels stage: RGB under alpha 0 is 0 again | Resize first and bleed last — the Lab's Export runs resize → bleed → encode in that order |
| The bleed is gone after saving as WebP or JPEG | Those formats are encoded through the browser canvas; JPEG has no alpha at all | Compare the PNG and the WebP in the Channels stage | Keep bled textures as PNG |
| Colour of the neighbouring sprite shows at the edge in an atlas | Sprites are packed without gaps, so filtering reaches the next sprite | Look at the atlas: are sprites touching? | That needs padding or extrusion between sprites, not bleed: see atlas padding and the sprite sheet packer |
| Semi-transparent edge pixels themselves are dark | The art was exported against a black matte, so the colour is baked into pixels with alpha 1–254 | The dark pixels have alpha above 0 | Nerulio cannot fix this (bleed never touches alpha > 0): re-export from the source without a matte |
Other ways to do it
| Option | When it is the better choice |
|---|---|
| Godot 4: Process › Fix Alpha Border (import option) | Textures imported into Godot: it fills transition pixels with the surrounding colour on import and is on by default, so a bled PNG is usually not needed there. |
| Unity: Alpha is Transparency (import option) | Textures imported into Unity: it dilates the colour channels to avoid filtering artifacts at alpha edges. |
| Premultiplied alpha | Your renderer blends premultiplied textures: hidden colour cannot leak at all. |
Outputs and how each was checked
"Measured" means the downloaded file was opened again outside the page (Pillow, numpy or an independent parser) and the property was measured. Loading these files in a game engine was not tested.
| Target | Files | Check |
|---|---|---|
| Channel PNGs | <name>-r/g/b/a.png (8-bit grey) | Measured numpyByte-identical per channel, including RGB under fully transparent texels. |
| Normal map | PNG · OpenGL (+Y) or DirectX (−Y) | Measured numpyDecoded vectors are unit length (worst deviation 0.0002); OpenGL and DirectX differ in green only. |
| Packed mask / ORM | Unity HDRP mask · URP metallic · ORM (glTF, Godot) | Measured PillowPacked bytes checked: ORM preset over AO 10 / roughness 80 / metallic 220 → (10, 80, 220, 255). |
| Engine import | Unity · Unreal · Godot | UNVERIFIEDNot imported in any engine. |
Evidence
Channel unpack byte-identical on all four channels (max difference 0), 2048 transparent texels keep their RGB; edge bleed changed 448 texels, all with alpha 0, and no alpha byte; OpenGL ↔ DirectX differ exactly in green (255 − g); converted normals decode to unit vectors.
Limits
- Exact channel bytes need PNG; JPEG/WebP are decoded by the browser and marked "not byte-exact".
- Interlaced PNG and 16-bit output are not supported; up to 33.5 million pixels on the exact path.
- A normal map's convention cannot be detected from its pixels, only converted.
- Nothing was imported into Unity, Unreal or Godot: channel orders rest on their documentation and engine source.
Versions and sources
Measured on the Texture Lab (docs/TEXTURE-LAB.md): edge bleed changed 448 texels around a 24 × 24 square (exactly four rings), none with alpha above 0; the alpha plane had 0 differing bytes; the first ring read (210, 40, 30, 0). The mipmap preview is the same per-channel box average as mipChain in src/game/texture-fix.js. Engine import options are quoted from the Godot 4.7 and Unity 6 documentation.
Official documentation used
Questions
Will my transparent areas become visible?
No. Only the colour under alpha 0 changes; in the test 448 texels changed, all of them with alpha 0, and no alpha byte changed.
Is this the same as atlas padding?
No: padding adds space between sprites in an atlas; edge bleed fills the transparent pixels inside an image. Use both for packed atlases.
Is it free, and do I need an account?
Everything on these pages is free and works without an account. There is no watermark and no export that is held back.
Are my sprites uploaded?
No. The Studio decodes, packs and exports in this browser tab. Projects are autosaved in the browser (IndexedDB) and can be saved as a .nerulio file.
Does it need Aseprite or an engine plugin installed?
No. .aseprite files are read and written in the browser, and each engine bundle carries what that engine needs (for Godot, the import settings and scene; for Unity and the Godot TileSet, a small editor script).