Skip to content

Conversation

@shanecelis
Copy link
Contributor

@shanecelis shanecelis commented Oct 22, 2024

Objective

Avoid a premature normalize operation and get better smooth normals for it.

Inspiration

@IceSentry suggested face_normal() could have its normalize removed based on this article in PR #16039.

Solution

I did not want to change face_normal() to return a vector that's not normalized. The name "normal" implies it'll be normalized. Instead I added the face_area_normal() function, whose result is not normalized. Its magnitude is equal two times the triangle's area. I've noted why this is the case in its doc comment.

I changed compute_smooth_normals() from computing normals from adjacent faces with equal weight to use the area of the faces as a weight. This has the benefit of being cheaper computationally and hopefully produces better normals.

The compute_flat_normals() is unchanged and still uses face_normal().

Testing

One test was added which shows the bigger triangle having an effect on the normal, but the previous test that uses the same size triangles is unchanged.

WARNING: No visual test has been done yet. No example exists that demonstrates the compute_smooth_normals(). Perhaps there's a good model to demonstrate what the differences are. I would love to have some input on this.

I'd suggest @IceSentry and @stepancheg to review this PR.

Further Considerations

It's possible weighting normals by their area is not definitely better than unweighted. It's possible there may be aesthetic reasons to prefer one over the other. In such a case, we could offer two variants: weighted or unweighted. Or we could offer another function perhaps like this: compute_smooth_normals_with_weights(|normal, area| 1.0) which would restore the original unweighted sum of normals.


Showcase

Smooth normal calculation now weights adjacent face normals by their area.

Migration Guide

Add `face_area_normal()` function. Change `compute_smooth_normals()`
from computing normal from adjacent faces with equal weight to
use the area of the faces as a weight.

One test added which shows the bigger triangle having an effect on the
normal.
@tychedelia tychedelia added C-Feature A new feature, making something new possible A-Rendering Drawing game state to the screen S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Oct 22, 2024
@shanecelis
Copy link
Contributor Author

I implemented the compute_smooth_normals_with_weights(|normal, area| 1.0) simply to demonstrate what the visual difference is. I have not committed that function in this PR. I altered the "custom_primitives" example and make the mesh low resolution for testing and demonstration purposes for this PR. Here is an image of the variants:

normals-still

In the top left, "low poly" shows the flat normals, which are unchanged by this patch. In the top right, "explicit normals" are from the heart builder; they should be considered best. Bottom left is "unweighted"; this is what compute_smooth_normals() did prior to the patch. Bottom right is "weighted"; this is what compute_smooth_normals() does after the patch.

It is a little difficult to see any changes side to side, so I made a gif where the changes present do tend to pop out more.

normal-demo-4

For the case above, I'd judge the "weighted" image to match the "explicit normals" image slightly better than the "unweighted." The visual differences have been relatively slight.

Because most of the differences are slight though, I would not be inclined to include compute_smooth_normals_with_weights() as part of bevy's API.

(b - a).cross(c - a).into()
}

pub(crate) fn face_normal(a: [f32; 3], b: [f32; 3], c: [f32; 3]) -> [f32; 3] {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could probably reuse face_area_normal here and just call .normalize() on the output.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the body of face_normal()? I thought about that, but I figured it'd be an extra function call. Maybe that's C thinking though. However, I'd have to call Vec3::from() again to call normalize from face_area_normal() since we'd outputting [f32; 3].

Happy to do it if you think it's worth it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, right, I missed that it outputs a [f32:3]. Although, I wouldn't be surprised if the compiler just optimizes all of it out. Same thing for the function call. Could add an inline annotation if it's an issue. Doesn't really matter though, it's just a few lines so duplication is fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll add the inlines and make them pub.

Copy link
Contributor

@IceSentry IceSentry left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know how I feel about the results being slightly different. I guess it makes sense, but I'm wondering if it could break in some edge cases. At least if it breaks it's not too hard for a user to write a custom function that fixes it.

face_normal and face_area_normal could probably be pub. It doesn't hurt anything to expose that to users.

@IceSentry IceSentry added S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it and removed S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Dec 1, 2024
@alice-i-cecile alice-i-cecile added this pull request to the merge queue Dec 3, 2024
Merged via the queue into bevyengine:main with commit f375422 Dec 3, 2024
30 checks passed
ecoskey pushed a commit to ecoskey/bevy that referenced this pull request Jan 6, 2025
# Objective

Avoid a premature normalize operation and get better smooth normals for
it.

## Inspiration

@IceSentry suggested `face_normal()` could have its normalize removed
based on [this article](https://iquilezles.org/articles/normals/) in PR
bevyengine#16039.

## Solution

I did not want to change `face_normal()` to return a vector that's not
normalized. The name "normal" implies it'll be normalized. Instead I
added the `face_area_normal()` function, whose result is not normalized.
Its magnitude is equal two times the triangle's area. I've noted why
this is the case in its doc comment.

I changed `compute_smooth_normals()` from computing normals from
adjacent faces with equal weight to use the area of the faces as a
weight. This has the benefit of being cheaper computationally and
hopefully produces better normals.

The `compute_flat_normals()` is unchanged and still uses
`face_normal()`.

## Testing

One test was added which shows the bigger triangle having an effect on
the normal, but the previous test that uses the same size triangles is
unchanged.

**WARNING:** No visual test has been done yet. No example exists that
demonstrates the compute_smooth_normals(). Perhaps there's a good model
to demonstrate what the differences are. I would love to have some input
on this.

I'd suggest @IceSentry and @stepancheg to review this PR.

## Further Considerations

It's possible weighting normals by their area is not definitely better
than unweighted. It's possible there may be aesthetic reasons to prefer
one over the other. In such a case, we could offer two variants:
weighted or unweighted. Or we could offer another function perhaps like
this: `compute_smooth_normals_with_weights(|normal, area| 1.0)` which
would restore the original unweighted sum of normals.

---

## Showcase

Smooth normal calculation now weights adjacent face normals by their
area.

## Migration Guide
github-merge-queue bot pushed a commit that referenced this pull request Jul 17, 2025
# Objective

Closes #18383 

## Solution

Given the 2 votes (me and @komadori ) for making angle-weighted normals
the default, I went ahead and did so, moving the face-weighted
implementation to the new `Mesh::compute_face_weighted_normals` method.
I factored out the common code between both into
`Mesh::compute_custom_smooth_normals`, which I went ahead and made
public to make it easier for users to add any other weighting methods
they might come up with.

If any of these decisions are undesirable for any reason, please let me
know and I will gladly make modifications.

## Testing & Showcase

I made a demo that exaggerates the difference at
[Waridley/bevy_smooth_normals_comparison](https://github.com/Waridley/bevy_smooth_normals_comparison).
Screenshots included in the readme.

Another way it could be demonstrated is via scaling a mesh along its
normals, like for generating outline meshes with inverted faces. I'd be
willing to make a demo for that as well.

I also edited and renamed the `compute_smooth_normals` tests to use
face-weighted normals, and added a new test for angle-weighted ones
which validates that all normals of a unit cube have each component
equal to `(±1 / √3) ± f32::EPSILON`.

## Migration Guide

#16050 already did not mention a migration guide, and it is not even in
a stable release yet. If this lands in a 0.16 RC, updating from RC1
would probably not require any changes in the vast majority of cases,
anyway. If someone really needs face-weighted normals, they can switch
to `.compute_face_weighted_normals()` or
`.with_computed_face_weighted_normals()`. And if for some reason they
really liked the old count-weighted implementation from 0.15, there is
an example in the docs for `compute_custom_smooth_normals`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Rendering Drawing game state to the screen C-Feature A new feature, making something new possible S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

5 participants