Normal Map for a Sprite Sheet — Every Frame on Its Own
Run a normal map generator over a whole sprite sheet and it sees one picture, not an animation: frames that sit close together share their blur and edge distances, and the relief of a pose changes with its neighbours. Here the sheet is cut into frames in the Sprite workspace first, and the Texture workspace makes the normal map of every frame on its own with the same settings in pixels, so the relief holds still while the animation plays.
Give an animated sprite sheet its normal map frame by frame: each frame's relief is computed inside its own rectangle with the same settings in pixels, and the result is one normal-map sheet with exactly the colour sheet's layout. In Nerulio you cut the sheet in the Sprite workspace (grid or islands, then Apply), open the Texture tab, and export _n.png plus a Godot 4 scene whose AnimationPlayer steps through the frames, or a Unity 6 importer that sets the same sprite rects and attaches _NormalMap.

- Godot 4 2D lightsVerified· Godot 4.7.2
- Unity 6 URP 2DVerified· Unity 6000.5.3f1 (URP 17.5)
- PNG set + JSONPlain files
What goes wrong at frame borders, and how per-frame generation avoids it
A normal map generator looks at neighbouring pixels. The slope kernel reads 1 px around each pixel (Pixel, Sobel, Scharr) or 2 px (Sobel 5×5), the bevel measures the distance to the nearest transparent pixel, and blurs reach further. Over a whole sheet all three cross frame borders: where two frames touch, the bevel treats the next frame's pixels as more body, and the kernel mixes both drawings in the border columns.
Nerulio processes each frame region on its own. The kernels clamp at the region border (they repeat the frame's last pixel), the distance transform treats the frame edge as transparent, and blur never reads across it. Width, depth and strength are absolute pixels, never rescaled per frame, so a pose that repeats gets identical relief wherever it sits on the sheet; tests/normals.test.mjs checks that a frame's map does not depend on its neighbours and moves with the sprite.
Outside the drawing each frame gets the flat normal (128, 128, 255) with full alpha, and the silhouette's normals are copied 2 px outward into the transparent area. When the engine filters or mipmaps the sheet, the texels next to the sprite's edge therefore hold plausible normals instead of nothing.
Lights are stored in frame pixels, so the preview and the exported scene light every frame from the same relative position: the animation does not drift through the light as its region moves across the sheet.
- Frame region
- The rectangle of one frame on the sheet; all normal-map work stays inside it.
- Clamp
- An edge mode that repeats the last pixel instead of reading past the border.
- Edge bleed
- Copying edge normals into transparent pixels so filtering at the silhouette stays correct.
Example: a 720 × 330 sheet of 80 × 110 frames
The CC0 adventurer sheet from our Godot and Unity checks (720 × 330 is not a power of two):
720 / 80 = 9 columns 330 / 110 = 3 rows → 27 cells
each region: bevel, kernel and blur run inside 80 × 110 only
normal sheet: 720 × 330, same layout, saved as _n.png
Godot frame 10 = row 1, column 1 → region_rect Rect2(80, 110, 80, 110)
Unity rects count rows from the bottom: y = 330 − (110 + 110) = 110
row 0 → y = 220 row 2 → y = 0In the Godot scene each frame is a key on region_rect, placed at the sum of the durations before it, and the Animation's length is the sum of all of them; one CanvasTexture holds the normal map for every frame.
What it does
- Frames come from the Sprite workspace (Apply); the Texture workspace does not cut frames itself, and a sheet without frames is lit as one picture.
- The distance transform, blur and kernels never read across a frame border; tests/normals.test.mjs checks that a frame's map does not depend on its neighbours and moves with the sprite.
- Bevel width, depth and detail are absolute pixels, never rescaled per frame; pixel-art frames up to 128 px get a suggested 1.5 px rim.
- Lights are placed in frame pixels, so every frame is lit alike; Enter plays the animation lit, and ` switches between the whole sheet and one frame.
- Height brush (B) strokes stay inside the frame they started in, one undo step each.
How to do it
- Drop the sprite sheet; it opens in the Sprite workspace.
- Check the grid and animations and press Apply.
- Open the Texture tab: a normal map for every frame is generated at once.
- Adjust bevel and detail, place lights with the Lights tool (L), and press Enter to watch the animation lit.
- Export (Ctrl+E) for Godot 4, Unity 6 URP 2D or as a PNG set.
Check the result
- Press ` to switch between the whole sheet and one frame: the frame's relief must look the same in both.
- Press Enter in the Lit view: if the relief flickers between two frames that look alike, compare them in the Height view; the difference is in the drawings, not in their neighbours.
- Open
_n.pngnext to the colour sheet: the silhouettes line up pixel for pixel. - In Godot, play the AnimationPlayer:
region_rectadvances one frame per key and the normals move with the sprite.
Whole-sheet versus per-frame normal maps
| One pass over the whole sheet | Per frame (this workspace) | |
|---|---|---|
| Bevel near frame borders | edge distance can reach into a neighbouring frame | measured inside each frame only |
| Blur and detail | can bleed from one frame into the next | stop at the frame border |
| Same drawing in two frames | may get a different relief | gets the same relief, moved with the sprite |
| Light position | in sheet pixels: frames in different places are lit differently | in frame pixels: every frame lit alike |
| Export | one normal map image; engine setup by hand | Godot scene with an AnimationPlayer; Unity sprite rects plus _NormalMap |
Troubleshooting
| Symptom | Likely cause | How to check | Fix |
|---|---|---|---|
| A straight bright ridge along one side of a frame | The drawing touches the frame edge, and the frame border counts as transparent, so a rim is built there | The ridge lies exactly on the frame border | Re-cut with room around the drawing, or flatten that strip with the Height brush |
| The relief of one pose leaks into the next | The normal map was made from the whole sheet (another tool, or a sheet whose frames were never cut) | Texture workspace shows one region; the Frames panel is empty | Cut the frames in the Sprite workspace (see the sheet cutter) and regenerate |
| Edges shimmer as the sprite moves in the engine | Bilinear filtering samples across frame borders of the normal sheet | Gone with Nearest / Point filtering | Nearest or Point for pixel art; for filtered art leave spacing between frames (atlas padding) |
| Unity shows the wrong part of the sheet | The sheet was edited or resized after export, so the rects no longer fit | Sprite Editor rects against the PNG size | Export again and re-run Apply Texture JSON |
Other ways to do it
| Option | When it is the better choice |
|---|---|
| Generate each frame separately in any tool and reassemble | A few frames and a tool you already use; you have to keep the layout identical by hand. |
| Laigter's sheet splitting | Its app splits a sheet by frame count or grid size (release 1.12.0); how its bevel treats frame borders was not measured. |
Pack colour and normal atlases together (TexturePacker --pack-normalmaps) | Many sprites packed into one atlas: the Studio's Texture workspace works on one sheet, not on a packed multi-sprite atlas. |
Engine exports and how each was checked
"Verified" means the exported files were loaded by the real engine or tool on real CC0 assets and the result was compared with the source. Anything that was not loaded is labelled as such.
| Target | Files | Check |
|---|---|---|
| Godot 4 2D lights | _lit.tscn · CanvasTexture .tres · normal / specular PNG | Verified Godot 4.7.2Six real CC0 cases rendered by Godot (gl_compatibility): every checked frame within 1/255 of the Studio's lit preview. The same render with green flipped is 3.8–37 levels off, so the check can see a wrong convention. |
| Unity 6 URP 2D | PNG · nerulio-texture.json · Editor/NerulioNormalMapImporter.cs | Verified Unity 6000.5.3f1 (URP 17.5)12 of 12 runs (6 cases in Gamma and Linear colour space): the lighting URP applied matches the exported normal map (mean error 0.0037–0.0085). URP draws its own light falloff, so brightness is Unity's, not the preview's. |
| PNG set + JSON | albedo · _n (OpenGL) · _n_dx (DirectX) · 16-bit height · AO · specular · manifest | Plain filesPlain files for any engine or tool; no engine run is claimed for them. |
Evidence
Godot 4.7.2 rendered 6 real CC0 cases (pixel art, an HD sprite, a tileable texture, an imported DirectX map): every checked frame within 1/255 of the Studio's preview. Unity 6000.5 URP 2D: 12 of 12 runs pass (6 cases × Gamma and Linear). OpenGL/DirectX detection on real maps of known convention: 128 full 1K maps 100 % right, and not one wrong "high" verdict in 1,562 samples (a naive "green-high means OpenGL" rule gets 46 %).
Limits
- The Texture workspace does not cut frames; a sheet without frames is lit as one picture.
- Near a region border in Wrap or Mirror mode the live brush preview can differ slightly until the exact recompute after the stroke.
- Normal maps are not packed together with a multi-sprite atlas.
- On a picture without transparent edges (a tileable texture) a flipped red channel cannot be told apart from a DirectX green; the Check panel says so and offers Flip red.
- A sheet is lit as one picture until its frames are cut in the Sprite workspace; the Texture workspace does not cut frames.
- The rim light is preview only, and Unity gets no specular map (Godot does).
- Unity draws its own light falloff: the exported normal map is verified in Unity, the brightness curve is Unity's.
Versions and sources
tests/normals.test.mjs shows that a frame's map does not depend on its neighbours and moves with the sprite. Godot 4.7.2 rendered the exported AnimationPlayer frames within 1/255, including the 60-frame samurai and the 720 × 330 adventurer; the same six cases passed 12 of 12 runs in Unity 6000.5.3f1. That the normal map must share the sprite's UVs is stated in the Unity manual.
Official documentation used
Questions
Why not make one normal map of the whole sheet?
When frames touch or sit close together, a whole-sheet pass lets the blur and the edge distance run from one frame into the next, so the same drawing gets a different relief depending on its neighbours, and the animation shimmers. Per-frame processing gives each pose the same result wherever it sits on the sheet.
Does the Godot export play the animation?
Yes. <name>_lit.tscn has a Sprite2D with a CanvasTexture, CanvasModulate for the ambient light, one PointLight2D per light and an AnimationPlayer over the frames. Godot 4.7.2 matched the Studio's reference within 1/255 on every checked frame, including through that AnimationPlayer.
And in Unity?
The Unity 6 URP 2D bundle sets the sprite rects from its JSON and adds the normal map as the _NormalMap secondary texture (linear, uncompressed, never resampled). It passed 12 of 12 runs in Unity 6000.5.3f1; the brightness curve is URP's own.
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).