Normal from Height (PBR)
Normals from a height map — no model download, just math
- height
- normal_map
There are two ways to get a normal map for your AI-generated texture: download a model that infers it (slow, heavy, another moving part), or derive it from the height you already have. TextureNormalFromHeight ("Normal from Height (PBR)") is the second one - it turns any grayscale height map into a tangent-space normal map with finite-difference math, on your CPU, in a fraction of a second. No weights, no network calls, no VRAM.
It's the workhorse of ComfyUI-Texture-Simple, Gökay Aydoğan's offline PBR toolkit. For its intended use - taking a generated albedo or height map and producing the supporting maps - a derivative-based normal map is usually better than a learned one, because it matches your height map exactly. The two agree with each other by construction.
How it works
The node takes the luminance of your height input, optionally blurs it, then computes the gradient - the slope - in X and Y using finite differences. wrap_edges (on by default) computes those differences with rolled, wrapping offsets so the normal map tiles; off, it falls back to plain np.gradient. The X and Y gradients get scaled by strength, stacked against a constant Z of 1.0, and normalized. That Z of 1 is why flat areas map to that familiar blue-purple: a level surface is a vector pointing straight out. Finally everything gets remapped from [-1, 1] to [0, 1] because that's what a texture file expects.
The inputs that matter
- strength (default 2.0) - how bumpy it reads. Higher = more exaggerated relief. 2 is a good starting point; low-res or noisy sources can take 4–6, but you'll see artifacts before too long.
- blur_px (default 0) - a pre-blur on the height before differencing. This is your noise suppressor. Diffusion-generated height maps are noisy, and derivatives amplify noise; a blur of 1–3 usually cleans it up without flattening the real relief.
- convention -
OpenGL (+Y)orDirectX (-Y). This flips the sign of the green channel. Blender, most PBR viewers, and OpenGL pipelines expect one; DirectX (i.e. most game engines and some renderers) expect the other. If your normals look inverted - lighting from the wrong side, bumps reading as dents - this dropdown is the fix, and it's the first thing to check.
Install
ComfyUI Manager, or:
cd ComfyUI/custom_nodes
git clone https://github.com/gokayfem/ComfyUI-Texture-Simple.git
python -m pip install -r ComfyUI-Texture-Simple/requirements.txt
Restart ComfyUI; the node is under texture/PBR. Only numpy and Pillow are required.
Wiring it in
Feed a height map (or any image - it derives height from luminance) into height, and normal_map goes straight into the normal slot of the pack's Texture Viewer Pro, or into a TextureChannelPack layout, or straight into your renderer's normal input. The one habit worth building: tile your source with TextureMakeTileable before you generate normals. A seam in the height map becomes a scar in the normal map, and no amount of blur_px fixes a source that never tiled.
Inputs (5)
| Name | Type | Default | Description |
|---|---|---|---|
| height | IMAGE | — | |
| strength | FLOAT | 2.000–20 | — |
| blur_px | INT | 00–32 | — |
| convention | COMBO | 2 options: OpenGL (+Y), DirectX (-Y) | |
| wrap_edges | BOOLEAN | true | — |
Outputs (1)
| Name | Type | Description |
|---|---|---|
| normal_map | IMAGE | — |