diff --git a/doc/Makefile b/doc/Makefile
index 195e17ce0a2958904630a731b51ecfe4204d129c..4611b6aee9cc85babbc81d75af2c5e888534fed7 100644
--- a/doc/Makefile
+++ b/doc/Makefile
@@ -3,7 +3,7 @@
 
 # You can set these variables from the command line.
 SPHINXOPTS    =
-SPHINXBUILD   = sphinx-build
+SPHINXBUILD   = python $(shell which sphinx-build)
 SPHINXPROJ    = pytools
 SOURCEDIR     = .
 BUILDDIR      = _build
@@ -17,4 +17,4 @@ help:
 # Catch-all target: route all unknown targets to Sphinx using the new
 # "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
 %: Makefile
-	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
+	@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/pytools/__init__.py b/pytools/__init__.py
index 64a896bd4540cededbd2f4a015356dba8768d7d6..4ee4fc124bee7a347aa9cbc92d6dc85191ba0498 100644
--- a/pytools/__init__.py
+++ b/pytools/__init__.py
@@ -37,7 +37,6 @@ from typing import (
         Any, Callable, Dict, Iterable, List, Optional, Set, Tuple, TypeVar)
 import builtins
 
-
 from sys import intern
 
 
@@ -171,7 +170,8 @@ Hashing
 Sampling
 --------
 
-.. autofunction:: sphere_sample
+.. autofunction:: sphere_sample_equidistant
+.. autofunction:: sphere_sample_fibonacci
 
 Type Variables Used
 -------------------
@@ -256,7 +256,7 @@ def deprecate_keyword(oldkey: str,
 # }}}
 
 
-# {{{ math --------------------------------------------------------------------
+# {{{ math
 
 def delta(x, y):
     if x == y:
@@ -472,7 +472,7 @@ class FakeList:
             return self._Function(index)
 
 
-# {{{ dependent dictionary ----------------------------------------------------
+# {{{ dependent dictionary
 
 class DependentDictionary:
     def __init__(self, f, start=None):
@@ -1809,7 +1809,7 @@ def word_wrap(text, width, wrap_using="\n"):
 # }}}
 
 
-# {{{ command line interfaces -------------------------------------------------
+# {{{ command line interfaces
 
 def _exec_arg(arg, execenv):
     import os
@@ -2701,11 +2701,12 @@ def unordered_hash(hash_instance, iterable, hash_constructor=None):
 
 # {{{ sphere_sample
 
-def sphere_sample(npoints_approx, r=1):
-    """Generate points regularly distributed on a sphere.
-    Based on: https://www.cmu.edu/biolphys/deserno/pdf/sphere_equi.pdf
-    Returns an array of shape (npoints, 3). npoints does not generally equally
-    npoints_approx.
+def sphere_sample_equidistant(npoints_approx: int, r: float = 1.0):
+    """Generate points regularly distributed on a sphere
+    based on https://www.cmu.edu/biolphys/deserno/pdf/sphere_equi.pdf.
+
+    :returns: an :class:`~numpy.ndarray` of shape ``(3, npoints)``, where
+        ``npoints`` does not generally equal *npoints_approx*.
     """
 
     import numpy as np
@@ -2728,14 +2729,59 @@ def sphere_sample(npoints_approx, r=1):
                            r * np.cos(theta)])
             count += 1
 
-    # Add poles.
+    # add poles
     for i in range(3):
         for sign in [-1, +1]:
             pole = np.zeros(3)
             pole[i] = r * sign
             points.append(pole)
 
-    return np.array(points)
+    return np.array(points).T.copy()
+
+
+# NOTE: each tuple contains ``(epsilon, max_npoints)``
+_SPHERE_FIBONACCI_OFFSET = (
+        (0.33, 24), (1.33, 177), (3.33, 890),
+        (10, 11000), (27, 39000), (75, 600000), (214, float("inf")),
+        )
+
+
+def sphere_sample_fibonacci(
+        npoints: int, r: float = 1.0, *,
+        optimize: Optional[str] = None):
+    """Generate points on a sphere based on an offset Fibonacci lattice from [2]_.
+
+    .. [2] http://extremelearning.com.au/how-to-evenly-distribute-points-on-a-sphere-more-effectively-than-the-canonical-fibonacci-lattice/
+
+    :param optimize: takes the values: *None* to use the standard Fibonacci
+        lattice, ``"minimum"`` to minimize the nearest neighbor distances in the
+        lattice and ``"average"`` to minimize the average distances in the
+        lattice.
+
+    :returns: an :class:`~numpy.ndarray` of shape ``(3, npoints)``.
+    """     # noqa: E501
+
+    import numpy as np
+    if optimize is None:
+        epsilon = 0.5
+    elif optimize == "minimum":
+        epsilon, _ = next(o for o in _SPHERE_FIBONACCI_OFFSET if npoints < o[1])
+    elif optimize == "average":
+        epsilon = 0.36
+    else:
+        raise ValueError(f"unknown 'optimize' choice: '{optimize}'")
+
+    golden_ratio = (1 + np.sqrt(5)) / 2
+    n = np.arange(npoints)
+
+    phi = 2.0 * np.pi * n / golden_ratio
+    theta = np.arccos(1.0 - 2.0 * (n + epsilon) / (npoints + 2 * epsilon - 1))
+
+    return np.stack([
+        r * np.sin(theta) * np.cos(phi),
+        r * np.sin(theta) * np.sin(phi),
+        r * np.cos(theta)
+        ])
 
 # }}}
 
diff --git a/test/test_pytools.py b/test/test_pytools.py
index 6c24ecc501c80dde26566a48ad86bb8924a25dc5..17ee1fff8fbb425c028d155accece9348ff44004 100644
--- a/test/test_pytools.py
+++ b/test/test_pytools.py
@@ -555,6 +555,61 @@ def test_unordered_hash():
             != unordered_hash(hashlib.sha256(), lst).digest())
 
 
+# {{{ sphere sampling
+
+@pytest.mark.parametrize("sampling", [
+    "equidistant", "fibonacci", "fibonacci_min", "fibonacci_avg",
+    ])
+def test_sphere_sampling(sampling, visualize=False):
+    from functools import partial
+    from pytools import sphere_sample_equidistant, sphere_sample_fibonacci
+
+    npoints = 128
+    radius = 1.5
+
+    if sampling == "equidistant":
+        sampling_func = partial(sphere_sample_equidistant, r=radius)
+    elif sampling == "fibonacci":
+        sampling_func = partial(
+                sphere_sample_fibonacci, r=radius, optimize=None)
+    elif sampling == "fibonacci_min":
+        sampling_func = partial(
+                sphere_sample_fibonacci, r=radius, optimize="minimum")
+    elif sampling == "fibonacci_avg":
+        sampling_func = partial(
+                sphere_sample_fibonacci, r=radius, optimize="average")
+    else:
+        raise ValueError(f"unknown sampling method: '{sampling}'")
+
+    import numpy as np
+    points = sampling_func(npoints)
+    assert np.all(np.linalg.norm(points, axis=0) < radius + 1.0e-15)
+
+    if not visualize:
+        return
+
+    import matplotlib.pyplot as plt
+    fig = plt.figure(figsize=(10, 10), dpi=300)
+    ax = fig.add_subplot(111, projection="3d")
+
+    import matplotlib.tri as mtri
+    theta = np.arctan2(np.sqrt(points[0]**2 + points[1]**2), points[2])
+    phi = np.arctan2(points[1], points[0])
+    triangles = mtri.Triangulation(theta, phi)
+
+    ax.plot_trisurf(points[0], points[1], points[2], triangles=triangles.triangles)
+    ax.set_xlim([-radius, radius])
+    ax.set_ylim([-radius, radius])
+    ax.set_zlim([-radius, radius])
+    ax.margins(0.05, 0.05, 0.05)
+
+    # plt.show()
+    fig.savefig(f"sphere_sampling_{sampling}")
+    plt.close(fig)
+
+# }}}
+
+
 if __name__ == "__main__":
     if len(sys.argv) > 1:
         exec(sys.argv[1])