From ebc5733d486c768fc11e769ab02f0bb62678fb6a Mon Sep 17 00:00:00 2001 From: "Jan Wedekind (Dr)" Date: Mon, 26 Jan 2026 20:20:51 +0000 Subject: [PATCH 1/5] Added explanation of octaves of noise --- src/volumetric_clouds/main.clj | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/src/volumetric_clouds/main.clj b/src/volumetric_clouds/main.clj index ae4a1539..3db2f3b5 100644 --- a/src/volumetric_clouds/main.clj +++ b/src/volumetric_clouds/main.clj @@ -518,6 +518,8 @@ (interpolate z3 2.5 3.5 5.5) => 2.0)) ;; ### Octaves of noise +;; +;; Fractal Brownian Motion is implemented by computing a weighted sum of the same base noise function using different frequencies. (defn fractal-brownian-motion [base octaves & args] (let [scales (take (count octaves) (iterate #(* 2 %) 1))] @@ -525,7 +527,7 @@ (map (fn [amplitude scale] (* amplitude (apply base (map #(* scale %) args)))) octaves scales)))) - +;; Here the Fractal Brownian Motion is tested using an alternating 1D function and later a 2D checkboard function. (facts "Fractal Brownian motion" (let [base1 (fn [x] (if (>= (mod x 2.0) 1.0) 1.0 0.0)) base2 (fn [y x] (if (= (Math/round (mod y 2.0)) (Math/round (mod x 2.0))) @@ -545,12 +547,13 @@ (fractal-brownian-motion base1 [0.0 1.0] 0.0) => 0.0 (fractal-brownian-motion base1 [0.0 1.0] 0.5) => 1.0)) - +;; ### Remapping and clamping +;; +;; The remap function is used to map a range of values of an input tensor to a different range. (defn remap [value low1 high1 low2 high2] (dfn/+ low2 (dfn/* (dfn/- value low1) (/ (- high2 low2) (- high1 low1))))) - (tabular "Remap values of tensor" (fact ((remap (tensor/->tensor [?value]) ?low1 ?high1 ?low2 ?high2) 0) => ?expected) @@ -564,11 +567,11 @@ 1 0 2 0 4 2) +;; The clamp function is used to clamp a value to a range. (defn clamp [value low high] (dfn/max low (dfn/min value high))) - (tabular "Clamp values of tensor" (fact ((clamp (tensor/->tensor [?value]) ?low ?high) 0) => ?expected) ?value ?low ?high ?expected @@ -577,17 +580,20 @@ 0 2 3 2 4 2 3 3) - +;; ### Generating octaves of noise +;; +;; The octaves function is to create a series of decreasing weights and normalize them so that they add up to 1. (defn octaves [n decay] (let [series (take n (iterate #(* % decay) 1.0)) sum (apply + series)] (mapv #(/ % sum) series))) - +;; Here is an example of noise weights decreasing by 50% at each octave. (octaves 4 0.5) +;; Now a noise array can be generated using octaves of noise. (defn noise-octaves [tensor octaves low high] (tensor/clone @@ -603,10 +609,15 @@ low high 0 255) 0 255))) +;; ### 2D examples +;; +;; Here is an example of 4 octaves of Worley noise. (bufimg/tensor->image (noise-octaves worley-norm (octaves 4 0.6) 120 230)) +;; Here is an example of 4 octaves of Perlin noise. (bufimg/tensor->image (noise-octaves perlin-norm (octaves 4 0.6) 120 230)) +;; Here is an example of 4 octaves of mixed Perlin and Worley noise. (bufimg/tensor->image (noise-octaves perlin-worley-norm (octaves 4 0.6) 120 230)) @@ -1320,4 +1331,5 @@ float shadow(vec3 point) ;; * [Vertical density profile](https://www.wedesoft.de/software/2023/05/03/volumetric-clouds/) ;; * [Powder function](https://advances.realtimerendering.com/s2015/index.html) ;; * [Curl noise](https://www.wedesoft.de/software/2023/03/20/procedural-global-cloud-cover/) +;; * [Precomputed atmospheric scattering](https://ebruneton.github.io/precomputed_atmospheric_scattering/) ;; * [Deep opacity maps](https://www.wedesoft.de/software/2023/05/03/volumetric-clouds/) From ffb4b1a70673822d15dc58969e669f630bc2e0f6 Mon Sep 17 00:00:00 2001 From: "Jan Wedekind (Dr)" Date: Mon, 26 Jan 2026 20:34:24 +0000 Subject: [PATCH 2/5] Working on OpenGL explanation --- src/volumetric_clouds/main.clj | 57 ++++++++++++++++++---------------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/src/volumetric_clouds/main.clj b/src/volumetric_clouds/main.clj index 3db2f3b5..a0a64a33 100644 --- a/src/volumetric_clouds/main.clj +++ b/src/volumetric_clouds/main.clj @@ -29,8 +29,6 @@ [org.lwjgl.opengl GL GL11 GL12 GL13 GL15 GL20 GL30 GL32 GL42])) -;; # Procedural generation of volumetric clouds -;; ;; Volumetric clouds are commonly used in flight simulators and visual effects. ;; For a introductory video see [Sebastian Lague's video "Coding Adventure: Clouds](https://www.youtube.com/watch?v=4QOcCGI6xOU). ;; Note that this article is about procedural generation and not about simulating real weather. @@ -39,7 +37,7 @@ ;; ;; [Worley noise](https://en.wikipedia.org/wiki/Worley_noise) is a type of structured noise which is defined for each pixel using the distance to the nearest seed point. ;; -;; ### Noise parameters +;; #### Noise parameters ;; ;; First we define a function to create parameters of the noise. ;; @@ -57,7 +55,7 @@ (make-noise-params 256 8 2) => {:size 256 :divisions 8 :cellsize 32 :dimensions 2}) -;; ### 2D and 3D vectors +;; #### 2D and 3D vectors ;; ;; Next we need a function which allows us to create 2D or 3D vectors depending on the number of input parameters. (defn vec-n @@ -69,7 +67,7 @@ (vec-n 2 3 1) => (vec3 2 3 1)) -;; ### Random points +;; #### Random points ;; ;; The following method generates a random point in a cell specified by the cell indices. (defn random-point-in-cell @@ -115,7 +113,7 @@ (plotly/layer-point {:=x :x :=y :y}))) -;; ### Modular distance +;; #### Modular distance ;; ;; In order to get a periodic noise array, we need to component-wise wrap around distance vectors. (defn mod-vec @@ -158,7 +156,7 @@ 0 5 0 0 3.0) -;; ### Modular lookup +;; #### Modular lookup ;; ;; We also need to lookup elements with wrap around. ;; We recursively use `tensor/select` and then finally the tensor as a function to lookup along each axis. @@ -187,7 +185,7 @@ (division-index {:cellsize 4} 7.5) => 1 (division-index {:cellsize 4} -0.5) => -1) -;; ### Getting indices of Neighbours +;; #### Getting indices of Neighbours ;; ;; The following function determines the neighbouring indices of a cell recursing over each dimension. (defn neighbours @@ -204,7 +202,7 @@ (neighbours 1 10) => [[0 9] [1 9] [2 9] [0 10] [1 10] [2 10] [0 11] [1 11] [2 11]]) -;; ### Sampling Worley noise +;; #### Sampling Worley noise ;; ;; Using above functions one can now implement Worley noise. ;; For each pixel the distance to the closest seed point is calculated. @@ -240,7 +238,7 @@ ;; [Perlin noise](https://adrianb.io/2014/08/09/perlinnoise.html) is generated by choosing a random gradient vector at each cell corner. ;; The noise tensor's intermediate values are interpolated with a continuous function, utilizing the gradient at the corner points. -;; ### Random gradients +;; #### Random gradients ;; ;; The 2D or 3D gradients are generated by creating a vector where each component is set to a random number between -1 and 1. ;; Random vectors are generated until the vector length is greater 0 and lower or equal to 1. @@ -305,7 +303,7 @@ (plotly/base {:=title "Random gradients" :=mode "lines"}) (plotly/layer-point {:=x :x :=y :y}))) -;; ### Corner vectors +;; #### Corner vectors ;; ;; The next step is to determine the vectors to the corners of the cell for a given point. ;; First we define a function to determine the fractional part of a number. @@ -346,7 +344,7 @@ (v2 1 1) => (vec2 -0.25 -0.5) (v3 0 0 0) => (vec3 0.75 0.5 0.25))) -;; ### Extract gradients of cell corners +;; #### Extract gradients of cell corners ;; ;; The function below retrieves the gradient values at a cell's corners, utilizing `wrap-get` for modular access. (defn corner-gradients @@ -372,7 +370,7 @@ ((corner-gradients {:cellsize 4 :dimensions 3} gradients3 (vec3 9 6 3)) 0 0 0) => (vec3 2 1 0))) -;; ### Influence values +;; #### Influence values ;; ;; The influence value is the function value of the function with the selected random gradient at a corner. (defn influence-values @@ -395,7 +393,7 @@ (influence2 1 1) => 11.0 (influence3 1 1 1) => 111.0)) -;; ### Interpolating the influence values +;; #### Interpolating the influence values ;; ;; For interpolation the following "ease curve" is used. (defn ease-curve @@ -437,7 +435,7 @@ (weights3 0 0 0) => (roughly 0.010430 1e-6))) -;; ### Sampling Perlin noise +;; #### Sampling Perlin noise ;; ;; A Perlin noise sample is computed by ;; * Getting the random gradients for the cell corners. @@ -478,7 +476,7 @@ ;; ## Mixing noise values ;; -;; ### Combination of Worley and Perlin noise +;; #### Combination of Worley and Perlin noise ;; ;; One can mix Worley and Perlin noise by simply doing a linear combination of the two. (def perlin-worley-norm (dfn/+ (dfn/* 0.3 perlin-norm) (dfn/* 0.7 worley-norm))) @@ -486,7 +484,7 @@ ;; Here for example is the average of Perlin and Worley noise. (bufimg/tensor->image (dfn/+ (dfn/* 0.5 perlin-norm) (dfn/* 0.5 worley-norm))) -;; ### Interpolation +;; #### Interpolation ;; ;; One can linearly interpolate tensor values by recursing over the dimensions as follows. (defn interpolate @@ -517,7 +515,7 @@ (interpolate y3 2.5 3.5 3.0) => 3.0 (interpolate z3 2.5 3.5 5.5) => 2.0)) -;; ### Octaves of noise +;; #### Octaves of noise ;; ;; Fractal Brownian Motion is implemented by computing a weighted sum of the same base noise function using different frequencies. (defn fractal-brownian-motion @@ -547,7 +545,7 @@ (fractal-brownian-motion base1 [0.0 1.0] 0.0) => 0.0 (fractal-brownian-motion base1 [0.0 1.0] 0.5) => 1.0)) -;; ### Remapping and clamping +;; #### Remapping and clamping ;; ;; The remap function is used to map a range of values of an input tensor to a different range. (defn remap @@ -580,7 +578,7 @@ 0 2 3 2 4 2 3 3) -;; ### Generating octaves of noise +;; #### Generating octaves of noise ;; ;; The octaves function is to create a series of decreasing weights and normalize them so that they add up to 1. (defn octaves @@ -609,7 +607,7 @@ low high 0 255) 0 255))) -;; ### 2D examples +;; #### 2D examples ;; ;; Here is an example of 4 octaves of Worley noise. (bufimg/tensor->image (noise-octaves worley-norm (octaves 4 0.6) 120 230)) @@ -621,8 +619,11 @@ (bufimg/tensor->image (noise-octaves perlin-worley-norm (octaves 4 0.6) 120 230)) -;; ## Testing shaders +;; ## Volumetric clouds +;; #### OpenGL setup +;; +;; In order to render the clouds we create a window and an OpenGL context. (GLFW/glfwInit) (def window-width 640) @@ -635,7 +636,9 @@ (GLFW/glfwMakeContextCurrent window) (GL/createCapabilities) - +;; #### Compiling and linking shader programs +;; +;; The following method is used compile a shader program. (defn make-shader [source shader-type] (let [shader (GL20/glCreateShader shader-type)] (GL20/glShaderSource shader source) @@ -664,8 +667,8 @@ program)) -(def vertex-test " -#version 130 +(def vertex-test +"#version 130 in vec3 point; void main() { @@ -673,8 +676,8 @@ void main() }") -(def fragment-test " -#version 130 +(def fragment-test +"#version 130 out vec4 fragColor; void main() { From 40084d7ee9966d9f1a07916e0d813a1daffc14cb Mon Sep 17 00:00:00 2001 From: "Jan Wedekind (Dr)" Date: Mon, 26 Jan 2026 21:42:49 +0000 Subject: [PATCH 3/5] Working on description of OpenGL program --- src/volumetric_clouds/main.clj | 64 +++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 29 deletions(-) diff --git a/src/volumetric_clouds/main.clj b/src/volumetric_clouds/main.clj index a0a64a33..99a8fa44 100644 --- a/src/volumetric_clouds/main.clj +++ b/src/volumetric_clouds/main.clj @@ -624,6 +624,7 @@ ;; #### OpenGL setup ;; ;; In order to render the clouds we create a window and an OpenGL context. +;; Note that we need to create an invisible window to get an OpenGL context, even though we are not going to draw to the window (GLFW/glfwInit) (def window-width 640) @@ -647,7 +648,7 @@ (throw (Exception. (GL20/glGetShaderInfoLog shader 1024)))) shader)) - +;; The different shaders are then linked to become a shader program using the following method. (defn make-program [& shaders] (let [program (GL20/glCreateProgram)] (doseq [shader shaders] @@ -658,7 +659,7 @@ (throw (Exception. (GL20/glGetProgramInfoLog program 1024)))) program)) - +;; This method is used to perform both compilation and linking of vertex shaders and fragment shaders. (defn make-program-with-shaders [vertex-sources fragment-sources] (let [vertex-shaders (map #(make-shader % GL20/GL_VERTEX_SHADER) vertex-sources) @@ -666,8 +667,8 @@ program (apply make-program (concat vertex-shaders fragment-shaders))] program)) - -(def vertex-test +;; We are going to use this simple vertex shader to simply pass a vertex through without any transformations. +(def vertex-passthrough "#version 130 in vec3 point; void main() @@ -675,7 +676,7 @@ void main() gl_Position = vec4(point, 1); }") - +;; The following fragment shader is used to test rendering white pixels. (def fragment-test "#version 130 out vec4 fragColor; @@ -684,7 +685,7 @@ void main() fragColor = vec4(1, 1, 1, 1); }") - +;; In order to pass data to LWJGL methods, we need to be able to convert arrays to Java buffer objects. (defmacro def-make-buffer [method create-buffer] `(defn ~method [data#] (let [buffer# (~create-buffer (count data#))] @@ -692,10 +693,12 @@ void main() (.flip buffer#) buffer#))) +;; Above macro is used to define methods for creating float, int, and byte buffer objects. (def-make-buffer make-float-buffer BufferUtils/createFloatBuffer) (def-make-buffer make-int-buffer BufferUtils/createIntBuffer) (def-make-buffer make-byte-buffer BufferUtils/createByteBuffer) +;; We implement a method to create a vertex array object with a vertex buffer object and an index buffer object. (defn setup-vao [vertices indices] (let [vao (GL30/glGenVertexArrays) vbo (GL15/glGenBuffers) @@ -709,7 +712,7 @@ void main() GL15/GL_STATIC_DRAW) {:vao vao :vbo vbo :ibo ibo})) - +;; We also define the corresponding destructor for the vertex data. (defn teardown-vao [{:keys [vao vbo ibo]}] (GL15/glBindBuffer GL15/GL_ELEMENT_ARRAY_BUFFER 0) (GL15/glDeleteBuffers ibo) @@ -718,16 +721,9 @@ void main() (GL30/glBindVertexArray 0) (GL15/glDeleteBuffers vao)) - -(defn float-buffer->array - "Convert float buffer to flaot array" - [buffer] - (let [result (float-array (.limit buffer))] - (.get buffer result) - (.flip buffer) - result)) - - +;; #### Offscreen rendering to a texture +;; +;; The following method is used to create an empty 2D RGBA floating point texture (defn make-texture-2d [width height] (let [texture (GL11/glGenTextures)] @@ -739,7 +735,16 @@ void main() (GL42/glTexStorage2D GL11/GL_TEXTURE_2D 1 GL30/GL_RGBA32F width height) texture)) +;; We define a method to convert a Java buffer object to a floating point array. +(defn float-buffer->array + "Convert float buffer to float array" + [buffer] + (let [result (float-array (.limit buffer))] + (.get buffer result) + (.flip buffer) + result)) +;; The following method reads texture data into a Java buffer and then converts it to a floating point array. (defn read-texture-2d [texture width height] (let [buffer (BufferUtils/createFloatBuffer (* height width 4))] @@ -747,7 +752,7 @@ void main() (GL11/glGetTexImage GL11/GL_TEXTURE_2D 0 GL12/GL_RGBA GL11/GL_FLOAT buffer) (float-buffer->array buffer))) - +;; This method sets up rendering to a specified texture of specified size and then executes the body. (defmacro framebuffer-render [texture width height & body] `(let [fbo# (GL30/glGenFramebuffers)] @@ -764,7 +769,8 @@ void main() (GL30/glBindFramebuffer GL30/GL_FRAMEBUFFER 0) (GL30/glDeleteFramebuffers fbo#))))) - +;; We also create a method to set up the layout of the vertex buffer. +;; Our vertex data is only going to be 3D coordinates of points. (defn setup-point-attribute [program] (let [point-attribute (GL20/glGetAttribLocation program "point")] @@ -804,7 +810,7 @@ void main() (GL20/glDeleteProgram program))))) -(render-pixel [vertex-test] [fragment-test]) +(render-pixel [vertex-passthrough] [fragment-test]) ;; ## Noise octaves shader @@ -830,7 +836,7 @@ void main() (tabular "Test noise mock" - (fact (nth (render-pixel [vertex-test] [noise-mock (noise-probe ?x ?y ?z)]) 0) + (fact (nth (render-pixel [vertex-passthrough] [noise-mock (noise-probe ?x ?y ?z)]) 0) => ?result) ?x ?y ?z ?result 0 0 0 0.0 @@ -871,7 +877,7 @@ void main() (tabular "Test octaves of noise" - (fact (first (render-pixel [vertex-test] + (fact (first (render-pixel [vertex-passthrough] [noise-mock (noise-octaves ?octaves) (octaves-probe ?x ?y ?z)])) => ?result) @@ -921,7 +927,7 @@ void main() (tabular "Test intersection of ray with box" (fact ((juxt first second) - (render-pixel [vertex-test] + (render-pixel [vertex-passthrough] [ray-box (ray-box-probe ?ox ?oy ?oz ?dx ?dy ?dz)])) => ?result) ?ox ?oy ?oz ?dx ?dy ?dz ?result @@ -1010,7 +1016,7 @@ void main() (tabular "Test cloud transfer" - (fact (seq (render-pixel [vertex-test] + (fact (seq (render-pixel [vertex-passthrough] [(fog ?density) constant-scatter no-shadow (cloud-transfer "fog" ?step) (cloud-transfer-probe ?a ?b)])) @@ -1065,7 +1071,7 @@ void main() [width height] (let [fragment-sources [ray-box constant-scatter no-shadow (cloud-transfer "fog" 0.01) (fog 1.0) fragment-cloud] - program (make-program-with-shaders [vertex-test] fragment-sources) + program (make-program-with-shaders [vertex-passthrough] fragment-sources) vao (setup-quad-vao)] (setup-point-attribute program) (try @@ -1131,7 +1137,7 @@ float noise(vec3 idx) (defn render-noise [width height & cloud-shaders] (let [fragment-sources (concat cloud-shaders [ray-box fragment-cloud]) - program (make-program-with-shaders [vertex-test] fragment-sources) + program (make-program-with-shaders [vertex-passthrough] fragment-sources) vao (setup-quad-vao)] (try (setup-point-attribute program) @@ -1174,7 +1180,7 @@ void main() (tabular "Remap and clamp input parameter values" (fact (first (render-pixel - [vertex-test] + [vertex-passthrough] [remap-clamp (remap-probe ?value ?low1 ?high1 ?low2 ?high2)])) => ?expected) ?value ?low1 ?high1 ?low2 ?high2 ?expected @@ -1253,7 +1259,7 @@ void main() (tabular "Shader function for scattering phase function" - (fact (first (render-pixel [vertex-test] [(mie-scatter ?g) (mie-probe ?mu)])) + (fact (first (render-pixel [vertex-passthrough] [(mie-scatter ?g) (mie-probe ?mu)])) => (roughly ?result 1e-6)) ?g ?mu ?result 0 0 (/ 3 (* 16 PI)) @@ -1264,7 +1270,7 @@ void main() (defn scatter-amount [theta] - (first (render-pixel [vertex-test] [(mie-scatter 0.76) (mie-probe (cos theta))]))) + (first (render-pixel [vertex-passthrough] [(mie-scatter 0.76) (mie-probe (cos theta))]))) (let [scatter From e5e0c174a75f64aeb39d86670293345e1512b786 Mon Sep 17 00:00:00 2001 From: "Jan Wedekind (Dr)" Date: Mon, 26 Jan 2026 22:25:17 +0000 Subject: [PATCH 4/5] Continuing explanation of volumetric rendering --- src/volumetric_clouds/main.clj | 175 +++++++++++++++++++-------------- 1 file changed, 103 insertions(+), 72 deletions(-) diff --git a/src/volumetric_clouds/main.clj b/src/volumetric_clouds/main.clj index 99a8fa44..37e47c0a 100644 --- a/src/volumetric_clouds/main.clj +++ b/src/volumetric_clouds/main.clj @@ -37,7 +37,7 @@ ;; ;; [Worley noise](https://en.wikipedia.org/wiki/Worley_noise) is a type of structured noise which is defined for each pixel using the distance to the nearest seed point. ;; -;; #### Noise parameters +;; ### Noise parameters ;; ;; First we define a function to create parameters of the noise. ;; @@ -55,7 +55,7 @@ (make-noise-params 256 8 2) => {:size 256 :divisions 8 :cellsize 32 :dimensions 2}) -;; #### 2D and 3D vectors +;; ### 2D and 3D vectors ;; ;; Next we need a function which allows us to create 2D or 3D vectors depending on the number of input parameters. (defn vec-n @@ -67,7 +67,7 @@ (vec-n 2 3 1) => (vec3 2 3 1)) -;; #### Random points +;; ### Random points ;; ;; The following method generates a random point in a cell specified by the cell indices. (defn random-point-in-cell @@ -113,7 +113,7 @@ (plotly/layer-point {:=x :x :=y :y}))) -;; #### Modular distance +;; ### Modular distance ;; ;; In order to get a periodic noise array, we need to component-wise wrap around distance vectors. (defn mod-vec @@ -142,6 +142,7 @@ [params a b] (mag (mod-vec params (sub b a)))) +;; The `tabular` macro implemented by Midje is useful for running parametrized tests. (tabular "Wrapped distance of two points" (fact (mod-dist {:size 8} (vec2 ?ax ?ay) (vec2 ?bx ?by)) => ?result) ?ax ?ay ?bx ?by ?result @@ -156,7 +157,7 @@ 0 5 0 0 3.0) -;; #### Modular lookup +;; ### Modular lookup ;; ;; We also need to lookup elements with wrap around. ;; We recursively use `tensor/select` and then finally the tensor as a function to lookup along each axis. @@ -185,7 +186,7 @@ (division-index {:cellsize 4} 7.5) => 1 (division-index {:cellsize 4} -0.5) => -1) -;; #### Getting indices of Neighbours +;; ### Getting indices of Neighbours ;; ;; The following function determines the neighbouring indices of a cell recursing over each dimension. (defn neighbours @@ -202,7 +203,7 @@ (neighbours 1 10) => [[0 9] [1 9] [2 9] [0 10] [1 10] [2 10] [0 11] [1 11] [2 11]]) -;; #### Sampling Worley noise +;; ### Sampling Worley noise ;; ;; Using above functions one can now implement Worley noise. ;; For each pixel the distance to the closest seed point is calculated. @@ -238,7 +239,7 @@ ;; [Perlin noise](https://adrianb.io/2014/08/09/perlinnoise.html) is generated by choosing a random gradient vector at each cell corner. ;; The noise tensor's intermediate values are interpolated with a continuous function, utilizing the gradient at the corner points. -;; #### Random gradients +;; ### Random gradients ;; ;; The 2D or 3D gradients are generated by creating a vector where each component is set to a random number between -1 and 1. ;; Random vectors are generated until the vector length is greater 0 and lower or equal to 1. @@ -303,7 +304,7 @@ (plotly/base {:=title "Random gradients" :=mode "lines"}) (plotly/layer-point {:=x :x :=y :y}))) -;; #### Corner vectors +;; ### Corner vectors ;; ;; The next step is to determine the vectors to the corners of the cell for a given point. ;; First we define a function to determine the fractional part of a number. @@ -344,7 +345,7 @@ (v2 1 1) => (vec2 -0.25 -0.5) (v3 0 0 0) => (vec3 0.75 0.5 0.25))) -;; #### Extract gradients of cell corners +;; ### Extract gradients of cell corners ;; ;; The function below retrieves the gradient values at a cell's corners, utilizing `wrap-get` for modular access. (defn corner-gradients @@ -370,7 +371,7 @@ ((corner-gradients {:cellsize 4 :dimensions 3} gradients3 (vec3 9 6 3)) 0 0 0) => (vec3 2 1 0))) -;; #### Influence values +;; ### Influence values ;; ;; The influence value is the function value of the function with the selected random gradient at a corner. (defn influence-values @@ -393,7 +394,7 @@ (influence2 1 1) => 11.0 (influence3 1 1 1) => 111.0)) -;; #### Interpolating the influence values +;; ### Interpolating the influence values ;; ;; For interpolation the following "ease curve" is used. (defn ease-curve @@ -435,7 +436,7 @@ (weights3 0 0 0) => (roughly 0.010430 1e-6))) -;; #### Sampling Perlin noise +;; ### Sampling Perlin noise ;; ;; A Perlin noise sample is computed by ;; * Getting the random gradients for the cell corners. @@ -476,7 +477,7 @@ ;; ## Mixing noise values ;; -;; #### Combination of Worley and Perlin noise +;; ### Combination of Worley and Perlin noise ;; ;; One can mix Worley and Perlin noise by simply doing a linear combination of the two. (def perlin-worley-norm (dfn/+ (dfn/* 0.3 perlin-norm) (dfn/* 0.7 worley-norm))) @@ -484,7 +485,7 @@ ;; Here for example is the average of Perlin and Worley noise. (bufimg/tensor->image (dfn/+ (dfn/* 0.5 perlin-norm) (dfn/* 0.5 worley-norm))) -;; #### Interpolation +;; ### Interpolation ;; ;; One can linearly interpolate tensor values by recursing over the dimensions as follows. (defn interpolate @@ -515,7 +516,7 @@ (interpolate y3 2.5 3.5 3.0) => 3.0 (interpolate z3 2.5 3.5 5.5) => 2.0)) -;; #### Octaves of noise +;; ### Octaves of noise ;; ;; Fractal Brownian Motion is implemented by computing a weighted sum of the same base noise function using different frequencies. (defn fractal-brownian-motion @@ -545,7 +546,7 @@ (fractal-brownian-motion base1 [0.0 1.0] 0.0) => 0.0 (fractal-brownian-motion base1 [0.0 1.0] 0.5) => 1.0)) -;; #### Remapping and clamping +;; ### Remapping and clamping ;; ;; The remap function is used to map a range of values of an input tensor to a different range. (defn remap @@ -578,7 +579,7 @@ 0 2 3 2 4 2 3 3) -;; #### Generating octaves of noise +;; ### Generating octaves of noise ;; ;; The octaves function is to create a series of decreasing weights and normalize them so that they add up to 1. (defn octaves @@ -607,7 +608,7 @@ low high 0 255) 0 255))) -;; #### 2D examples +;; ### 2D examples ;; ;; Here is an example of 4 octaves of Worley noise. (bufimg/tensor->image (noise-octaves worley-norm (octaves 4 0.6) 120 230)) @@ -619,9 +620,9 @@ (bufimg/tensor->image (noise-octaves perlin-worley-norm (octaves 4 0.6) 120 230)) -;; ## Volumetric clouds +;; ## OpenGL rendering -;; #### OpenGL setup +;; ### OpenGL initialization ;; ;; In order to render the clouds we create a window and an OpenGL context. ;; Note that we need to create an invisible window to get an OpenGL context, even though we are not going to draw to the window @@ -637,7 +638,7 @@ (GLFW/glfwMakeContextCurrent window) (GL/createCapabilities) -;; #### Compiling and linking shader programs +;; ### Compiling and linking shader programs ;; ;; The following method is used compile a shader program. (defn make-shader [source shader-type] @@ -667,24 +668,6 @@ program (apply make-program (concat vertex-shaders fragment-shaders))] program)) -;; We are going to use this simple vertex shader to simply pass a vertex through without any transformations. -(def vertex-passthrough -"#version 130 -in vec3 point; -void main() -{ - gl_Position = vec4(point, 1); -}") - -;; The following fragment shader is used to test rendering white pixels. -(def fragment-test -"#version 130 -out vec4 fragColor; -void main() -{ - fragColor = vec4(1, 1, 1, 1); -}") - ;; In order to pass data to LWJGL methods, we need to be able to convert arrays to Java buffer objects. (defmacro def-make-buffer [method create-buffer] `(defn ~method [data#] @@ -693,12 +676,14 @@ void main() (.flip buffer#) buffer#))) +;; ### Setup of vertex data +;; ;; Above macro is used to define methods for creating float, int, and byte buffer objects. (def-make-buffer make-float-buffer BufferUtils/createFloatBuffer) (def-make-buffer make-int-buffer BufferUtils/createIntBuffer) (def-make-buffer make-byte-buffer BufferUtils/createByteBuffer) -;; We implement a method to create a vertex array object with a vertex buffer object and an index buffer object. +;; We implement a method to create a vertex array object (VAO) with a vertex buffer object (VBO) and an index buffer object (IBO). (defn setup-vao [vertices indices] (let [vao (GL30/glGenVertexArrays) vbo (GL15/glGenBuffers) @@ -721,7 +706,7 @@ void main() (GL30/glBindVertexArray 0) (GL15/glDeleteBuffers vao)) -;; #### Offscreen rendering to a texture +;; ### Offscreen rendering to a texture ;; ;; The following method is used to create an empty 2D RGBA floating point texture (defn make-texture-2d @@ -779,13 +764,18 @@ void main() (GL20/glEnableVertexAttribArray point-attribute))) +;; We are going to use a simple background quad to perform volumetric rendering. (defn setup-quad-vao [] - (let [vertices (float-array [1.0 1.0 0.0, -1.0 1.0 0.0, -1.0 -1.0 0.0, 1.0 -1.0 0.0]) - indices (int-array [0 1 2 3])] + (let [vertices (float-array [ 1.0 1.0 0.0, + -1.0 1.0 0.0, + 1.0 -1.0 0.0, + -1.0 -1.0 0.0]) + indices (int-array [0 1 3 2])] (setup-vao vertices indices))) +;; We now have all definitions ready to implement rendering of an image. (defmacro render-array [width height & body] `(let [texture# (volumetric-clouds.main/make-texture-2d ~width ~height)] @@ -795,7 +785,9 @@ void main() (finally (GL11/glDeleteTextures texture#))))) - +;; The following method creates a program and the quad VAO and sets up the memory layout. +;; The program and VAO are then used to render a single pixel. +;; Using this method we can write unit tests for OpenGL shaders! (defn render-pixel [vertex-sources fragment-sources] (let [program (make-program-with-shaders vertex-sources fragment-sources) @@ -810,11 +802,33 @@ void main() (GL20/glDeleteProgram program))))) -(render-pixel [vertex-passthrough] [fragment-test]) +;; We are going to use this simple vertex shader to simply pass through the points from the vertex buffer without any transformations. +(def vertex-passthrough +"#version 130 +in vec3 point; +void main() +{ + gl_Position = vec4(point, 1); +}") + +;; The following fragment shader is used to test rendering white pixels. +(def fragment-test +"#version 130 +out vec4 fragColor; +void main() +{ + fragColor = vec4(1, 1, 1, 1); +}") +;; We can now render a single white RGBA pixel using the graphics card. +(render-pixel [vertex-passthrough] [fragment-test]) -;; ## Noise octaves shader +;; ## Volumetric Clouds +;; +;; ### Mocks and probing shaders +;; +;; The following fragment shader creates a 3D checkboard pattern serving as a mock function below. (def noise-mock "#version 130 float noise(vec3 idx) @@ -823,7 +837,8 @@ float noise(vec3 idx) return ((v.x == 1) == (v.y == 1)) == (v.z == 1) ? 1.0 : 0.0; }") - +;; We can test this mock function using the following probing shader. +;; Note that we are using the `template` macro of the `comb` Clojure library to generate the shader code from a template. (def noise-probe (template/fn [x y z] "#version 130 @@ -834,7 +849,7 @@ void main() fragColor = vec4(noise(vec3(<%= x %>, <%= y %>, <%= z %>))); }")) - +;; Here multiple tests are run to test that the mock implements a checkboard pattern correctly. (tabular "Test noise mock" (fact (nth (render-pixel [vertex-passthrough] [noise-mock (noise-probe ?x ?y ?z)]) 0) => ?result) @@ -848,7 +863,10 @@ void main() 0 1 1 0.0 1 1 1 1.0) - +;; ### Octaves of noise +;; +;; We now implement a shader for 3D Fractal Brownian motion. +;; Note that we can use the template macro to generate code for an arbitrary number of octaves. (def noise-octaves (template/fn [octaves] "#version 130 @@ -864,7 +882,7 @@ float octaves(vec3 idx) return result; }")) - +;; Again we use a probing shader to test the shader function. (def octaves-probe (template/fn [x y z] "#version 130 @@ -875,7 +893,7 @@ void main() fragColor = vec4(octaves(vec3(<%= x %>, <%= y %>, <%= z %>))); }")) - +;; A few unit tests with one or two octaves are sufficient to drive development of the shader function. (tabular "Test octaves of noise" (fact (first (render-pixel [vertex-passthrough] [noise-mock (noise-octaves ?octaves) @@ -890,8 +908,10 @@ void main() 1 0 0 [1.0 0.0] 1.0) -;; ## Shader for intersecting a ray with a box - +;; ### Shader for intersecting a ray with a box +;; +;; The following shader implements intersection of a ray with an axis-aligned box. +;; The shader function returns the distance of the near and far intersection with the box. (def ray-box "#version 130 vec2 ray_box(vec3 box_min, vec3 box_max, vec3 origin, vec3 direction) @@ -909,7 +929,7 @@ vec2 ray_box(vec3 box_min, vec3 box_max, vec3 origin, vec3 direction) return vec2(max(s_near, 0.0), max(0.0, s_far)); }") - +;; The probing shader returns the near and far distance in the red and green channel of the fragment color. (def ray-box-probe (template/fn [ox oy oz dx dy dz] "#version 130 @@ -924,7 +944,7 @@ void main() fragColor = vec4(ray_box(box_min, box_max, origin, direction), 0, 0); }")) - +;; The shader is tested with different ray origins and directions. (tabular "Test intersection of ray with box" (fact ((juxt first second) (render-pixel [vertex-passthrough] @@ -944,8 +964,9 @@ void main() 2 0 0 1 0 0 [0.0 0.0]) -;; ## Shader for light transfer through clouds - +;; ### Shader for light transfer through clouds +;; +;; We test the light transfer through clouds using constant density fog. (def fog (template/fn [v] "#version 130 @@ -954,7 +975,7 @@ float fog(vec3 idx) return <%= v %>; }")) - +;; Volumetric rendering involves sampling cloud density along a ray and multiplying the transmittance values. (def cloud-transfer (template/fn [noise step] "#version 130 @@ -976,7 +997,8 @@ vec4 cloud_transfer(vec3 origin, vec3 direction, vec2 interval) return result; }")) - +;; For now we also assume isotropic scattering of light in all directions. +;; This is a placeholder for introducing Mie scattering later. (def constant-scatter "#version 130 float in_scatter(vec3 point, vec3 direction) @@ -984,7 +1006,8 @@ float in_scatter(vec3 point, vec3 direction) return 1.0; }") - +;; Finally we assume that there is no shadow. +;; This is a placeholder for introducing cloud shadows later. (def no-shadow "#version 130 float shadow(vec3 point) @@ -992,7 +1015,7 @@ float shadow(vec3 point) return 1.0; }") - +;; We can now test the color and opacity of the cloud using the following probing shader. (def cloud-transfer-probe (template/fn [a b] "#version 130 @@ -1006,7 +1029,7 @@ void main() fragColor = cloud_transfer(origin, direction, interval); }")) - +;; We also introduce a Midje checker for requiring a vector to have an approximate value. (defn roughly-vector [expected error] (fn [actual] @@ -1014,7 +1037,7 @@ void main() (<= (apply + (mapv (fn [a b] (* (- b a) (- b a))) actual expected)) (* error error))))) - +;; A few tests are performed to check that there is opacity and that the step size does not affect the result in constant fog. (tabular "Test cloud transfer" (fact (seq (render-pixel [vertex-passthrough] [(fog ?density) constant-scatter no-shadow @@ -1028,8 +1051,16 @@ void main() 0 1 0.5 0.5 [0.393 0.393 0.393 0.393]) -;; ## Rendering of fog box - +;; ### Rendering of fog box +;; +;; The following fragment shader is used to render an image of a box filled with fog. +;; +;; * The pixel coordinate and the resolution of the image are used to determine a viewing direction which also gets rotated using the rotation matrix. +;; * The origin of the camera is set at a specified distance to the center of the box and rotated as well. +;; * The ray box function is used to determine the near and far intersection points of the ray with the box. +;; * The cloud transfer function is used to sample the cloud density along the ray and determine the overall opacity and color of the fog box. +;; * The background is a mix of blue color and a small blob of white where the viewing direction points to the light source. +;; * The opacity value of the fog is used to overlay the fog color over the background. (def fragment-cloud "#version 130 uniform vec2 resolution; @@ -1093,7 +1124,7 @@ void main() (bufimg/tensor->image (rgba-array->bufimg (render-fog 640 480) 640 480)) -;; ## Rendering of 3D noise +;; ### Rendering of 3D noise (defn float-array->texture3d [data size] @@ -1156,7 +1187,7 @@ float noise(vec3 idx) 640 480)) -;; ## Remap and clamp 3D noise +;; ### Remap and clamp 3D noise (def remap-clamp "#version 130 @@ -1217,7 +1248,7 @@ float remap_noise(vec3 idx) 640 480)) -;; ## Octaves of 3D noise +;; ### Octaves of 3D noise (bufimg/tensor->image (rgba-array->bufimg @@ -1227,7 +1258,7 @@ float remap_noise(vec3 idx) 640 480)) -;; ## Mie scattering +;; ### Mie scattering (def mie-scatter (template/fn [g] @@ -1297,7 +1328,7 @@ void main() 640 480)) -;; ## Self-shading of clouds +;; ### Self-shading of clouds (def shadow (template/fn [noise step] "#version 130 @@ -1327,7 +1358,7 @@ float shadow(vec3 point) (noise-octaves (octaves 4 0.5)) noise-shader) 640 480)) -;; ## Tidy up +;; ### Tidy up (GL11/glBindTexture GL12/GL_TEXTURE_3D 0) (GL11/glDeleteTextures noise-texture) From 2b3c2dba2c9f0145a17f633e482456a3719551c9 Mon Sep 17 00:00:00 2001 From: "Jan Wedekind (Dr)" Date: Mon, 26 Jan 2026 22:26:29 +0000 Subject: [PATCH 5/5] Update site/volumetric_clouds/main.qmd --- site/volumetric_clouds/main.qmd | 273 ++++++++++++++++++++++++-------- 1 file changed, 206 insertions(+), 67 deletions(-) diff --git a/site/volumetric_clouds/main.qmd b/site/volumetric_clouds/main.qmd index cb131cd2..f8760e24 100644 --- a/site/volumetric_clouds/main.qmd +++ b/site/volumetric_clouds/main.qmd @@ -25,9 +25,6 @@ image: clouds.jpg .clay-side-by-side {margin: 1em 0} - -# Procedural generation of volumetric clouds - Volumetric clouds are commonly used in flight simulators and visual effects. For a introductory video see [Sebastian Lague's video "Coding Adventure: Clouds](https://www.youtube.com/watch?v=4QOcCGI6xOU). Note that this article is about procedural generation and not about simulating real weather. @@ -212,7 +209,7 @@ Here is a scatter plot showing one random point placed in each cell. ```{=html} -
+
``` @@ -275,6 +272,8 @@ Using the `mod-dist` function we can calculate the distance between two points i ::: +The `tabular` macro implemented by Midje is useful for running parametrized tests. + ::: {.sourceClojure} ```clojure @@ -615,7 +614,7 @@ The gradient field can be plotted with Plotly as a scatter plot of disconnected ```{=html} -
+
``` @@ -1090,6 +1089,8 @@ true ### Octaves of noise +Fractal Brownian Motion is implemented by computing a weighted sum of the same base noise function using different frequencies. + ::: {.sourceClojure} ```clojure @@ -1103,6 +1104,8 @@ true ::: +Here the Fractal Brownian Motion is tested using an alternating 1D function and later a 2D checkboard function. + ::: {.sourceClojure} ```clojure @@ -1138,6 +1141,11 @@ true +### Remapping and clamping + +The remap function is used to map a range of values of an input tensor to a different range. + + ::: {.sourceClojure} ```clojure (defn remap @@ -1174,6 +1182,8 @@ true ::: +The clamp function is used to clamp a value to a range. + ::: {.sourceClojure} ```clojure @@ -1208,6 +1218,11 @@ true +### Generating octaves of noise + +The octaves function is to create a series of decreasing weights and normalize them so that they add up to 1. + + ::: {.sourceClojure} ```clojure (defn octaves @@ -1219,6 +1234,8 @@ true ::: +Here is an example of noise weights decreasing by 50% at each octave. + ::: {.sourceClojure} ```clojure @@ -1239,6 +1256,8 @@ true ::: +Now a noise array can be generated using octaves of noise. + ::: {.sourceClojure} ```clojure @@ -1261,6 +1280,11 @@ true +### 2D examples + +Here is an example of 4 octaves of Worley noise. + + ::: {.sourceClojure} ```clojure (bufimg/tensor->image (noise-octaves worley-norm (octaves 4 0.6) 120 230)) @@ -1273,6 +1297,8 @@ true ::: +Here is an example of 4 octaves of Perlin noise. + ::: {.sourceClojure} ```clojure @@ -1286,6 +1312,8 @@ true ::: +Here is an example of 4 octaves of mixed Perlin and Worley noise. + ::: {.sourceClojure} ```clojure @@ -1300,7 +1328,13 @@ true -## Testing shaders +## OpenGL rendering + + +### OpenGL initialization + +In order to render the clouds we create a window and an OpenGL context. +Note that we need to create an invisible window to get an OpenGL context, even though we are not going to draw to the window ::: {.sourceClojure} @@ -1405,13 +1439,18 @@ nil ::: {.printedClojure} ```clojure -#object[org.lwjgl.opengl.GLCapabilities 0x39d3f717 "org.lwjgl.opengl.GLCapabilities@39d3f717"] +#object[org.lwjgl.opengl.GLCapabilities 0x734ee5fd "org.lwjgl.opengl.GLCapabilities@734ee5fd"] ``` ::: +### Compiling and linking shader programs + +The following method is used compile a shader program. + + ::: {.sourceClojure} ```clojure (defn make-shader [source shader-type] @@ -1425,6 +1464,8 @@ nil ::: +The different shaders are then linked to become a shader program using the following method. + ::: {.sourceClojure} ```clojure @@ -1441,6 +1482,8 @@ nil ::: +This method is used to perform both compilation and linking of vertex shaders and fragment shaders. + ::: {.sourceClojure} ```clojure @@ -1454,33 +1497,7 @@ nil ::: - -::: {.sourceClojure} -```clojure -(def vertex-test " -#version 130 -in vec3 point; -void main() -{ - gl_Position = vec4(point, 1); -}") -``` -::: - - - -::: {.sourceClojure} -```clojure -(def fragment-test " -#version 130 -out vec4 fragColor; -void main() -{ - fragColor = vec4(1, 1, 1, 1); -}") -``` -::: - +In order to pass data to LWJGL methods, we need to be able to convert arrays to Java buffer objects. ::: {.sourceClojure} @@ -1496,6 +1513,11 @@ void main() +### Setup of vertex data + +Above macro is used to define methods for creating float, int, and byte buffer objects. + + ::: {.sourceClojure} ```clojure (def-make-buffer make-float-buffer BufferUtils/createFloatBuffer) @@ -1546,6 +1568,8 @@ void main() ::: +We implement a method to create a vertex array object (VAO) with a vertex buffer object (VBO) and an index buffer object (IBO). + ::: {.sourceClojure} ```clojure @@ -1565,6 +1589,8 @@ void main() ::: +We also define the corresponding destructor for the vertex data. + ::: {.sourceClojure} ```clojure @@ -1580,18 +1606,9 @@ void main() -::: {.sourceClojure} -```clojure -(defn float-buffer->array - "Convert float buffer to flaot array" - [buffer] - (let [result (float-array (.limit buffer))] - (.get buffer result) - (.flip buffer) - result)) -``` -::: +### Offscreen rendering to a texture +The following method is used to create an empty 2D RGBA floating point texture ::: {.sourceClojure} @@ -1610,6 +1627,24 @@ void main() ::: +We define a method to convert a Java buffer object to a floating point array. + + +::: {.sourceClojure} +```clojure +(defn float-buffer->array + "Convert float buffer to float array" + [buffer] + (let [result (float-array (.limit buffer))] + (.get buffer result) + (.flip buffer) + result)) +``` +::: + + +The following method reads texture data into a Java buffer and then converts it to a floating point array. + ::: {.sourceClojure} ```clojure @@ -1623,6 +1658,8 @@ void main() ::: +This method sets up rendering to a specified texture of specified size and then executes the body. + ::: {.sourceClojure} ```clojure @@ -1645,6 +1682,9 @@ void main() ::: +We also create a method to set up the layout of the vertex buffer. +Our vertex data is only going to be 3D coordinates of points. + ::: {.sourceClojure} ```clojure @@ -1658,18 +1698,25 @@ void main() ::: +We are going to use a simple background quad to perform volumetric rendering. + ::: {.sourceClojure} ```clojure (defn setup-quad-vao [] - (let [vertices (float-array [1.0 1.0 0.0, -1.0 1.0 0.0, -1.0 -1.0 0.0, 1.0 -1.0 0.0]) - indices (int-array [0 1 2 3])] + (let [vertices (float-array [ 1.0 1.0 0.0, + -1.0 1.0 0.0, + 1.0 -1.0 0.0, + -1.0 -1.0 0.0]) + indices (int-array [0 1 3 2])] (setup-vao vertices indices))) ``` ::: +We now have all definitions ready to implement rendering of an image. + ::: {.sourceClojure} ```clojure @@ -1685,6 +1732,10 @@ void main() ::: +The following method creates a program and the quad VAO and sets up the memory layout. +The program and VAO are then used to render a single pixel. +Using this method we can write unit tests for OpenGL shaders! + ::: {.sourceClojure} ```clojure @@ -1704,10 +1755,44 @@ void main() ::: +We are going to use this simple vertex shader to simply pass through the points from the vertex buffer without any transformations. + ::: {.sourceClojure} ```clojure -(render-pixel [vertex-test] [fragment-test]) +(def vertex-passthrough +"#version 130 +in vec3 point; +void main() +{ + gl_Position = vec4(point, 1); +}") +``` +::: + + +The following fragment shader is used to test rendering white pixels. + + +::: {.sourceClojure} +```clojure +(def fragment-test +"#version 130 +out vec4 fragColor; +void main() +{ + fragColor = vec4(1, 1, 1, 1); +}") +``` +::: + + +We can now render a single white RGBA pixel using the graphics card. + + +::: {.sourceClojure} +```clojure +(render-pixel [vertex-passthrough] [fragment-test]) ``` ::: @@ -1722,7 +1807,12 @@ void main() -## Noise octaves shader +## Volumetric Clouds + + +### Mocks and probing shaders + +The following fragment shader creates a 3D checkboard pattern serving as a mock function below. ::: {.sourceClojure} @@ -1738,6 +1828,9 @@ float noise(vec3 idx) ::: +We can test this mock function using the following probing shader. +Note that we are using the `template` macro of the `comb` Clojure library to generate the shader code from a template. + ::: {.sourceClojure} ```clojure @@ -1754,11 +1847,13 @@ void main() ::: +Here multiple tests are run to test that the mock implements a checkboard pattern correctly. + ::: {.sourceClojure} ```clojure (tabular "Test noise mock" - (fact (nth (render-pixel [vertex-test] [noise-mock (noise-probe ?x ?y ?z)]) 0) + (fact (nth (render-pixel [vertex-passthrough] [noise-mock (noise-probe ?x ?y ?z)]) 0) => ?result) ?x ?y ?z ?result 0 0 0 0.0 @@ -1783,6 +1878,12 @@ true +### Octaves of noise + +We now implement a shader for 3D Fractal Brownian motion. +Note that we can use the template macro to generate code for an arbitrary number of octaves. + + ::: {.sourceClojure} ```clojure (def noise-octaves @@ -1803,6 +1904,8 @@ float octaves(vec3 idx) ::: +Again we use a probing shader to test the shader function. + ::: {.sourceClojure} ```clojure @@ -1819,11 +1922,13 @@ void main() ::: +A few unit tests with one or two octaves are sufficient to drive development of the shader function. + ::: {.sourceClojure} ```clojure (tabular "Test octaves of noise" - (fact (first (render-pixel [vertex-test] + (fact (first (render-pixel [vertex-passthrough] [noise-mock (noise-octaves ?octaves) (octaves-probe ?x ?y ?z)])) => ?result) @@ -1848,7 +1953,10 @@ true -## Shader for intersecting a ray with a box +### Shader for intersecting a ray with a box + +The following shader implements intersection of a ray with an axis-aligned box. +The shader function returns the distance of the near and far intersection with the box. ::: {.sourceClojure} @@ -1873,6 +1981,8 @@ vec2 ray_box(vec3 box_min, vec3 box_max, vec3 origin, vec3 direction) ::: +The probing shader returns the near and far distance in the red and green channel of the fragment color. + ::: {.sourceClojure} ```clojure @@ -1893,12 +2003,14 @@ void main() ::: +The shader is tested with different ray origins and directions. + ::: {.sourceClojure} ```clojure (tabular "Test intersection of ray with box" (fact ((juxt first second) - (render-pixel [vertex-test] + (render-pixel [vertex-passthrough] [ray-box (ray-box-probe ?ox ?oy ?oz ?dx ?dy ?dz)])) => ?result) ?ox ?oy ?oz ?dx ?dy ?dz ?result @@ -1927,7 +2039,9 @@ true -## Shader for light transfer through clouds +### Shader for light transfer through clouds + +We test the light transfer through clouds using constant density fog. ::: {.sourceClojure} @@ -1943,6 +2057,8 @@ float fog(vec3 idx) ::: +Volumetric rendering involves sampling cloud density along a ray and multiplying the transmittance values. + ::: {.sourceClojure} ```clojure @@ -1970,6 +2086,9 @@ vec4 cloud_transfer(vec3 origin, vec3 direction, vec2 interval) ::: +For now we also assume isotropic scattering of light in all directions. +This is a placeholder for introducing Mie scattering later. + ::: {.sourceClojure} ```clojure @@ -1983,6 +2102,9 @@ float in_scatter(vec3 point, vec3 direction) ::: +Finally we assume that there is no shadow. +This is a placeholder for introducing cloud shadows later. + ::: {.sourceClojure} ```clojure @@ -1996,6 +2118,8 @@ float shadow(vec3 point) ::: +We can now test the color and opacity of the cloud using the following probing shader. + ::: {.sourceClojure} ```clojure @@ -2015,6 +2139,8 @@ void main() ::: +We also introduce a Midje checker for requiring a vector to have an approximate value. + ::: {.sourceClojure} ```clojure @@ -2028,11 +2154,13 @@ void main() ::: +A few tests are performed to check that there is opacity and that the step size does not affect the result in constant fog. + ::: {.sourceClojure} ```clojure (tabular "Test cloud transfer" - (fact (seq (render-pixel [vertex-test] + (fact (seq (render-pixel [vertex-passthrough] [(fog ?density) constant-scatter no-shadow (cloud-transfer "fog" ?step) (cloud-transfer-probe ?a ?b)])) @@ -2056,7 +2184,17 @@ true -## Rendering of fog box +### Rendering of fog box + +The following fragment shader is used to render an image of a box filled with fog. + + +* The pixel coordinate and the resolution of the image are used to determine a viewing direction which also gets rotated using the rotation matrix. +* The origin of the camera is set at a specified distance to the center of the box and rotated as well. +* The ray box function is used to determine the near and far intersection points of the ray with the box. +* The cloud transfer function is used to sample the cloud density along the ray and determine the overall opacity and color of the fog box. +* The background is a mix of blue color and a small blob of white where the viewing direction points to the light source. +* The opacity value of the fog is used to overlay the fog color over the background. ::: {.sourceClojure} @@ -2112,7 +2250,7 @@ void main() [width height] (let [fragment-sources [ray-box constant-scatter no-shadow (cloud-transfer "fog" 0.01) (fog 1.0) fragment-cloud] - program (make-program-with-shaders [vertex-test] fragment-sources) + program (make-program-with-shaders [vertex-passthrough] fragment-sources) vao (setup-quad-vao)] (setup-point-attribute program) (try @@ -2152,7 +2290,7 @@ void main() -## Rendering of 3D noise +### Rendering of 3D noise ::: {.sourceClojure} @@ -2233,7 +2371,7 @@ float noise(vec3 idx) (defn render-noise [width height & cloud-shaders] (let [fragment-sources (concat cloud-shaders [ray-box fragment-cloud]) - program (make-program-with-shaders [vertex-test] fragment-sources) + program (make-program-with-shaders [vertex-passthrough] fragment-sources) vao (setup-quad-vao)] (try (setup-point-attribute program) @@ -2265,7 +2403,7 @@ float noise(vec3 idx) -## Remap and clamp 3D noise +### Remap and clamp 3D noise ::: {.sourceClojure} @@ -2302,7 +2440,7 @@ void main() ```clojure (tabular "Remap and clamp input parameter values" (fact (first (render-pixel - [vertex-test] + [vertex-passthrough] [remap-clamp (remap-probe ?value ?low1 ?high1 ?low2 ?high2)])) => ?expected) ?value ?low1 ?high1 ?low2 ?high2 ?expected @@ -2371,7 +2509,7 @@ float remap_noise(vec3 idx) -## Octaves of 3D noise +### Octaves of 3D noise ::: {.sourceClojure} @@ -2392,7 +2530,7 @@ float remap_noise(vec3 idx) -## Mie scattering +### Mie scattering ::: {.sourceClojure} @@ -2437,7 +2575,7 @@ void main() ::: {.sourceClojure} ```clojure (tabular "Shader function for scattering phase function" - (fact (first (render-pixel [vertex-test] [(mie-scatter ?g) (mie-probe ?mu)])) + (fact (first (render-pixel [vertex-passthrough] [(mie-scatter ?g) (mie-probe ?mu)])) => (roughly ?result 1e-6)) ?g ?mu ?result 0 0 (/ 3 (* 16 PI)) @@ -2462,7 +2600,7 @@ true ::: {.sourceClojure} ```clojure (defn scatter-amount [theta] - (first (render-pixel [vertex-test] [(mie-scatter 0.76) (mie-probe (cos theta))]))) + (first (render-pixel [vertex-passthrough] [(mie-scatter 0.76) (mie-probe (cos theta))]))) ``` ::: @@ -2513,7 +2651,7 @@ true -## Self-shading of clouds +### Self-shading of clouds ::: {.sourceClojure} @@ -2561,7 +2699,7 @@ float shadow(vec3 point) -## Tidy up +### Tidy up ::: {.sourceClojure} @@ -2638,6 +2776,7 @@ nil * [Vertical density profile](https://www.wedesoft.de/software/2023/05/03/volumetric-clouds/) * [Powder function](https://advances.realtimerendering.com/s2015/index.html) * [Curl noise](https://www.wedesoft.de/software/2023/03/20/procedural-global-cloud-cover/) +* [Precomputed atmospheric scattering](https://ebruneton.github.io/precomputed_atmospheric_scattering/) * [Deep opacity maps](https://www.wedesoft.de/software/2023/05/03/volumetric-clouds/)