Nodes/Jovi_GLSL/NORMAL (JOV_GL)
ComfyUI Node

NORMAL (JOV_GL)

Turn a flat image into a normal map — right in your workflow

By Amorano·Created 2 years ago·Updated about a year ago· 20
NORMAL (JOV_GL)
  • image
  • RGBA
  • RGB
  • MASK
scalar1.00
detail1.00
flipfalse
FRAGMENT// name: NORMAL // desc: Convert input into a Normal map // category: CREATE uniform sampler2D image; // | Input image to convert into a normal map uniform float scalar; // 1.00; 0 | Intensity of base normal uniform float detail; // 1.00; 0 | Intensity of detail normal uniform bool flip; // | Reverse the Normal direction const mat3 scharr_x = mat3( 1.0, 10.0/3.0, 1.0, 0.0, 0.0, 0.0, -1.0, -10.0/3.0, -1.0 ); const mat3 scharr_y = mat3( 1.0, 0.0, -1.0, 10.0/3.0, 0.0, -10.0/3.0, 1.0, 0.0, -1.0 ); vec3 scharr(vec2 uv) { vec3 result = vec3(0.0); vec2 texelSize = 1.0 / iResolution.xy; for (int i = -1; i <= 1; i++) { for (int j = -1; j <= 1; j++) { vec2 offset = vec2(float(i), float(j)) * texelSize; vec3 color = texture(image, uv + offset).rgb; float luminance = dot(color, vec3(0.299, 0.587, 0.114)); result.x += luminance * scharr_x[i+1][j+1] * detail; result.y += luminance * scharr_y[i+1][j+1] * detail; } } return result; } void mainImage( out vec4 fragColor, in vec2 fragCoord ) { vec2 uv = fragCoord / iResolution.xy; // detailed normal vec3 normal = vec3(0.0, 0.0, 1.0); normal.xy = scharr(uv).xy * scalar; if (flip) { normal.xy = normal.yx; } normal.x *= -scalar; normal = normalize(normal) * 0.5 + 0.5; fragColor = vec4(normal, 1.0); }

Normal maps are everywhere in modern image workflows now - IC-Light relighting, ControlNet normal conditioning, material passes, even as a pre-processor for feeding geometry into a diffusion model. But ComfyUI's built-in options for making one from an arbitrary image are thinner than you'd think. This node converts an input image into a normal map on the GPU, with controls for how strong the base normal is and how much detail survives, plus a flip for orientation. If you need "surface information I can actually relight," this is the fastest route in the graph.

How it works

The shader runs a Scharr operator - a sharper cousin of Sobel - across the image to estimate the height gradient, then builds a normal from that gradient plus the surface up-vector. The scalar input scales the base normal's strength (how much the image's brightness variation pushes the normals around), and detail scales the detail component. The flip boolean reverses the normal direction, which is the knob you reach for when a relight looks inverted or your materials' convention is flipped relative to the rest of the pipeline.

The inputs

  • image - the image to convert (a grayscale heightmap works beautifully; a photo works too).
  • scalar - base normal intensity, default 1.
  • detail - detail normal intensity, default 1.
  • flip - reverse the normal direction, default off.

Outputs are the pack's RGBA, RGB, and MASK. For a normal map you want the RGB output - it's the three-channel normal, which is what IC-Light, ControlNet, and material renderers expect. The RGBA output just adds a full alpha.

Install

One-time pack install. Via ComfyUI Manager, search Jovi_GLSL; or:

cd ComfyUI/custom_nodes
git clone https://github.com/Amorano/Jovi_GLSL.git
cd Jovi_GLSL
pip install -r requirements.txt

Restart ComfyUI. Standard pack deps (PyOpenGL, glfw, opencv-contrib-python, cozy_comfyui from git), needs an OpenGL context. No models, no keys. Under **JOVI_GLSL 🌈 → CREATE`.

Gotchas and tips

Feed it a clean grayscale heightmap and you'll get clean normals; feed it a busy photo and the normals get noisy fast - drop scalar and detail to tame it. The controlnet/IC-Light ecosystem is generally fussy about normal conventions, so if your relight comes out wrong, flip it before reaching for other tools. And a genuinely fun trick: pipe one of the pack's NOISE nodes into this, and you get procedural bumpy material maps in seconds - Worley into NORMAL is a great texture factory.

CategoryJOV_GL 🌈/CREATE

Inputs (5)

NameTypeDefaultDescription
imageoptIMAGEInput image to convert into a normal map
scalaroptFLOAT1.00Intensity of base normal
detailoptFLOAT1.00Intensity of detail normal
flipoptBOOLEANfalseReverse the Normal direction
FRAGMENToptSTRING// name: NORMAL // desc: Convert input into a Normal map // category: CREATE uniform sampler2D image; // | Input image to convert into a normal map uniform float scalar; // 1.00; 0 | Intensity of base normal uniform float detail; // 1.00; 0 | Intensity of detail normal uniform bool flip; // | Reverse the Normal direction const mat3 scharr_x = mat3( 1.0, 10.0/3.0, 1.0, 0.0, 0.0, 0.0, -1.0, -10.0/3.0, -1.0 ); const mat3 scharr_y = mat3( 1.0, 0.0, -1.0, 10.0/3.0, 0.0, -10.0/3.0, 1.0, 0.0, -1.0 ); vec3 scharr(vec2 uv) { vec3 result = vec3(0.0); vec2 texelSize = 1.0 / iResolution.xy; for (int i = -1; i <= 1; i++) { for (int j = -1; j <= 1; j++) { vec2 offset = vec2(float(i), float(j)) * texelSize; vec3 color = texture(image, uv + offset).rgb; float luminance = dot(color, vec3(0.299, 0.587, 0.114)); result.x += luminance * scharr_x[i+1][j+1] * detail; result.y += luminance * scharr_y[i+1][j+1] * detail; } } return result; } void mainImage( out vec4 fragColor, in vec2 fragCoord ) { vec2 uv = fragCoord / iResolution.xy; // detailed normal vec3 normal = vec3(0.0, 0.0, 1.0); normal.xy = scharr(uv).xy * scalar; if (flip) { normal.xy = normal.yx; } normal.x *= -scalar; normal = normalize(normal) * 0.5 + 0.5; fragColor = vec4(normal, 1.0); }

Outputs (3)

NameTypeDescription
RGBAIMAGEFull channel [RGBA] image. If there is an alpha, the image will be masked out with it when using this output.
RGBIMAGEThree channel [RGB] image. There will be no alpha.
MASKMASKSingle channel mask output.