Implementing flat ribbon curves in Cycles again (need help)

Blender 2.8x had a strand primitive with flat normals that were removed as part of the Embree rewrite. Cycles forces rounded normals even when a normal vector is connected to a shader normal input. I need flat normals for a fur effect that I usually use in Eevee and I’m willing to put in the legwork to make it happen. Or, at the very least, the disparity needs to be documented. I’m hoping a blender dev can help me understand the powers that which I know little.

Optional reading:

The issue:


Here’s my effect: using Geometry Nodes (previously a Python addon I wrote), I copy the surface emitter face normal and apply it to the hair system material, and disable shadows on the hair object. It is a stylized effect that almost works in Cycles, except for the ugly rounded normal gradient.

Eevee

Cycles

Check my bug report for the demo file: #104275 - Cycles strands always use rounded normals even with custom Normal input to BSDF - blender - Blender Projects

It’s almost like a very roundabout way of implementing the old Tangent Normal slider in Blender Internal. This Right Click Select page claims you can use the Tangent Normal hair info node, but it stops working as soon as you groom the hair. I honestly cannot figure out what the Tangent Normal input is even useful for.

Post 1/? stay tuned to witness me wildly gyrating in Visual Studio as I comment things out and watch Blender break

1 Like

Here’s what I think I know so far. Please correct me where I’m wrong.

In Blender 4.0, curves are uploaded to the render device as points and the geometry is generated there. Normals for curves are calculated for intersections in curve_intersect.h

f (!(sd->type & PRIMITIVE_MOTION)) {
    P_curve[0] = kernel_data_fetch(curve_keys, ka);
    P_curve[1] = kernel_data_fetch(curve_keys, k0);
    P_curve[2] = kernel_data_fetch(curve_keys, k1);
    P_curve[3] = kernel_data_fetch(curve_keys, kb);
  }
  else {
    motion_curve_keys(kg, sd->object, sd->prim, sd->time, ka, k0, k1, kb, P_curve);
  }

  P = P + D * t;

  const float4 dPdu4 = catmull_rom_basis_derivative(P_curve, sd->u);
  const float3 dPdu = float4_to_float3(dPdu4);

  if (sd->type & PRIMITIVE_CURVE_RIBBON) {
    /* Rounded smooth normals for ribbons, to approximate thick curve shape. */
    const float3 tangent = normalize(dPdu);
    const float3 bitangent = normalize(cross(tangent, -D));
    const float sine = sd->v;
    const float cosine = cos_from_sin(sine);

    // This is the important line!
    sd->N = normalize(sine * bitangent - cosine * normalize(cross(tangent, bitangent)));

The sine * bitangent - cosine component is what generates the actual rounded normals, I think.

Further down in the same file:

  sd->Ng = (sd->type & PRIMITIVE_CURVE_RIBBON) ? sd->wi : sd->N;

I’ve tried various combinations of commenting out the sd->N calculation, forcing sd->Ng = sd->N, and sd->N = normalize ( … ) and none of them look quite right.

Then I fetched the old Blender 2.83 source code and if you squint at geom_curve_intersect.h you can find an early version of what is now a much more compact curve_intersect.h, as well as its code to calculate the normal:

if (sd->type & PRIMITIVE_CURVE) {
      P_curve[0] = kernel_tex_fetch(__curve_keys, ka);
      P_curve[1] = kernel_tex_fetch(__curve_keys, k0);
      P_curve[2] = kernel_tex_fetch(__curve_keys, k1);
      P_curve[3] = kernel_tex_fetch(__curve_keys, kb);
    }
    else {
      motion_cardinal_curve_keys(kg, sd->object, sd->prim, sd->time, ka, k0, k1, kb, P_curve);
    }

    float3 p[4];
    p[0] = float4_to_float3(P_curve[0]);
    p[1] = float4_to_float3(P_curve[1]);
    p[2] = float4_to_float3(P_curve[2]);
    p[3] = float4_to_float3(P_curve[3]);

    P = P + D * t;

    tg = normalize(curvetangent(isect->u, p[0], p[1], p[2], p[3]));

    if (kernel_data.curve.curveflags & CURVE_KN_RIBBONS) {
      sd->Ng = normalize(-(D - tg * (dot(tg, D))));
    }
    sd->N = sd->Ng;

… so I tried implementing that myself:

  if (sd->type & PRIMITIVE_CURVE_RIBBON) {
    /* Rounded smooth normals for ribbons, to approximate thick curve shape. */
    const float3 tangent = normalize(dPdu);
    const float3 bitangent = normalize(cross(tangent, -D));
    const float sine = sd->v;
    const float cosine = cos_from_sin(sine);

    // new code
    sd->N = normalize(-( D - tangent * (dot(tangent, D))));

… and even swapped the assignment of N and Ng like in the 2.83 source code

Spoiler alert, it didn’t work.

[ Screenshots here in a bit, my build is still compiling ]

Then I noticed in closure/bsdf.h, the normal is also being overriden:

  /* For curves use the smooth normal, particularly for ribbons the geometric
   * normal gives too much darkening otherwise. */
  *eval = zero_spectrum();
  *pdf = 0.f;
  int label = LABEL_NONE;
  const float3 Ng = (sd->type & PRIMITIVE_CURVE) ? sc->N : sd->Ng;

I’ve gotten some interesting results by setting Ng = sd->Ng, but I still get some hair strands that are randomly dark. Now where I’m stumped (I think) is on backface handling. Blender 2.83’s source code has some explicit references to backfacing curve primitives, and does Scary Math Stuff in response, again in geom_curve_intersect.h. And I think what I’m seeing is the “backside” (relative to the light source) of the hair strand at certain angles simply not receiving any light, as opposed to in Eevee where the face is lit the same on both sides.

So to sum up, I’ve got a lot of questions:

  1. The normal-related code I’ve found is in two spots: ray intersection and shader closure sampling. Is there anywhere else I should be looking?

  2. sd->Ng and sd->N are true normal (geometry) and normal, as far as I can tell. We also have sc->N in bsdf.h which I’m not sure if it’s a typo or not. What is the source of all of these? Which one comes from the shader? The reason I’m not sure if it’s a typo is because I was assuming that the normal set in the shader (closure) is the custom vector I’m trying to set, but they all seem to interact in weird ways. Setting a custom normal in the shader in stock Cycles does produce a change! … but it seems to get “overlaid” with the default rounded normal. It’s very weird and I don’t think I’m explaining it right.

  3. Is there something about raytracing that prevents the “same on both sides” effect I’m trying to get? I’ve been able to make an awful shader node that dynamically flips the normal on the “far side from camera” of a face, assuming it’s backfacing. Which in theory should help, and works as expectedish on a single plane, but I don’t think curves have any such notion of a rear-facing “far side” surface.

Thanks for reading my word salad. I know just enough about rasterizers, raytracers, and blender code structure to be dangerous.

It’s late here so I will add more screenshots tomorrow.

I also wanted to add: I can’t test for feature parity on the edited normals on the hair strands in 2.83 because the old hair system did not allow you to sample anything but color from the emitter surface.

The only change needed to get rid of the width-wise rounding is:

sd->N = normalize(-( D - tangent * (dot(tangent, D))));

in curve_intersect.h.

Removing the rounding in that direction shows that there’s still a difference between Eevee and Cycles: Eevee can replace the normal among the entire length of the strand, while Cycles is still interpolating normals along the strand.

Eevee:

Cycles:

In ALL cases, even on 3d strands, the Normal ViewLayer output shows the normal solid per-strand, like Eevee’s shading suggests.

  1. Where is the interpolation along the strand’s length coming from in Cycles?

  2. I still don’t understand why Cycles appears to “overlay” the normal data from the shader instead of replacing it, which makes me think this is happening somewhere else. What’s going on?

4 Likes

I’ve read over your posts, and did some quick digging in the code in the areas you investigated. Thank you for documenting your investigation, it gave me a good starting place for looking into this issue.

And you probably know this already, but it’s nice to get the information out there for other people.

So the important parts of the process of rendering a curve is this:

  1. Run shader_setup_from_ray and inside there run curve_shader_setup. This leads you to the /kernel/geom/curve_intersect.h file you mentioned earlier where the rounded normals are produced. Inside this file you noted two things.

    • sd->N = normalize(sine * bitangent - cosine * normalize(cross(tangent, bitangent))); generates rounded normals for the round ribbon curves.
    • sd->Ng = (sd->type & PRIMITIVE_CURVE_RIBBON) ? sd->wi : sd->N; sets the “True normals” to sd->wi for round ribbon curves. sd->wi is the the direction of the incoming ray (or some modified version of it). And this makes sense, the ribbons are “flat”, and to make sure they look like a cylinder they “rotate” to face towards the ray that hit them (E.G. They face the camera). There is a bit more nuance here, but this is good enough for understanding the concept.
  2. Now that some curve specific shading data is setup from curve_shader_setup, shading can be done. This starts with svm_eval_nodes. This parses your node tree and sets it up for rendering. Most nodes are parsed the same for triangle geometry and curves, so there’s not much to worry about here at the moment. For example, if in the shader editor your set the normal of a Diffuse BSDF to 1, 0, 0, then it will be interpreted as 1, 0, 0 in svm_eval_nodes, it doesn’t matter if the material is on a piece of “normal geometry” or a curve.

  3. Then those shaders get evaluated. This comes in two parts. Direct light sampling (integrate_surface_direct_light), and indirect sampling (integrate_surface_bsdf_bssrdf_bounce).

    • Direct light sampling is the process of picking a light, and then calculating how much light from that light reaches your object, and whether or not it’s in shadow. There’s a bunch of other stuff going on as well (E.G. Computing how much light is lost to the shader).
    • Indirect sampling is the process of just picking a semi-random ray direction (influence by the surface and material properties), and tracing a ray off in that direction until it hits something else, then Cycles does some shading and stuff over there. There’s some other stuff going on there too (E.G. Computing how much light is lost to the shader).
  • How these shaders (in combination with their geometry) gets evaluated in direct light and indirect sampling is going to impact how the curves look. So to “fix” this issue we need to figure out what inside these functions is causing the curves to look round or incorrect, then figure out where we need to adjust the code to fix it.

As implied in the text earlier, the experiments you’ve done, and the node setup, the issues come from normals. And there are three normals we have to deal with, “Normals” (sd->N), “True normals” (sd->Ng), and shader normals (E.G. BSDF->N). The “Normals” are the triangle normals that are modified by settings like “Smooth shading”. The “True normals” is the normal of the actual geometry. And The shading normals are the normals set by the shader.

So lets look at how normals are used in the Direct light and indirect sampling.

In Direct light sampling, sd->N is used a bit. So is bsdf->N. So we need to ensure both are correct.
And in indirect sampling, it seems the shading normal (E.G. BSDF->N) are used. So we need to ensure they are correct.
True normals (sd->Ng) doesn’t appear to be used for curves in either in any meaningful way.

Let’s start with the simple one. BSDF->N is correct. As mentioned earlier, svm_eval_nodes parses the shader node tree. And for many nodes it doesn’t do anything special for curves. So BSDF->N (as set in there) is correct.

So we just need to fix sd->N, by setting it equal to BSDF->N. And we get this result:

That’s a pretty good result. But there’s some issues with this approach. You can have multiple BSDFs in your node setup, each with their own normal. Or with the Principled BSDF, you can have one node which contains multiple closures, some of them having their own normals. So setting sd->N equal to BSDF->N doesn’t work properly in these cases.

So we need to figure out a different approach for this. And I’m not sure what the best approach is, and I’m not the right person to ask for that. But here’s some ideas:

  1. sd->N as used in direct light sampling seems to be one of the main issues. So instead of using sd->N, maybe a shader can be picked and the normals from that can be used in the calculations there? I’m not sure how hard this is, or the impact it will have on things like performance, or physical correctness.
  2. Give users an option to override sd->N for curves, maybe with some geometry nodes and other settings?
  3. Something else.

I should note, I went with the sd->N = BSDF->N approach because it gives you the results you want. However a different approach maybe also work.

2 Likes

sc->N comes from the nodes in the shader editor. sc is short for “shader closure”. In my previous comment I talked about bsdf->N. This is the same as sc->N in this context.


There are 3 separate normals that Cycles works with. Normals (sd->N), True Normals (sd->Ng), and shader normals (bsdf->N). In your scene, only “Normals” (sd->N) and “shader normals” (bsdf->N) have a meaningful impact.

The shader normals (bsdf->N) are the normals you set in the shader editor. The normals (sd->N) is what you’re modifying with your code changes in curve_intersect.h, and it is unaffected by your node setup in the shader editor.

When these two values differ from each other, you get extra shading. And that’s what’s causing the issue for you. Your shader normals (bsdf->N) and normals (sd->N) are different from each other which is causing a shading effect. For your use case, you want the normals (sd->N) to be equal to your shading normals (bsdf->N) to create a flatter appearance.

I’ve had a discussion with @sentharn somewhere else. It seems there are two different things they want.

  1. Add an option for flat ribbons. This is relatively simple. Just switch between round and flat normals in curve_intersect.h based on a setting.
  2. Fully override the normals to get a result like the one below. This is a bit more involved, and I’m not sure the best approach to doing this in a user friendly manner.

5 Likes

Thank you so much, this was the exact kind of insight I needed to experiment. I ended up adding several assignments to overwrite sd->N in the diffuse, glossy, and SSS closures in Principled BSDF, gating them behind a check on primitive type. For example:
image

(N is the value read from the Normal input to the shader, in this case.)

This makes exactly what I want.

Backing up, I think the ribbon shape isn’t really the problem here, but the ability to set the geometry normals for curve primitives. I tried the upcoming Set Curve Normal node in Geometry Nodes for 4.1. Like we discussed elsewhere, it turns out that that value is not read by the renderer at all, at least not in Cycles.

I’m not sure what the next step is. I’d love to see this ability land in mainline but I don’t know where the best place is. I’ll chat with some of the devs to see how viable it is.

Thanks again. This was great insight into the inner workigns of Cycles.

5 Likes