Skip to content

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.

Drop texture maps (PNG keeps exact channel bytes)
Runs in your browser. Files are not uploaded.
Texture Lab: a normal map generated from a CC0 texture, with the OpenGL/DirectX convention shown
The real Texture Lab with a CC0 asset (ambientCG).
Outputs
  • 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 → 2560

The 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

  1. Drop the sprite or texture (PNG).
  2. Choose how many pixels to bleed.
  3. Compare the mipmap preview before and after.
  4. 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

SymptomLikely causeHow to checkFix
Dark or white outline only when the object is small or far awayMip levels average the hidden RGB into the edgeFix › Mipmaps: compare 1/4, 1/8, 1/16 before and after bleedingBleed 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 scaledBilinear filtering samples between the edge and the transparent neighbourZoom the engine view: the fringe follows the silhouetteBleed 2 px or more; for pixel art use Nearest filtering instead (blurry pixel art in Godot)
The bleed disappeared after resizing the textureThe resize went through a canvas, which discards colour under alpha 0Open the resized file in the Channels stage: RGB under alpha 0 is 0 againResize first and bleed last — the Lab's Export runs resize → bleed → encode in that order
The bleed is gone after saving as WebP or JPEGThose formats are encoded through the browser canvas; JPEG has no alpha at allCompare the PNG and the WebP in the Channels stageKeep bled textures as PNG
Colour of the neighbouring sprite shows at the edge in an atlasSprites are packed without gaps, so filtering reaches the next spriteLook 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 darkThe art was exported against a black matte, so the colour is baked into pixels with alpha 1–254The dark pixels have alpha above 0Nerulio cannot fix this (bleed never touches alpha > 0): re-export from the source without a matte

Other ways to do it

OptionWhen 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 alphaYour 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.

TargetFilesCheck
Channel PNGs<name>-r/g/b/a.png (8-bit grey)Measured numpyByte-identical per channel, including RGB under fully transparent texels.
Normal mapPNG · OpenGL (+Y) or DirectX (−Y)Measured numpyDecoded vectors are unit length (worst deviation 0.0002); OpenGL and DirectX differ in green only.
Packed mask / ORMUnity 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 importUnity · Unreal · GodotUNVERIFIEDNot 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).