Proposal: Analytic Derivatives for Procedural Noise

Hey dear friends, developers, and contributors!

When I worked on the Flow movie project, I used the sum of sine waves for water rendering. One part in geonodes - to generate the mesh and displace the geometry, and the other part in shaders - to generate fragment normals. That was necessary so the water waves look pixel-perfect, even when the water geometry was coarse.
And getting the normals for a sine wave is easy.

Recently I switched the wave generation to the new Gabor noise. The Gabor texture waves look stunning. Unfortunately, the performance is… not. Normal generation is the main bottleneck here. Each wave sample uses 3 texture lookups, and with 16 samples, that’s 48 procedural lookups per pixel. Not ideal.

ā€˜Bump to Normal’ nose is one solution, but it relies on partial derivatives.

:small_blue_diamond: Partial Derivative Normals

These are computed using the partial derivatives of the pixel value with respect to screen-space or UV-space coordinates, using functions like dFdx() and dFdy().

Advantages:

  • Automatic: Rely on built-in GPU functions.
  • Flexible: Works for basically any texture where analytic normals are unavailable.

Disadvantages:

  • Approximate: Based on finite differences of nearby fragments—less accurate than analytic normals.
  • Unreliable: The result depends on the screen-space sampling rate and can be noisy under certain conditions.

:small_blue_diamond: Analytic Normals

These are computed from an explicit mathematical representation of the surface. For example, if a surface is defined by a function or a parametric equation, its normal can be derived using vector calculus (like the cross product of tangent vectors).

Advantages:

  • Exact: Derived directly from the underlying function, so they are as accurate as the model allows.
  • Stable: Do not rely on sampling or finite differences, so no approximation error.

So, I propose to include analytic derivatives for Gabor noise and, ideally, also for other procedural textures. This would make per-pixel lighting calculation for procedural surfaces fast and easy.
For geo-node textures, the feature is not required as the normals are calculated directly from vertex normals.

Here are some examples of what I am talking about:

Gabor:

Simplex 3D:

(I have more links to, basically all noise types +(2D, 3D and 4D), but I can only add 2 links right now)

I live in shaders, so I am proficient at implementing that myself, but I would like to discuss this further.

MārtiņŔ UpÄ«tis

32 Likes

Hey!

I don’t see a huge issue of exposing sockets that gives derivatives, even if the calculation might be somewhat expensive. We have technology which skips evaluation of unused sockets so simple cases will not be slower. Making complex cases possible is probably will be understandable that they require more computations. But surely would be good to hear from the rendering (and compositing) teams here.

The thing I am not fully sure: are you volunteering to take care of implementation, and just want some general answer from the rendering team yay-or-nay? We can give pointers in the code, but it might be a bit more trickier than ā€œjustā€ a shader. Not to scare you away, but more to set up expectations of the code complexity (it isn’t too bad, but might take a bit to get familiar).

1 Like

Having recently spent some time inside Voronoi code, my impression is that most of ā€œcomplexityā€ (or ā€œannoyanceā€?) is that each node has four separate implementations: Cycles, Cycles OSL, Blender GPU (for Eevee / GPU comp), and Blender CPU (for CPU comp / texture nodes). But if you know what kind of changes to do, then it is ā€œjustā€ a matter of doing it in four different places, in three different programming languages (C++, GLSL, OSL) :slight_smile:

3 Likes

I’d be very good to do not add any kind of hardcoded function analize into existed nodes. Instead, at some point, we should be able to just add such functionality to the function nodes to be able to convert any generic math node group into some other group. If someone really need this – can make such a python auto derivativer of a math nodes (probably – 1-2 days task if use wolfram) right now.

In this case I’d imagine you’d also need to add sockets. So it will be a couple of extra places to modify. But indeed it is not too bad.

The proposal is about exposing analytic derivatives (aka, computed via formula). It has advantages over auto-differentiation (which is only available in Eevee, and OSL, but not in SVM), and it is not about analyzing any of the node graphs.

we should be able to just add such functionality to the function nodes to be able to convert any generic math node group into some other group

This sounds very vague to me. And it is also not something render engines like Cycles could utilize.

1 Like

That does not sound that bad at all. I can do that.

Regarding performance, the analytic derivatives come basically ā€˜for free’ as the data are mostly available during the ā€˜value’ calculation.

I was just asking if my proposal sounds logical and if no one else is working on something like this.

I can prepare a patch and do some testing.

6 Likes

I did not mean anything related with nodes backends, this should be blender core feature related with functional nodes generation.

This is, basically, it — 3 extra lines.
For a 3D variant, it would need an extra derivative for z though. I am not sure how to pack 5 output values yet.

float4 compute_2d_gabor_kernel(float2 position, float frequency, float orientation)
{
  float distance_squared = length_squared(position);
  float hann_window = 0.5 + 0.5 * cos(M_PI * distance_squared);
  float gaussian_envelope = exp(-M_PI * distance_squared);
  float windowed_gaussian_envelope = gaussian_envelope * hann_window;

  vec2 frequency_vector = frequency * vec2(cos(orientation), sin(orientation));
  float l = dot(position, frequency_vector);
  float angle = 2.0 * M_PI * l;
  vec2 phasor = float2(cos(angle), sin(angle));
  
  // Analytic gradient of windowed Gabor kernel
  float2 grad_gauss = -2.0 * M_PI * position; // gradient of Gaussian envelope
  float2 grad_cos = -phasor.y * (2.0 * M_PI) * frequency_vector; // derivative of cos(2Ļ€l)

  phasor = windowed_gaussian_envelope * phasor;
  vec2 grad = windowed_gaussian_envelope * (grad_gauss * phasor.x + grad_cos);

  return float4(phasor, grad); //real, imag, d/dx, d/dy
} 
6 Likes