NORMAL (JOV_GL)
Turn a flat image into a normal map — right in your workflow
- image
- RGBA
- RGB
- MASK
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.
Inputs (5)
| Name | Type | Default | Description |
|---|---|---|---|
| imageopt | IMAGE | Input image to convert into a normal map | |
| scalaropt | FLOAT | 1.00 | Intensity of base normal |
| detailopt | FLOAT | 1.00 | Intensity of detail normal |
| flipopt | BOOLEAN | false | Reverse the Normal direction |
| FRAGMENTopt | STRING | // 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)
| Name | Type | Description |
|---|---|---|
| RGBA | IMAGE | Full channel [RGBA] image. If there is an alpha, the image will be masked out with it when using this output. |
| RGB | IMAGE | Three channel [RGB] image. There will be no alpha. |
| MASK | MASK | Single channel mask output. |