Hi folks,
After a thread on tips for creating normal maps, I thought it was worth revisiting the scripts on the topic that I could find.
The scripts megathread links to ruccho’s Generate Normal Map. This creates a normal map for the edges of an active cel. From what I could tell, the colors are incorrect. securas has a repo for a script that creates edge normals; the colors are corrected by this time. The script is adapted from ericoporto, who in turn adapted ruccho’s work.
I thought a script that filled in the interior of the image and worked off a flattened composite would be nice. The simplified version is below. A longer-term, more complicated version is in a Github repository. The repo version allows multiple frames to be created at once and uses a better grayscale conversion.
The test images for the globe are sourced from Pixel Planet Emporium.
local defaults = {
stretchContrast = false,
scale = 16,
edgeType = "WRAP",
showFlatMap = false,
showGrayMap = false,
preserveAlpha = false,
pullFocus = false
}
local dlg = Dialog { title = "Normal From Height Map" }
dlg:slider {
id = "scale",
label = "Slope:",
min = 1,
max = 255,
value = defaults.scale
}
dlg:combobox {
id = "edgeType",
label = "Edges:",
option = defaults.edgeType,
options = { "CLAMP", "WRAP" }
}
dlg:newrow { always = false }
dlg:check {
id = "stretchContrast",
label = "Normalize:",
text = "Stretch Contrast",
selected = defaults.stretchContrast
}
dlg:newrow { always = false }
dlg:check {
id = "showFlatMap",
label = "Show:",
text = "Flat",
selected = defaults.showFlatMap
}
dlg:check {
id = "showGrayMap",
text = "Height",
selected = defaults.showGrayMap
}
dlg:newrow { always = false }
dlg:check {
id = "preserveAlpha",
label = "Keep Alpha:",
selected = defaults.preserveAlpha
}
dlg:newrow { always = false }
dlg:button {
id = "confirm",
text = "&OK",
focus = defaults.pullFocus,
onclick = function()
local activeSprite = app.activeSprite
if not activeSprite then
app.alert("There is no active sprite.")
return
end
if activeSprite.colorMode ~= ColorMode.RGB then
app.alert("The sprite must be in RGB color mode.")
return
end
-- Cache global methods used in loop to locals.
local max = math.max
local min = math.min
local sqrt = math.sqrt
local trunc = math.tointeger
-- Unpack arguments.
local args = dlg.data
local scale = args.scale or defaults.scale
local stretchContrast = args.stretchContrast
local showFlatMap = args.showFlatMap
local showGrayMap = args.showGrayMap
local preserveAlpha = args.preserveAlpha
-- Unpack spirte data; flatten to image.
local spriteWidth = activeSprite.width
local spriteHeight = activeSprite.height
local flatImg = Image(spriteWidth, spriteHeight)
local activeFrame = app.activeFrame
or activeSprite.frames[1]
flatImg:drawSprite(activeSprite, activeFrame)
local flatPxItr = flatImg:pixels()
-- Unpack flattened to tables of
-- alpha and grayscale value. Find
-- minimum and maximum gray.
local valTable = {}
local alphaTable = {}
local flatIdx = 1
local vMin = 999999
local vMax = -999999
for elm in flatPxItr do
local hex = elm()
local alpha = (hex >> 0x18) & 0xff
alphaTable[flatIdx] = alpha
-- v range is [0.0, 1.0].
local v = 0.0
if alpha > 0 then
local b = hex & 0xff
local g = (hex >> 0x08) & 0xff
local r = (hex >> 0x10) & 0xff
-- The average of red, green and blue color
-- channels is not an accurate way of finding
-- the gray value of an image. It is used here
-- as a simplification.
v = (r + g + b) / (3.0 * 255.0)
end
-- v is still checked against min/max even if
-- alpha is zero. This could be changed.
if v < vMin then vMin = v end
if v > vMax then vMax = v end
valTable[flatIdx] = v
flatIdx = flatIdx + 1
end
if stretchContrast and vMax > vMin then
local rangeLum = math.abs(vMax - vMin)
if rangeLum > 0.01 then
local invRangeVal = 1.0 / rangeLum
local valLen = #valTable
for i = 1, valLen, 1 do
local v = valTable[i]
valTable[i] = (v - vMin) * invRangeVal
end
end
end
if showFlatMap then
local flatLayer = activeSprite:newLayer()
flatLayer.name = "Flattened"
activeSprite:newCel(
flatLayer, activeFrame, flatImg, Point(0, 0))
end
if showGrayMap then
local grayLayer = activeSprite:newLayer()
grayLayer.name = "Height.Map"
local grayImg = Image(spriteWidth, spriteHeight)
local grayPxItr = grayImg:pixels()
local grayIdx = 1
for elm in grayPxItr do
local alpha = alphaTable[grayIdx]
local lum = valTable[grayIdx]
if alpha > 0 then
local v = trunc(0.5 + lum * 255.0)
local hex = alpha << 0x18 | v << 0x10 | v << 0x08 | v
elm(hex)
end
grayIdx = grayIdx + 1
end
activeSprite:newCel(
grayLayer, activeFrame, grayImg, Point(0, 0))
end
local edgeType = args.edgeType or defaults.edgeType
local wrapper = nil
if edgeType == "CLAMP" then
wrapper = function (a, b)
if a < 0 then return 0 end
if a >= b then return b - 1 end
return a
end
else
wrapper = function(a, b)
return a % b
end
end
local writeIdx = 1
local halfScale = scale * 0.5
local defHex = 0xffff8080
if preserveAlpha then defHex = 0x0 end
local normalImg = Image(spriteWidth, spriteHeight)
local normalItr = normalImg:pixels()
for elm in normalItr do
local alphaCenter = alphaTable[writeIdx]
local hex = defHex
if alphaCenter > 0 then
-- Flip y axis.
local yc = elm.y
local yn1 = wrapper(yc - 1, spriteHeight)
local yp1 = wrapper(yc + 1, spriteHeight)
local xc = elm.x
local xn1 = wrapper(xc - 1, spriteWidth)
local xp1 = wrapper(xc + 1, spriteWidth)
local yn1Index = xc + yn1 * spriteWidth
local yp1Index = xc + yp1 * spriteWidth
local ycw = yc * spriteWidth
local xn1Index = xn1 + ycw
local xp1Index = xp1 + ycw
-- Treat pixels with 0 alpha as 0 height.
local grayNorth = 0.0
local grayWest = 0.0
local grayEast = 0.0
local graySouth = 0.0
local alphaNorth = alphaTable[1 + yn1Index]
local alphaWest = alphaTable[1 + xn1Index]
local alphaEast = alphaTable[1 + xp1Index]
local alphaSouth = alphaTable[1 + yp1Index]
if alphaNorth > 0 then grayNorth = valTable[1 + yn1Index] end
if alphaWest > 0 then grayWest = valTable[1 + xn1Index] end
if alphaEast > 0 then grayEast = valTable[1 + xp1Index] end
if alphaSouth > 0 then graySouth = valTable[1 + yp1Index] end
local dx = halfScale * (grayWest - grayEast)
local dy = halfScale * (graySouth - grayNorth)
local sqMag = dx * dx + dy * dy + 1.0
if sqMag > 1.0 then
local nz = 1.0 / sqrt(sqMag)
local nx = min(max(dx * nz, -1.0), 1.0)
local ny = min(max(dy * nz, -1.0), 1.0)
local r = trunc(nx * 127.5 + 128.0)
local g = trunc(ny * 127.5 + 128.0)
local b = trunc(nz * 127.5 + 128.0)
hex = 0xff000000
| (b << 0x10)
| (g << 0x08)
| r
else
hex = 0xffff8080
end
end
elm(hex)
writeIdx = writeIdx + 1
end
local normalLayer = activeSprite:newLayer()
normalLayer.name = string.format("Normal.Map.%03d", scale)
activeSprite:newCel(
normalLayer, activeFrame, normalImg, Point(0, 0))
app.refresh()
end
}
dlg:button {
id = "cancel",
text = "&CANCEL",
onclick = function()
dlg:close()
end
}
dlg:show { wait = false }
Notes:
-
Making a normal map from a height map is straightforward. So, one will often come with the other. Either full-bore 3D software, such as Blender, or software specialized in normal map creation are the best options, imo. The benefit here is only that you can stay in Aseprite.
-
A gray version of a diffuse image won’t work as a height map. However, if you’ve already included shading in a sprite, then its gray version could serve as a reference. As an example, here is a sheet sourced from Mega Man X4. For more info, I liked this tutorial, “Normal Maps and How Not to Do Them” by James OHare.
-
Conversion from color to gray is not as simple as you’d think. See this older thread. The simplified script above uses the average of a color’s R, G, & B channels. If you go to
Sprite > Color Mode > More Options
in Aseprite, you can choose between Luminance, HSL and HSV, then convert back to RGB color. -
Normal maps with transparency seem acceptable in 2D. I opted to treat alpha zero as black (minimum height) and to include it when stretching contrast. Just about any alpha related design choice could be otherwise.
-
As of writing, Aseprite’s built-in normal map wheel is incorrect. There is a pull request to fix it. Meanwhile, a normal color picker, gradient and wheel generator can be found here.
-
I ignored the option to flip the y axis. Afaik, y pointing to the top of the image is conventional… But with all the game engines, who can say. It shouldn’t be hard to flip it if needed.
-
If you want edges only, similar to ruccho and securas, you can use a layer mask to create a white silhouette, then build a normal map off of that.
-
ruccho’s script looks at a pixel’s neighbors over two steps. I’m assuming this is to soften/alias the edge. This script only looks out by one; looks like securas’s is the same. My general concern would be not to introduce a pillow shading effect.
The most informative piece on blending a base normal map with a detail that I found is “Blending in Detail” by Colin Barre-Brisebois and Stephen Hill. They compare their method, Reoriented Normal Mapping (RNM), with 6 others. Their interactive visualization is here.
Understanding the article could be difficult because the code is written in HLSL and the explanation assumes familiarity with quaternions. At any rate, most of it is over my head.
-- Lua implementation of Reoriented Normal Mapping (RNM)
-- by Colin Barre-Brisebois and Stephen Hill
-- https://blog.selfshadow.com/publications/blending-in-detail/
local targets = { "ACTIVE", "ALL", "RANGE" }
local defaults = {
target = "RANGE",
blendType = "REORIENTED",
zLock = true,
pullFocus = false
}
local function blendRnm(a, b)
local ax = a[1]
local ay = a[2]
local az = a[3]
local bx = b[1]
local by = b[2]
local bz = b[3]
local tx = ax + ax - 1
local ty = ay + ay - 1
local tz = az + az
local ux = 1 - (bx + bx)
local uy = 1 - (by + by)
local uz = bz + bz - 1
local dottu = tx * ux + ty * uy + tz * uz
local dx = tx * dottu - ux * tz
local dy = ty * dottu - uy * tz
local dz = tz * dottu - uz * tz
local sqMag = dx * dx + dy * dy + dz * dz
if sqMag > 0.0 then
local magInv = 1.0 / math.sqrt(sqMag)
return { dx * magInv,
dy * magInv,
dz * magInv }
else
return { 0, 0, 0 }
end
end
local dlg = Dialog { title = "Blend Normal Maps" }
dlg:combobox {
id = "target",
label = "Target:",
option = defaults.target,
options = targets
}
dlg:newrow { always = false }
dlg:check {
id = "zLock",
label = "Z Lock:",
selected = defaults.zLock
}
dlg:newrow { always = false }
dlg:button {
id = "confirm",
text = "&OK",
focus = defaults.pullFocus,
onclick = function()
local activeSprite = app.activeSprite
if not activeSprite then
app.alert("There is no active sprite.")
return
end
local colorMode = activeSprite.colorMode
if colorMode ~= ColorMode.RGB then
app.alert("Only RGB color mode is supported.")
return
end
local layerBase = app.activeLayer
if not layerBase then
app.alert("There is no active (base) layer.")
return
end
local parent = layerBase.parent
local baseIndex = layerBase.stackIndex
local parentLayers = parent.layers
local layerCount = #parentLayers
if baseIndex >= layerCount then
app.alert("There must be a layer above the active layer.")
return
end
local detailIndex = baseIndex + 1
local layerDetail = parentLayers[detailIndex]
if not layerDetail then
app.alert("There is no detail layer.")
return
end
if layerBase.isGroup or layerDetail.isGroup then
app.alert("Group layers are not supported.")
return
end
-- Cache functions.
local max = math.max
local min = math.min
local trunc = math.tointeger
local blendFunc = blendRnm
-- Unpack arguments.
local args = dlg.data
local target = args.target or defaults.target
local zLock = args.zLock
-- Find frames.
local frames = {}
if target == "ACTIVE" then
local activeFrame = app.activeFrame
if activeFrame then
frames[1] = activeFrame
end
elseif target == "RANGE" then
local appRange = app.range
local rangeFrames = appRange.frames
local rangeFramesLen = #rangeFrames
for i = 1, rangeFramesLen, 1 do
frames[i] = rangeFrames[i]
end
else
local activeFrames = activeSprite.frames
local activeFramesLen = #activeFrames
for i = 1, activeFramesLen, 1 do
frames[i] = activeFrames[i]
end
end
local compLayer = activeSprite:newLayer()
compLayer.name = string.format("Blend.%s.%s",
layerBase.name, layerDetail.name)
compLayer.parent = parent
local framesLen = #frames
app.transaction(function()
for i = 1, framesLen, 1 do
local frame = frames[i]
local celBase = layerBase:cel(frame)
local celDetail = layerDetail:cel(frame)
if celBase and celDetail then
local imgBase = celBase.image
local posBase = celBase.position
local xTlBase = posBase.x
local yTlBase = posBase.y
local widthBase = imgBase.width
local heightBase = imgBase.height
local xBrBase = xTlBase + widthBase
local yBrBase = yTlBase + heightBase
local imgDetail = celDetail.image
local posDetail = celDetail.position
local xTlDetail = posDetail.x
local yTlDetail = posDetail.y
local widthDetail = imgDetail.width
local heightDetail = imgDetail.height
local xBrDetail = xTlDetail + widthDetail
local yBrDetail = yTlDetail + heightDetail
-- Find intersection of over and under.
local xTlTarget = max(xTlBase, xTlDetail)
local yTlTarget = max(yTlBase, yTlDetail)
local xBrTarget = min(xBrBase, xBrDetail)
local yBrTarget = min(yBrBase, yBrDetail)
if xBrTarget > xTlTarget and yBrTarget > yTlTarget then
local widthTarget = xBrTarget - xTlTarget
local heightTarget = yBrTarget - yTlTarget
local trgImage = Image(widthTarget, heightTarget)
local trgPos = Point(xTlTarget, yTlTarget)
local trgItr = trgImage:pixels()
for elm in trgItr do
local xSprite = elm.x + xTlTarget
local ySprite = elm.y + yTlTarget
local xBase = xSprite - xTlBase
local yBase = ySprite - yTlBase
local hexBase = imgBase:getPixel(xBase, yBase)
local aBase = (hexBase >> 0x18) & 0xff
if aBase > 0 then
local xDetail = xSprite - xTlDetail
local yDetail = ySprite - yTlDetail
local hexDetail = imgDetail:getPixel(
xDetail, yDetail)
local aDetail = (hexDetail >> 0x18) & 0xff
if aDetail > 0 then
local bBase = (hexBase >> 0x10) & 0xff
local gBase = (hexBase >> 0x08) & 0xff
local rBase = hexBase & 0xff
local a = {
rBase / 255.0,
gBase / 255.0,
bBase / 255.0,
aBase / 255.0 }
local bDetail = (hexDetail >> 0x10) & 0xff
local gDetail = (hexDetail >> 0x08) & 0xff
local rDetail = hexDetail & 0xff
local b = {
rDetail / 255.0,
gDetail / 255.0,
bDetail / 255.0,
aDetail / 255.0 }
local normal = blendFunc(a, b)
if zLock and normal[3] < 0.0 then
normal[3] = 0.0
local nx = normal[1]
local ny = normal[2]
local sqMag = nx * nx + ny * ny
if sqMag > 0.0 then
local magInv = 1.0 / math.sqrt(sqMag)
normal[1] = nx * magInv
normal[2] = ny * magInv
else
normal[1] = 0.0
normal[2] = 0.0
end
end
local bComp = trunc(128.0 + 127.5 * normal[3])
local gComp = trunc(128.0 + 127.5 * normal[2])
local rComp = trunc(128.0 + 127.5 * normal[1])
elm(0xff000000
| (bComp << 0x10)
| (gComp << 0x08)
| rComp)
else
elm(hexBase)
end
end
end
activeSprite:newCel(
compLayer, frame,
trgImage, trgPos)
end
end
end
end)
app.refresh()
end
}
dlg:button {
id = "cancel",
text = "&CANCEL",
onclick = function()
dlg:close()
end
}
dlg:show { wait = false }
Notes:
-
The blend can produce normals with an inclination less than zero. You can see this when a color has less than 128 blue. My hack atm is to set z to 0.0 then normalize x and y.
-
If you want to try another blend, the input colors should be in [0.0, 1.0], not [0, 255].
Hope that was a fun read! Best,
Jeremy