Optimizing nodes for EEVEE performance

EEVEE is constantly getting incremental performance buffs. However! As nodetrees grow complex, compile times and general render performance can still quickly spiral to the point any small tweak is a sluggish one.

Nodes are a nice middle ground of abstract yet powerful, though how they work at a deeper level is kinda opaque, and existing resources are limited at best. Researching into these concepts has led to… Some results, of which being:

  1. Unconnected nodes are (usually) not evaluated.
    • This makes sense. In some contexts where a value can be discarded (like when an input becomes grayed out), it often is and the trailing nodes ignored.
  2. Some nodes are more expensive than others.
    • Again, makes sense. Mix Shader nodes are a prime high-cost example.
    • At a more basic level, Addition/Subtraction is generally cheaper than Multiplication, with Division being the heaviest. Most other stuff is just built upon those basic functions.
  3. All node groups get expanded.
    • In other words, groups are purely organizational and have no performance benefit.
  4. Compilation tries to optimize for constants.
    • This ties into no. 3, in that an issue with “reusing” previous compilation work doesn’t, well… work. Consts have to be individually optimized for.

All quite handy to know, but there are still a lot of blanks I’m interested in. Particularly:

  1. Is it correct to assume there’s no compilation impact for nodes connected to a disabled socket such as this?

  2. Does a Mix node configured like above also bypass the weighted average calculation?

  3. What compilation impact do Mix and other basic Math nodes have? Further, do the new Menu Switch nodes contribute to compile times at all?

  4. Do “useless” conversions between types get optimized out? This would appear to be the case, given the following:

While somewhat technical, the topic has interested me for a while, mostly since I’ve been incrementally building up a library of various nodegroups that my best guess on doing optimally lies somewhere between “random disjointed tips” and “vibes”

Yes the data is then passing through and A socket is completely discarded !

My opinion on that is that while these operation do have some performance cost obviously, it’s really not here that you’ll be able to save time.
Even stuff like coloramp in eevee legacy was more expansive than using a map range node but even with 10 of them in a graph, using map range didn’t show a noticeable impact.
That said, I’m generally more interested in the evaluation time once it’s compiled than the compilation so there might be subtleties there.

Anyway, stuff that cost way much more are elsewhere : put a 4d noise texture with detail 7 through a bump node and everything you could save by taking care of mix nodes avoiding too many color to float conversion is probably going to feel irrelevant.

indeed mixing shader is probably where you can save a lot of resources. A good rule of thumb is that generally you only need one shader type . Like if you have several principled you can definitely save some computation there by reducing that to 1 at the cost of a more complex graph.

Some combination might be costly : recently I used Transluscent shader on a alpha blended material and evaluation time was more than 10 times longer than the same with dithered alpha.

Shader to RGB is also very slow on eevee next so if you’re in NPR stuff that’s something you should definitely look into.

From there I generally look into procedural textures, trying to reduce the detail slider as much as possible and use 2D noise when possible … I think image texture size also have an impact…

Anyway, I see Gorion writing stuff and they are one of the most knowledgeable there so I’ll wait to see what they wrote to learn a few things from that !

I havent tested it via debugger or looked at code, but You can somewhat make educated guess based on how Blender behaves.

Eg. Make heavy shader and When You connect anything to disabled input of Lerp(mix) or not-selected switch, You can see there is no compilation. If there is no compilation, then those nodes are discarded in preprocessing, so there is no way that They can affect performance.

You kinda didnt showed any conversion in this picture.
In eevee afaik calculations wise, there are currently Float and Float4 types. Vector/Color/Shader types are more a UI thing.

Also conversion Vector to Float is not “optimised”. Because it sums(or averages, i dont remember) values instead of using only one dimension.

So instead of that implicid conversation, more optimized stuff, might be using separate one dimension instead of using combined.

Yes. Different nodes can have vastly different performance cost.
For math nodes You are quite right. Division is heavier than rest You mentioned, but that’s not near heaviest math node.
Division is RCP (reciprocal - 1/x) and multiply that is 16+4=20 clock cycles on GCN3, while Multiply or Add are only 4 gpu cycles.
But there are heavier math instructions:

HLSL Intrinsic Radeon Cycles Description / Human Readable Name
abs 0 Absolute Value
acos 52+ Arccosine
all 16 All Components True
any 12 Any Component True
asin 52+ Arcsine
atan 60+ Arctangent
atan2 64+ Two-Argument Arctangent
ceil 4 Ceiling (Round Up)
clamp 8 Clamp Value to Range
clip 0 Discard Pixel if Negative
cos 20 Cosine
cosh 40 Hyperbolic Cosine
cross 20 Cross Product
ddx 16+ Partial Derivative wrt X
ddy 16+ Partial Derivative wrt Y
degrees 4 Convert Radians to Degrees
distance 8 Euclidean Distance
dot 4 Dot Product
exp 20 Base-e Exponential
exp2 16 Base-2 Exponential
floor 4 Floor (Round Down)
fmod 24 Floating-Point Modulus
frac 4 Fractional Part
fwidth 20+ Sum of Absolute Derivatives
isfinite 0 Test for Finite Value
isinf 12 Test for Infinity
isnan 0 Test for NaN (Not a Number)
ldexp 4 Load Exponent
length 16+ Vector Length
lerp 4+ Linear Interpolation
log 12 Natural Logarithm
log2 8 Base-2 Logarithm
log10 12 Base-10 Logarithm
max 4 Maximum Value
min 4 Minimum Value
mul 4 Matrix / Vector Multiplication
pow 28 Power
radians 4 Convert Degrees to Radians
reflect 4 Vector Reflection
refract Vector Refraction
round 4 Round to Nearest
rsqrt 16 Reciprocal Square Root
saturate 0–4 Clamp to [0, 1] Range
sign 12+ Sign Function
sin 20 Sine
sinh 44 Hyperbolic Sine
smoothstep 20+ Smooth Hermite Interpolation
sqrt 16 Square Root
step 16 Step Function
tan 56 Tangent
tanh 64 Hyperbolic Tangent

Also other Nodes can be much much heavier than simple math nodes.
ShaderToRGB would be the heaviest - because it renders whole thing with lightning once, and then uses that render result as texture.

But as maybe surprising relatedly heavy node for someone might be: Texture node.
Texture sampling itself is no calculations heavy, but its “heavy” because GPU might be waiting for texture that is in VRAM, so it would be loaded into cache, that can take hundreds of gpu cycles.
–edited out wrong part about ColorRamp –

And that waiting for is wasted time, if GPU does not have nothing else to do.
So during rendering, those two setups can have essentially same performance:


But all those things i said is microptimisations. Things You do when You are making shaders that have to work on weak GPUs with high frametime, like mobile phone GPUs in realtime 60+ FPS because You do gamedev stuff.¹
Honestly most of that stuff is not even measurable inside Blender unless You abuse is by using very very heavy shader.
And compilation time wise, those things dont really change anything besides using Static Switching to cut of part of node tree via material variations.
Shader compiler will any-ways try hard o find a ways to optimise Your Material without changing result. So it will use Multiplication instead of Division if what You did was division by constant, without You even knowing, or needing to do anything.

¹ - and if You care about that gamedev shader optimizations i recommend this video: https://www.youtube.com/watch?v=y0QASid1v8w

Interesting! I had long since weaned myself off the Color Ramp purely for functional reasons (Map Range and HSV is often a better combo), but that’s good to know to avoid for performance too.

Also really appreciate the table! I’ll absolutely be saving that for reference. Clamping being higher than rounding is pretty interesting.

Seeing as ShaderToRGB is as expensive as it is -which would explain some things- I may look into creating “indev” materials that skips that stuff while tweaking, since I do indeed do a fair bit of NPR. Probably the correct way to go about it instead of trying to save a math node here and there.

An aspect of materials to which you should really pay attention for performance is image textures. If you aren’t careful about them, they can easily cause half of your render time. It’s easy to overlook them if you are new to Eevee, but they take a much bigger percentage of render time than they do with Cycles.

If you make a scene out of imported assets, one of the best things you can do to improve Eevee render times is to reduce the resolution of image textures where possible and to unplug anything that doesn’t contribute much.

But thats only clamping between 0..1, using clamp node with custom values does have a cost.
It is free in performance, because it is just modifier to other operations, and not new instruction.
In blender they added it nicely as those checkboxes.


- though, clamp node without changed values is also propably free in blender

But in GPUs its called “Saturate”, if You ever look at game engines materials and wondered what “Saturate” is, its just clamp01

Yeah, it’s really not about the nodes in the middle- major slowdowns come from inputs; 4D high detail Voronoi, giant images, etc, will deoptimize more than any math tweaks

Correction!

Sozap is right, they did changed it to not use texture! Now it uses smoething called colorband, so its much much faster. Anc closer to something that maprange does.

Though, it might be still abused. But unless You use dozens of them, it does not matter.

It may be very possible that Shader To RGB become obsolete soon, obviously it’s hard to tell about performance but it’s very likely to be much better, so maybe don’t invest too much energy circumventing that…

First off , it’s always great to read you and always insightful ! I think you pointed to the right thing when weighting these potential micro-optimization with the big picture of working in blender.
I’ve ran a bunch of test to see where I can save noticeable performance and never be too much bothered outside closures, procedural noise/voronoi, bump stuff like that…

Obviously you have deeper knowledge so you might see more than I do, but since implementing these optimization takes time it’s worth thinking beforehand if you’re putting the money in the right place. Obviously in the context of a realtime game especially on low hardware I guess everything is good to take, in the meantime in this case there is no way you’ll do stuff we do in blender like a fully procedural realistic wood shader or stuff like that. When doing optimization unless you’re well organized with a profiler and stuff like that it’s very easy to miss the right spot and focus on stuff that have a very low contribution to the end result … and live what takes most of the ressources completely untouched…

It’s often very difficult to understand what affects EEVEE performance with shaders. Complex shaders with lot of operations = ok performance, sometimes (not always). Basic shader with a single (not so complex) operation: so SLOW TO KILL everything.

For me.. if it works it works: if it doesn’t .. i try again from scratch with a different approach (or abandon the whole idea if nothing works ).

I’ve been really looking forward to see where the NPR project goes, though it definitely has been a wait… But yeah, probably a temporary fix at most

Quite true, haha. This thread has definitely been good for correcting my expectations. It’s all too easy to get stuck in a relatively inconsequential loop of trying to make something “perfect”

Yeah definitely, part of the problem is that most of the time we operate blindly without knowing where the real issues are and what impact a change has on something. Therefore it’s very tempting to try to fix every possible thing…

a good way to measure your change is likely to guide you on the right path as after a while you see where your efforts pays of and where it was unnecessary…

and speaking of, Yes, thats probably heaviest computationally “regular” node (so not ShaderToRGB, or AmbientOcclusion or BSDFs like Principled)

It does not use textures, so it does not takes VRAM and memory bandwith, and GPU does not have to wait for textures. But on the other hand to compute 3D noise fBM with a lot of octaves (Detail parameter in node) is alot of various instructions (dot, floor, frac, sin).

The way Blender’s shader code is composed is somewhat confusing:
gpu_shader_material_noise.glsl
gpu_shader_material_tex_noise.glsl

so heres what LLM says it results:

GLSL Code for Noise Texture
// Pseudo-random hash function for 3D space
vec3 noise_hash(vec3 p) {
    p = vec3(dot(p, vec3(127.1, 311.7, 74.7)),
             dot(p, vec3(269.5, 183.3, 246.1)),
             dot(p, vec3(113.5, 271.9, 124.6)));
    return fract(sin(p) * 43758.5453123);
}

// Basic 3D Value Noise
float noise_value(vec3 p) {
    vec3 i = floor(p);
    vec3 f = fract(p);
    
    // Smooth interpolation curves
    vec3 u = f * f * (3.0 - 2.0 * f);

    // Mix 8 corners of a cube
    float n000 = dot(noise_hash(i + vec3(0.0, 0.0, 0.0)), f - vec3(0.0, 0.0, 0.0));
    float n100 = dot(noise_hash(i + vec3(1.0, 0.0, 0.0)), f - vec3(1.0, 0.0, 0.0));
    float n010 = dot(noise_hash(i + vec3(0.0, 1.0, 0.0)), f - vec3(0.0, 1.0, 0.0));
    float n110 = dot(noise_hash(i + vec3(1.0, 1.0, 0.0)), f - vec3(1.0, 1.0, 0.0));
    float n001 = dot(noise_hash(i + vec3(0.0, 0.0, 1.0)), f - vec3(0.0, 0.0, 1.0));
    float n101 = dot(noise_hash(i + vec3(1.0, 0.0, 1.0)), f - vec3(1.0, 0.0, 1.0));
    float n011 = dot(noise_hash(i + vec3(0.0, 1.0, 1.0)), f - vec3(0.0, 1.0, 1.0));
    float n111 = dot(noise_hash(i + vec3(1.0, 1.0, 1.0)), f - vec3(1.0, 1.0, 1.0));

    float nx0 = mix(mix(n000, n100, u.x), mix(n010, n110, u.x), u.y);
    float nx1 = mix(mix(n001, n101, u.x), mix(n011, n111, u.x), u.y);
    
    return 0.5 + 0.5 * mix(nx0, nx1, u.z);
}



// Fractal Noise (FBM) mimicking Blenders Detail and Roughness parameters
float noise_texture(vec3 co, float scale, float detail, float roughness) {
    vec3 p = co * scale;
    float sum = 0.0;
    float freq = 1.0;
    float amp = 1.0;
    float total_amp = 0.0;

    int octaves = int(clamp(detail, 0.0, 15.0));
    
    // Base octaves
    for (int i = 0; i <= octaves; i++) {
        sum += noise_value(p * freq) * amp;
        total_amp += amp;
        freq *= 2.0;          // Lacunarity fixed at 2.0 like Blender default
        amp *= roughness;     // Roughness controls falloff per octave
    }

    // Handle fractional detail (smooth transition for non-integer detail levels)
    float fractional_detail = fract(detail);
    if (fractional_detail > 0.0 && octaves < 15) {
        float next_octave = noise_value(p * freq) * amp;
        sum += next_octave * fractional_detail;
        total_amp += amp * fractional_detail;
    }

    return sum / total_amp;
}

If LLM does not lies to me its something like that:

Detail Setting (Octaves) Estimated ALU Instructions per Pixel Performance Impact
Detail = 0 (1 octave) ~50 instructions Very lightweight
Detail = 2 (3 octaves) ~150 instructions Low / Standard
Detail = 4 (Default) ~250 instructions Moderate
Detail = 8+ (High detail) ~500+ instructions Heavy (can cause lag on high-res viewports if scaled across many objects)

Those are computation instructions, but that obviously also slow down compilation.

And we can actually somewhat check it using GPU debugger to look up what GPU driver gets from Blender:


But because i dont look at what Blender generates, but what GLSL code generated from dissasembled SPIRV code that was already optimised by GPU drivers from actual shader code

  • it does not represent actual lines or instruction count, but it might rough idea of how much it adds to compilation and how much it might cost performance.

so:

Emission Shader connected directly Output results in 2073 lines (2207 SPRV ops)
(6 “add” nodes connected to color adds only line GLSL line, but results in 2244 Ops total)

Principled Shader connected to Output gives: 3461 lines


Single Noise Texture gives: 4472 lines
and 4x Mixed Noise Textures 11670 lines


So as You guys see, adding single noise texture might out weight those single math optimizations You guys might make.

So what can You do?

Emm.. Nothing? And just eat the cost :stuck_out_tongue:

RTX 5070 can do theoretical 30 trillion instructions per second,
So theoretically that texture noise if it would fill whole screen, it would take about 0.003 ms on Full HD or ~0.01 ms for 4K image.
Real number is worse, but probably only by about 50%.

Because EEVEE uses multiple sampling,that get multiplied by sample count.
So for 4K render with 64 samples would be 1.28ms if my napkin math is correct (it probably is not)

In Gamedev we actually use volumetric noise in some cases, but in Unreal or Unity there are some lighter versions that use 3d textures (not avaliable currently in Blender).

What else can You do?


If what You do is only using scalar value, then dont use Color output, because thats just making 3 times calculation to later avarage it out.

Lower dimensions if You dont need more.

Lower Octaves. I guess that does not lowers compilation time, but it might (i dunno) reduce performance cost in eevee, in Cycles it does. But if You connect something to other than constant -cost might go up, dunno.

Reuse already calculated stuff if You are doing essentially the same stuff in other place in same material.

Oh anyways, when we talk about performance of shading in rasterizers like EEVEE, cost of its is directly proportional of how much 2x2 pixel quads each rendered triangle takes on screen.

So efforts with optimizing shaders should be somewhat correlated to how much that material is visible on renders.

A master at work ! Thanks for taking the time to detailing all that !

Not sure if that gonna be in docs, but newly added in 5.3 Switch and IndexSwitch will be working in kinda expected way:

There are two main cases to consider:
* Index is constant value: The Index Switch node is completely optimized away
and only the used input is evaluated. It works with all socket types.
* Index is varying: The Index Switch node is replaced by a couple of math and
mix nodes. This currently works for float, int, bool, vector, color and shader
sockets (but not bundle, closure and menu sockets).

Nice, was wondering when those were gonna make their way over from GeoNodes.

I am curious what compilation overhead branch logic adds, if any- it makes sense there’s no runtime impact. Speaking of, how do you go about profiling this stuff?

AFAIK In case of constant value of switches, so nothing is connected to Switch/Index/Menu:
Its done in inlining shaders, so kinda preprocessing before compilation. It just work as If You were just changing whats connected to socked and there is basically no overhead more than that.

But when You do connect something to inputs its kinda worst case scenario and it works like that: