Normal Map
A height map in, a renderer-ready normal out
- image
- source_mask
- mask
- image
- mask
- normal_info
Generating a normal map from an image is the most classic material job in the book, and x1NormalMap does it properly. Give it any grayscale height source - luminance of your image, a channel, a mask - and it computes a tangent-space normal map you can drop straight into a renderer, a shader ball, or the pack's own x1PBRPack. It's the middle of the pack's surfacing pipeline: x1Heightmap to art-direct the height, x1NormalMap to convert it, x1NormalBlend to stack detail on top.
How it works
The mechanism is the standard height-to-normal trick: take your heightfield, compute gradients along X and Y, and encode the surface slope as a tangent-space normal. What separates this from a bare numpy implementation is the control around it:
source_mode- pick what becomes the height:luma, individual RGB channels,max_rgb,saturation,value,alpha, ormask(fed via the optionalsource_mask)normalize_mode-auto_percentile(default, robust to outliers),manual_range, orauto_rangegammaandblur_radius- shape the heightfield before gradients are computed. This is the subtle one: a small blur before the gradient step is how you suppress noise-amplified bumpsstrength- the slope multiplier (default 4.0). This is your "how bumpy" knobconvention-openglordirectx, which differ only in green-channel orientation (see the gotcha below)invert_heightandinvert_x- flip the depth direction or the red axis when your source is inverted
Everything ships in a settings_json string, the pack's house style: powerful, and not always discoverable. The defaults are a solid starting point.
Inputs and outputs
Required: image and settings_json. Optional: source_mask (height source when source_mode is mask) and mask (limits where the normals are emitted).
image- the tangent-space normal map. Outside the optionalmask, it outputs a flat neutral normal rather than guessingmask- the normalized height scalar as aMASKnormal_info- aSTRINGsummary of the resolved settings and normalization range
The convention gotcha (read this)
opengl vs directx flips the green channel, and getting it wrong is the single most common normal-map failure: your surface looks fine but lighting reads inverted, as if the detail is recessed instead of raised. Renderers and engines differ in what they expect. The author's guidance for this pack's x1PreviewMaterial is clear - it expects glTF/OpenGL-space normals, and you set normal_convention to directx on the preview node when feeding it an engine-style external map (because the preview flips it back). If you generate with x1NormalMap in opengl mode, tell the preview node opengl. Match the pair and stop guessing.
Also worth knowing: the author suggests using x1Heightmap first if you want to inspect and art-direct the height before converting it - the two nodes are designed to chain, and it's a much better workflow than blind-generating normals and hoping.
Installing it
MKRShift Nodes via ComfyUI Manager (search "MKRShift Nodes"), or:
cd ComfyUI/custom_nodes
git clone https://github.com/criskb/MKRShift_Nodes
Restart and it's live. No requirements, no model downloads, no ffmpeg - the Surface/Maps family is plain numpy/torch math. The caveat, as with every node in this pack: you're installing a very large repo to reach a small family. If you're building PBR assets, the payoff is that all these maps share one consistent normalization vocabulary and chain cleanly into x1PBRPack.
Inputs (4)
| Name | Type | Default | Description |
|---|---|---|---|
| image | IMAGE | — | |
| settings_json | STRING | {"source_mode":"luma","normalize_mode":"auto_percentile","value_min":0.0,"value_max":1.0,"percentile_low":2.0,"percentile_high":98.0,"gamma":1.0,"blur_radius":0.0,"strength":4.0,"convention":"opengl","invert_height":false,"invert_x":false,"mask_feather":8.0,"invert_mask":false} | — |
| source_maskopt | MASK | — | |
| maskopt | MASK | — |
Outputs (3)
| Name | Type | Description |
|---|---|---|
| image | IMAGE | — |
| mask | MASK | — |
| normal_info | STRING | — |