Normal Map From Height, Blending Normal Maps

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:

  1. 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.

  2. 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.

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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:

  1. 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.

  2. 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

2 Likes