rust-simd-noise icon indicating copy to clipboard operation
rust-simd-noise copied to clipboard

Document domain and range of primitive functions

Open Ralith opened this issue 5 years ago • 23 comments
trafficstars

Experimentally I see that e.g. fbm_3d with parameters similar to the defaults seems to return values mostly in the range of -0.1..0.1. simplex_3d likes even smaller magnitudes. This is very confusing, as traditionally these noises are presented as having a range of 0..1 or -1..1. I'm guessing the domain is such that an interesting sample must cover multiple integers' worth of space, but even sampling quite large domains doesn't seem to get me a sensible range. The scaled high-level functions appear to naively remap the min/max that was actually generated, which doesn't seem sensible when I want to generate consistent results across multiple adjoining regions.

I'm sure I'm missing something stupid here, but if the docs spelled these things out it would save me a great deal of confusion all the same.

Ralith avatar Jul 07 '20 04:07 Ralith

simplex::simplex_1d(0.3, 0) returns 1.4400741, which is well outside of any conventional range for simplex. Is this intended?

Ralith avatar Jul 07 '20 23:07 Ralith

So many noise libraries will do a multiply and add at the end to bring the output to some range of 0..1 or -1..1. However once you start doing FBM or similar, with various parameters, that range goes out the window anyway. So I just leave that step out, and the scaling functions are provided to scale the output to whatever range you want.

jackmott avatar Jul 08 '20 00:07 jackmott

So many noise libraries will do a multiply and add at the end to bring the output to some range of 0..1 or -1..1. However once you start doing FBM or similar, with various parameters, that range goes out the window anyway.

How so? If you compose or transform a noise function, you're composing/transforming the ranges too, and can therefore normalize them into a well-defined range afterwards.

So I just leave that step out, and the scaling functions are provided to scale the output to whatever range you want.

These don't work. I need consistent results between multiple samplings of nearby coordinates. Taking the min/max of the returned value and scaling that range to fit will result in discontinuities, not to mention being a chunk of extra CPU work.

I'm mapping noise into a fixed-point range for compact use on the GPU, so having well defined ranges is very important to making effective use of available precision.

Ralith avatar Jul 08 '20 01:07 Ralith

Then you will need to experimentally determine, for whatever parameters you are using, the scaling and offset necessary to make the range -1..1 (or whatever you want it to be) and apply it.

jackmott avatar Jul 08 '20 01:07 jackmott

That's frustrating, because it's difficult, unreliable, and absolutely not necessary--all these functions have analytically well-defined ranges, and it's entirely possible for the library to expose this, as most other noise libraries do.

Ralith avatar Jul 08 '20 01:07 Ralith

If you can point me to a nosie library that does this the way you expect I'll see what I can do. The ones I have seen simply determine experimentally a scale/offset that only works in the case of a single octave.

jackmott avatar Jul 08 '20 01:07 jackmott

By other implementations, I'm referring principally to implementations of the simplex noise primitive, all of which I've reviewed provide noise in the -1..1 or 0..1 range. For example, see Simplex noise demystified.

For compositions of primitives, it's a matter of analyzing the composition. For example, FBM noise as implemented in this crate is the summation of N simplex octaves, where each octave is scaled by gain^n. Its bounds are therefore the simplex bounds with each extreme multiplied by (1 + gain + gain^2 + ... + gain^n). This can be computed cheaply in the course of the existing summation, and used to normalize the results.

Ralith avatar Jul 08 '20 01:07 Ralith

I worked as a shader writer & TD for 15 years in the VFX industry. Professional renderers normalize their noise functions (all of them) over -1..1 or 0..1. And when you add octaves it's then easy to make sure you stay within these ranges which are important for e.g. calculating displacementbound & co, indexing into a color ramp etc.

See e.g. OSL's simplex noise implementation.

Maybe "other noise libraries" are a bad reference?

I would rather look to uses of such noise libraries in professional renderers used by people paid extremely well to produce that kind of pictures you see on you favorite summer blockbuster or streaming sci-fi/fantasy series. ;)

virtualritz avatar Jul 08 '20 09:07 virtualritz

I'll definitely take a stab at this as time allows, it would make my life easier too. Also, PRs are welcome!

jackmott avatar Jul 08 '20 17:07 jackmott

Awesome to hear that, thanks! I've been reviewing the literature and various reference implementations in the hopes of getting a better handle on this and #24. Stefan Gustavson's C implementations are very well-commented and readable, but regrettably the scaling factors used there don't have derivations attached and this crate's implementation seems to have diverged somehow such that they no longer match; e.g. his sdnoise1 calls for scaling by 1 / (8*(3/4)^4), which empirically produces a suspiciously round range of ±7/8 in this crate's simplex_1d impl.

Ralith avatar Jul 08 '20 18:07 Ralith

As of #27 the fundamental primitives have well-defined ranges, but the higher-level interfaces like fbm do not. I don't presently plan to address them personally, but they should be comparatively straightforward based on the principle that d/dx (f(x) + g(x)) = d/dx f(x) + d/dx g(x), and d/dx c * f(x) = c * d/dx f(x).

Ralith avatar Jul 18 '20 17:07 Ralith

Hey! I'm running into this issue currently. For now, I think I'll experimentally generate a huge number of noise values with my preferred settings, then hardcode the results to avoid the runtime cost of re-calculating the bounds.

This is certainly less than ideal, but I don't have the knowledge of noise functions to understand how their parameters affect their range.

Naively, I think the gain and octaves would affect the range, but lacunarity and frequency would not, but I'm mostly taking a stab in the dark.

Is there any chance someone with more knowledge could revisit this issue and provide output_min()/output_max() functions on FbmSettings (and ideally for other applicable noise settings structs)? I could then make an attempt at altering generated_scaled() to use the global min/max instead of the local min/max, but that's so trivial as to probably not be worth a separate PR.

@Ralith @jackmott @virtualritz

Riizade avatar Aug 15 '21 17:08 Riizade

I recommend just looking at the implementation and analyzing them to determine the necessary scaling factor. There's not all that much math going on.

Ralith avatar Aug 15 '21 18:08 Ralith

Looking at simplex_2d, it looks like it generates a value from -1 to 1 fbm_2d is implemented here: https://github.com/jackmott/rust-simd-noise/blob/master/src/simplex.rs#L322

and can be simplified to:

    let mut result = simplex_2d(x, y, seed); // range of -1 to 1
    let mut amp = 1.0;

    for _ in 1..octaves {
        x = x * lac;
        y = y * lac;
        amp = amp * gain;
        result = (simplex_2d(x, y, seed) * amp) + result;
    }

    result

So the maximum value is gain^0 + gain^1 + gain^2... which is a geometric series of the form n=octaves a=1 and r=gain

So the closed-form sum would be 1* ((1-gain^(octaves+1))/(1 - gain))

is that correct?

Riizade avatar Aug 15 '21 19:08 Riizade

I'm pretty lost.

I wrote this code to experimentally detect the range of simplex_2d which is documented as -1 <= n<= 1 (https://github.com/jackmott/rust-simd-noise/blob/master/src/simplex.rs#L209)

        let mut max: f32 = 0.0;
        for a in 0..100 {
            for b in 0..100 {
                unsafe {
                    let mut result = simplex_2d::<Scalar>(
                        F32x1((a * 10) as f32),
                        F32x1((b * 10) as f32),
                        self.height_seed,
                    )
                    .0;
                    if result.abs() > max.abs() {
                        max = result;
                    }
                }
            }
        }

I get 0.02138349 . Considering I'm only testing 10,000 locations, I don't expect it to be 1.0, but 0.02... is pretty far away, I'd expect to get higher than that.

Further, I tried to experiment with generating fractal brownian motion directly from simplex noise by copying the implementation above and came up with the following:

unsafe {
            let mut x = chunk_coord.z as f32 * CHUNK_SIZE.z as f32;
            let mut y = chunk_coord.x as f32 * CHUNK_SIZE.x as f32;
            let mut amp = 1.0;
            let mut result = simplex_2d::<Scalar>(F32x1(x), F32x1(y), self.height_seed).0;

            for _ in 1..octaves {
                x = x * lacunarity;
                y = y * lacunarity;
                amp = amp * gain;
                result =
                    result + (simplex_2d::<Scalar>(F32x1(x), F32x1(y), self.height_seed).0 * amp);
            }

            x = chunk_coord.z as f32 * CHUNK_SIZE.z as f32;
            y = chunk_coord.x as f32 * CHUNK_SIZE.x as f32;

            println!("manual result: {:?}", result);
            println!(
                "actual result: {:?}",
                NoiseBuilder::fbm_2d_offset(x, 1, y, 1)
                    .with_seed(self.height_seed)
                    .with_octaves(octaves)
                    .with_gain(gain)
                    .with_freq(frequency)
                    .with_lacunarity(lacunarity)
                    .generate()
                    .0
                    .get(0)
                    .unwrap()
            )
        }

My manual implementation and the crate's implementation differ, I do not get the same result between the two implementations.

I looked at the implementation of set1_ps, mul_ps, and add_ps in case they did something other than what I expected, but they seem to be straightforward assignment, multiplication, and addition operations as far as I can tell.

At this point I'm pretty lost on how to go about calculating the range of the outputs considering I can't even get the simplex_2d function to return its documented range. I must be doing something incredibly stupid or very subtly wrong (like, maybe the endianness of my f32s is opposite what is expected when converting them to a SIMD float?)

I'd really appreciate it if I could get some feedback on which assumptions I'm making are wrong.

Riizade avatar Aug 15 '21 23:08 Riizade

I wrote this code to experimentally detect the range of simplex_2d

You're sampling at locations that are multiples of integers. Simplex noise is fundamentally laid out on a skewed grid, so this gives you extremely biased results. Try scaling such that your grid spacing is something like 0.01 instead of 10.

Ralith avatar Aug 15 '21 23:08 Ralith

Altering to


let mut max: f32 = 0.0;
for a in 0..100 {
    for b in 0..100 {
        unsafe {
            let mut result = simplex_2d::<Scalar>(
                F32x1(a as f32 * 0.013),
                F32x1(b as f32 * 1.278),
                self.height_seed,
            )
            .0;
            if result.abs() > max.abs() {
                max = result;
            }
        }
    }
}

println!("experimental simplex_2d max: {:?}", max);

gives the output experimental simplex_2d max: 0.022090733

I'm not sure I'm understanding correctly, but since I'm now providing non-integers as inputs to the function, the results shouldn't be biased in the way you suggest, right? But the results still don't indicate a likely range of 1 <= n <= 1

Riizade avatar Aug 15 '21 23:08 Riizade

That looks more reasonable, though your y-axis scale is still way too high to be sensible. If you're sure you're still having issues after turning that down, I recommend comparing with https://github.com/jackmott/rust-simd-noise/blob/master/src/simplex.rs#L1132-L1148, which is doing more or less the same thing, and passing on master.

Ralith avatar Aug 16 '21 00:08 Ralith

I copied the implementation from the test and ran the following:

fn simplex_2d_range() {
    for seed in 0..10 {
        let mut min = f32::INFINITY;
        let mut max = -f32::INFINITY;
        for y in 0..10 {
            for x in 0..100 {
                let n = unsafe {
                    simplex_2d::<Scalar>(F32x1(x as f32 / 10.0), F32x1(y as f32 / 10.0), seed).0
                };
                min = min.min(n);
                max = max.max(n);
            }
        }
        println!("min: {:?} // max {:?}", min, max);
    }
}

which results in several outputs of the same magnitude, for example: min: -0.021640444 // max 0.022089224

copying the check_bounds() function and running it fails the assertion, as expected.

I'm running on Windows 10, Intel Core i7 (9th gen). It's surprising to me that the Scalar output range would differ between machines, but that looks to be what's happening.

I don't think there's a way I could be polluting the scope of the function such that it's doing something else, but open to other explanations.

I'm a little at a loss. If the output range differs between different machines, there's not a great way to scale the output without running some tests to verify the bounds first. Which isn't the worst thing in the world considering how cheap the operations are (awesome work, by the way), but less than ideal from an ergonomics perspective.

Riizade avatar Aug 16 '21 00:08 Riizade

I downloaded the latest master and ran cargo test, and the tests pass

running 17 tests
test simplex_64::tests::simplex_1d_range ... ok
test simplex::tests::simplex_2d_deriv_sanity ... ok
test simplex::tests::simplex_1d_range ... ok
test simplex_64::tests::simplex_2d_range ... ok
test simplex::tests::simplex_2d_range ... ok
test tests::consistency_1d ... ok
test simplex::tests::simplex_1d_deriv_sanity ... ok
test tests::small_dimensions ... ok
test simplex::tests::simplex_3d_deriv_sanity ... ok
test tests::cell_consistency_2d ... ok
test tests::consistency_4d ... ok
test tests::consistency_3d ... ok
test tests::consistency_2d ... ok
test tests::cell_consistency_3d ... ok
test simplex_64::tests::simplex_4d_range ... ok
test simplex::tests::simplex_4d_range ... ok
test simplex::tests::simplex_3d_range ... ok

so it doesn't look to be a machine difference, but something wonky happening deep in my code somewhere. It's not a lot of code, and I'm not doing anything particularly arcane, but maybe it's something with memory layout since the functions are in an unsafe block.

Riizade avatar Aug 16 '21 00:08 Riizade

All of the above essentially to say that having the output range precomputed and made available on the FbmSettings struct or elsewhere would be a great feature and avoid much of this hassle for the next person who comes along, but I'm not able to implement it myself.

Riizade avatar Aug 16 '21 00:08 Riizade

If you're getting different results from identical code, then UB seems likely, yeah. If there's UB, the scale of the output is the least of your concerns. Once you've addressed that, I think you were on the right track for your analysis. I don't have time to dig deeply into this myself, but since you've got the same code failing in one case and passing in another, you should be able to isolate where they diverge.

Ralith avatar Aug 16 '21 01:08 Ralith

So I dug a bit and it seems the simplex_2d code uses this (or the same source as) https://procedural-content-generation.fandom.com/wiki/Simplex_Noise Everything seems to match except the 70x scaling on the return value which is absent in this crate (as the author stated).

70x would scale values between -0.014 ; 0.014 to -1 ; 1, but the weird thing is that when I sample from simplex_2d the range i get is -0.021 ; 0.021 so x70 would not work here.

I don't know if the wiki or the implementation is wrong though but i empirically I guess doing x40 would more or less work 🤷

Inspirateur avatar Aug 18 '23 15:08 Inspirateur