aboutsummaryrefslogtreecommitdiff
path: root/articles/2021-09-26_noise_and_ray_marching.org
blob: d3b231d0661f108ef2d360e8e0dae93ee652a4f9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
* 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 values. 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
The basic ingredient for producing volumetric images from CFD simulation data is to compute
some scalar field of samples \(s : \mathbb{R}^3 \to \mathbb{R}_0^+\). Each sample \(s(x)\) can be assigned a color
\(c(x)\) by some convenient color palette mapping scalar values to a tuple of red, green and blue
components.

[[https://literatelb.org/tangle/asset/palette/4wave_ROTB.png]]

The task of producing an image then consists to sampling the color field along a ray assigned
to a pixel by e.g. a simple pinhole camera projection. For this purpose a simple discrete
approximation of the volume rendering equation with constant step size \(\Delta x \in \mathbb{R}^+\) already
produces suprisingly good pictures. Specifically
$$C(r) = \sum_{i=0}^N c(i \Delta x) \mu (i \Delta x) \prod_{j=0}^{i-1} \left(1 - \mu(j\Delta x)\right)$$
is the color along ray \(r\) of length \(N\Delta x\) with local absorption values \(\mu(x)\). This
local absorption value may be chosen seperately of the sampling function adding an
additional tweaking point.

#+BEGIN_EXPORT html
<video style="width:100%" src="https://literatelb.org/media/nozzle.webm" controls="controls">
</video>
#+END_EXPORT

The basic approach may also be extended arbitrarily, e.g. it is only the inclusion of 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 sampling function \(s(x)\) given the results of a
fluid flow simulation. 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
<div class="flexcolumns">
<div>
<span>Q Criterion</span>
<img src="https://static.kummerlaender.eu/media/q_criterion_default.png"/>
</div>
<div>
<span>Curl Norm</span>
<img src="https://static.kummerlaender.eu/media/curl_default.png"/>
</div>
</div>
#+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
The reason for these artifacts is primarily choice of start offsets w.r.t. the traversed volume
in addition the the step width. While this tends to become less noticable when decreasing said
steps, this is 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 tend to produce a distracting static-like pattern. A common choice in practice is
to use so called /blue noise/ instead. While both kinds of noise eliminate most slicing artifacts, the
remaining patterns tend to be less noticeable for blue noise. 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]][fn:vac] provides a straight forward method for
pre-computing tileable blue noise textures that can be reused during the actual visualization.
Tileability is a desirable property for this as we otherwise would either need a noise texture
large enough to cover the entire image or instead observe jumps at the boundary between
the tiled texture.

The first ingredient for /void-and-cluster/ is a =filteredPattern= function that applies a
plain Gaussian filter with given $\sigma$ to a cyclic 2d array. Using cyclic wrapping during the
application of this filter 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):
#+END_SRC

The actual algorithm utilizes these three helper functions in  four steps:
1. Initial pattern generation
   #+BEGIN_SRC python
    n = np.prod(shape)
    n_start = int(n / 10)

    initial_pattern = initialPattern(shape, n_start, sigma)
    noise = np.zeros(shape)
   #+END_SRC
3. Eliminiation of =n_start= tightest clusters
   #+BEGIN_SRC python
    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
   #+END_SRC
4. Elimination of =n/2-n_start= largest voids
   #+BEGIN_SRC python
    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
   #+END_SRC
5. Elimination of =n-n/2= tightest clusters of the inverted pattern
   #+BEGIN_SRC python
    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
   #+END_SRC

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]$.

#+BEGIN_SRC python
return noise / (n-1)
#+END_SRC

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
<div class="flexcolumns">
<div>
<span>Blue noise texture</span>
<img src="https://static.kummerlaender.eu/media/blue_noise.png"/>
</div>
<div>
<span>Fourier transformation</span>
<img src="https://static.kummerlaender.eu/media/blue_noise_fourier.png"/>
</div>
</div>
#+END_EXPORT

One can see qualitatively 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
<div class="flexcolumns">
<div>
<span>White noise texture</span>
<img src="https://static.kummerlaender.eu/media/white_noise.png"/>
</div>
<div>
<span>Fourier transformation</span>
<img src="https://static.kummerlaender.eu/media/white_noise_fourier.png"/>
</div>
</div>
#+END_EXPORT

** Comparison
Contasting the original Q criterion visualization with one produced using blue noise jittering
followed by a soft blurring shader, we can see that the slicing artifacts largely vanish.
While the jittering is still visible to closer inspection, the result is significantly more pleasing
to the eye and arguably more faithful to the underlying scalar field.

#+BEGIN_EXPORT html
<div class="flexcolumns">
<div>
<span>Simple ray marching</span>
<img src="https://static.kummerlaender.eu/media/q_criterion_default.png"/>
</div>
<div>
<span>Ray marching with blue noise jittering</span>
<img src="https://static.kummerlaender.eu/media/q_criterion_blue_noise.png"/>
</div>
</div>
#+END_EXPORT

While white noise also obcures the slices, its lower frequency components 
produce more obvious static in the resulting image compared to blue noise.
As both kinds of noise are precomputed we can freely choose the kind of
noise that will produce the best results for our sampling data.

#+BEGIN_EXPORT html
<div class="flexcolumns">
<div>
<span>Blue noise</span>
<img src="https://static.kummerlaender.eu/media/q_criterion_blue_noise_close.png"/>
</div>
<div>
<span>White noise</span>
<img src="https://static.kummerlaender.eu/media/q_criterion_white_noise_close.png"/>
</div>
</div>
#+END_EXPORT

In practice where the noise is applied just-in-time during the visualization of
a CFD simulation, all remaining artifacts tend to become invisible. This can
be seen in the following video of the Q criterion evaluated for a simulated
nozzle flow in LiterateLB:

#+BEGIN_EXPORT html
<video style="width:100%" src="https://static.kummerlaender.eu/media/nozzle_q_criterion.webm" controls="controls">
</video>
#+END_EXPORT

[fn:vac] Ulichney, R. Void-and-cluster method for dither array generation. In Electronic Imaging (1993). DOI: [[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][10.1117/12.152707]].