From 32028d004b437e2778efb0532bd736a7c44d2db8 Mon Sep 17 00:00:00 2001 From: Adrian Kummerlaender Date: Thu, 23 Sep 2021 23:19:45 +0200 Subject: WIP: Article on noise in ray marching --- articles/2021-09-26_noise_and_ray_marching.org | 254 +++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 articles/2021-09-26_noise_and_ray_marching.org (limited to 'articles/2021-09-26_noise_and_ray_marching.org') diff --git a/articles/2021-09-26_noise_and_ray_marching.org b/articles/2021-09-26_noise_and_ray_marching.org new file mode 100644 index 0000000..c5ae921 --- /dev/null +++ b/articles/2021-09-26_noise_and_ray_marching.org @@ -0,0 +1,254 @@ +* Noise and Ray Marching +[[https://literatelb.org][LiterateLB's]] volumetric visualization functionality relies on a simple ray marching implementation +to sample both the 3D textures produced by the simulation side of things and the signed distance +functions that describe the obstacle geometry. While this produces surprisingly [[https://www.youtube.com/watch?v=n86GfhhL7sA][nice looking]] +results in many cases, some artifacts of the visualization algorithm are visible depending on the +viewport and sample gradients. Extending the ray marching code to utilize a noise function is +one possibility of mitigating such issues that I want to explore in this article. + +While my [[https://www.youtube.com/watch?v=J2al5tV14M8][original foray]] into just in time visualization of Lattice Boltzmann based simulations +was only an aftertought to [[https://tree.kummerlaender.eu/projects/symlbm_playground/][playing around]] with [[https://sympy.org][SymPy]] based code generation approaches I have +since put some work into a more fully fledged code. The resulting [[https://literatelb.org][LiterateLB]] code combines +symbolic generation of optimized CUDA kernels and functionality for just in time fluid flow +visualization into a single /literate/ [[http://code.kummerlaender.eu/LiterateLB/tree/lbm.org][document]]. + +For all fortunate users of the [[https://nixos.org][Nix]] package manager, tangling and building this from the [[https://orgmode.org][Org]] +document is as easy as executing the following commands on a CUDA-enabled NixOS host. + +#+BEGIN_SRC sh +git clone https://code.kummerlaender.eu/LiterateLB +nix-build +./result/bin/nozzle +#+END_SRC + +** Image Synthesis +Let $C$ be the sample density along a ray $r$ with length $L$ and given an absorption $\mu$. Then +\[ C(r) = \int_0^L c(x) \mu(x) \exp\left(-\int_0^x \mu(t) dt\right) dx \] +is the volume rendering equation yielding per-pixel density values that are easily mapped to +some convenient color palette. + +#+BEGIN_EXPORT html + +#+END_EXPORT + +I.e. we integrate over the values $c(x)$ along the ray weighted by the current absorption $\mu$ +and the accumulated absorption up to the current point. This way samples that are closer to +the view origin will be more prominent than samples of the same magnitude that are farther +away which of course is also a rough approximation of how a real volume behaves, hence +the name. + +In practice this integral is approximated by the sum +$$C(r) = \sum_{i=0}^N c(i \Delta x) \alpha (i \Delta x) \prod_{j=0}^{i-1} \left(1 - \alpha(j\Delta x)\right)$$ +for step width \(\Delta x \in \mathbb{R}^+\) in the most basic case. + +This approach may be extended arbitrarily, e.g. it is only a couple of phase functions +away from being able [[https://tree.kummerlaender.eu/projects/firmament/][recover the color produced by light travelling through the participating media that is our atmosphere]]. + +** The Problem +There are many different possibilities for the choice of samples \(c(x)\) in the volume rendering +equation. E.g. velocity and curl norms, the scalar product of ray direction and shear layer +normals or vortex identifiers such as the Q criterion +\[ Q = \|\Omega\|^2 - \|S\|^2 > 0 \text{ commonly thresholded to recover isosurfaces} \] +that contrasts the local vorticity and strain rate norms. The strain rate tensor \(S\) is easily +recovered from the non-equilibrium populations \(f^\text{neq}\) of the simulation lattice — and is in +fact already used for the turbulence model. Similarly, the vorticity \(\Omega = \nabla \times u\) can be +computed from the velocity field using a finite difference stencil. + +The problem w.r.t. rendering when thresholding sampling values to highlight structures in the flow +becomes apparent in the following picture: + +#+BEGIN_EXPORT html +
+
+Q Criterion + +
+
+Curl Norm + +
+
+#+END_EXPORT + +While the exact same volume discretization was used for both visualizations, the slices are much +less apparent for the curl norm samples due to the more gradual changes. In general the issue is +most prominent for scalar fields with large gradients (specifically the sudden jumps that occur +when restricting sampling to certain value ranges as is the case for the Q criterion). + +** Colors of Noise +Volumetric renderings that are produced when using a plain ray marching algorithm may contain +undesired artifacts. Specifically the choice of start offsets w.r.t. to the view origin can lead +to slicing artifacts. While this tends to become less noticable for smaller step widths these are +not desirable from a performance perspective. + +What I settled on for LiterateLB's renderer are view-aligned slicing and random jittering to remove +most visible artifacts. The choice of /randomness/ for jittering the ray origin is critical here as plain +random numbers produce a distracting static-like pattern. A common choice in practice is to use so +called /blue noise/ instead. Noise is called /blue/ if it contains only higher frequency components which +makes it harder for the pattern recognizer that we call brain to find patterns where there should be +none. + +The [[https://www.spiedigitallibrary.org/conference-proceedings-of-spie/1913/0000/Void-and-cluster-method-for-dither-array-generation/10.1117/12.152707.short?SSO=1][void-and-cluster algorithm (doi:10.1117/12.152707)]] provides a straight forward method for +pre-computing tileable blue noise textures to be reused during the visualization. + +The first ingredient for this algorithm is a =filteredPattern= function that applies a +plain Gaussian filter with given $\sigma$ to a cyclic 2d array. Using cyclic wrapping here is +what renders the generated texture tileable. + +#+BEGIN_SRC python +def filteredPattern(pattern, sigma): + return gaussian_filter(pattern.astype(float), sigma=sigma, mode='wrap', truncate=np.max(pattern.shape)) +#+END_SRC + +This function will be used to compute the locations of the largest void and tightest +cluster in a binary pattern (i.e. a 2D array of 0s and 1s). In this context a /void/ describes +an area with only zeros and a /cluster/ describes an area with only ones. + +#+BEGIN_SRC python +def largestVoidIndex(pattern, sigma): + return np.argmin(masked_array(filteredPattern(pattern, sigma), mask=pattern)) +#+END_SRC + +These two functions work by considering the given binary pattern as a float array that is blurred by +the Gaussian filter. The blurred pattern gives an implicit ordering of the /voidness/ of each pixel, the +minimum of which we can determine by a simple search. It is important to exclude the initial binary +pattern here as void-and-cluster depends on finding the largest areas where no pixel is set. + +#+BEGIN_SRC python +def tightestClusterIndex(pattern, sigma): + return np.argmax(masked_array(filteredPattern(pattern, sigma), mask=np.logical_not(pattern))) +#+END_SRC + +Computing the tightest cluster works in the same way with the exception of searching the largest array +element and masking by the inverted pattern. + +#+BEGIN_SRC python +def initialPattern(shape, n_start, sigma): + initial_pattern = np.zeros(shape, dtype=np.bool) + initial_pattern.flat[0:n_start] = True + initial_pattern.flat = np.random.permutation(initial_pattern.flat) + cluster_idx, void_idx = -2, -1 + while cluster_idx != void_idx: + cluster_idx = tightestClusterIndex(initial_pattern, sigma) + initial_pattern.flat[cluster_idx] = False + void_idx = largestVoidIndex(initial_pattern, sigma) + initial_pattern.flat[void_idx] = True + return initial_pattern +#+END_SRC + +For the initial binary pattern we set =n_start= random locations to one and then repeatedly +break up the largest void by setting its center to one. This is also done for the tightest cluster +by setting its center to zero. We do this until the locations of the tightest cluster and largest +void overlap. + +#+BEGIN_SRC python +def blueNoise(shape, sigma): + n = np.prod(shape) + n_start = int(n / 10) + + initial_pattern = initialPattern(shape, n_start, sigma) + noise = np.zeros(shape) + + pattern = np.copy(initial_pattern) + for rank in range(n_start,-1,-1): + cluster_idx = tightestClusterIndex(pattern, sigma) + pattern.flat[cluster_idx] = False + noise.flat[cluster_idx] = rank + + pattern = np.copy(initial_pattern) + for rank in range(n_start,int((n+1)/2)): + void_idx = largestVoidIndex(pattern, sigma) + pattern.flat[void_idx] = True + noise.flat[void_idx] = rank + + for rank in range(int((n+1)/2),n): + cluster_idx = tightestClusterIndex(np.logical_not(pattern), sigma) + pattern.flat[cluster_idx] = True + noise.flat[cluster_idx] = rank + + return noise / (n-1) +#+END_SRC + +The actual algorithm utilizes these three helper functions in four steps: +1. Initial pattern generation +2. Eliminiation of =n_start= tightest clusters +3. Elimination of =n/2-n_start= largest voids +4. Elimination of =n-n/2= tightest clusters of inverse pattern +For each elimination the current =rank= is stored in the noise texture +producing a 2D arrangement of the integers from 0 to =n=. As the last +step the array is divided by =n-1= to yield a grayscale texture with values +in $[0,1]$. + +In order to check whether this actually generated blue noise we can take a +look at the Fourier transformation for an exemplary \(100 \times 100\) texture: + +#+BEGIN_EXPORT html +
+
+Blue noise texture + +
+
+Fourier transformation + +
+
+#+END_EXPORT + +One can see clearly that higher frequency components are significantly more prominent +than lower ones. Contrasting this to white noise generated using uniformly distributed +random numbers, no preference for any range of frequencies can be observed: + +#+BEGIN_EXPORT html +
+
+White noise texture + +
+
+Fourier transformation + +
+
+#+END_EXPORT + +** Comparison +Contasting the original Q criterion visualization to one produced using blue noise jittering +followed by a soft blurring shader, we can see that the slicing artifacts mostly vanish. +While the jittering is apparent if one looks closely, the result is more visually pleasing +also arguably more faithful to the underlying scalar field. + +#+BEGIN_EXPORT html +
+
+Simple ray marching + +
+
+Ray marching with blue noise jittering + +
+
+#+END_EXPORT + +While white noise also obcures the slices the additional lower frequency components +produce a more obvious pattern in the resulting image compared to blue noise. + +#+BEGIN_EXPORT html +
+
+Blue noise + +
+
+White noise + +
+
+#+END_EXPORT + +#+BEGIN_EXPORT html + +#+END_EXPORT -- cgit v1.2.3