├── LICENSE ├── README.md ├── consts.js ├── doc ├── blue_ks.png ├── mixture.png ├── white_ks.png └── yellow_ks.png ├── index.html ├── km.js └── serve /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Lars Wander 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kubelka-Munk in GLSL 2 | 3 | This repository contains a working implementation of Kubelka-Munk theory, but 4 | with fake K/S data. This being released as an educational guide, and to help 5 | those interested in developing their own imaginary pigments. 6 | 7 | ## Background 8 | 9 | [Kubelka-Munk theory](https://en.wikipedia.org/wiki/Kubelka%E2%80%93Munk_theory) 10 | models the color of a mixture of pigments. To do so, it uses information about 11 | the pigments in the form of their K (absorption) and S (scattering) curves. 12 | These curves relate wavelengths of light (typically in the visible spectrum) to 13 | the amount of light absorped or scattered. 14 | 15 | Kubelka-Munk theory is both effective and efficient when it comes to simulating 16 | pigment mixtures. However, it is difficult to acquire the needed K & S curves 17 | to perform this mixing accurately. Doing so by hand requires a lot of careful 18 | data collection and computation (see [this 19 | thesis](https://scholarworks.rit.edu/theses/4892/) for more information). In 20 | addition, the K & S curves are considered to be the IP of the paint 21 | manufacturer, so they are not keen to have them shared once known. 22 | 23 | This repository contains an implementation of Kubelka-Munk pigment mixing, but 24 | with fake spectral data for the supplied `WHITE`, `BLUE`, and `YELLOW` 25 | pigments. These fake spectral curves look like this: 26 | 27 | ![white_ks](/doc/white_ks.png) 28 | 29 | ![yellow_ks](/doc/yellow_ks.png) 30 | 31 | ![blue_ks](/doc/blue_ks.png) 32 | 33 | These approxmiate white, yellow, and blue, but when mixed still create grey: 34 | 35 | ![mixture](/doc/mixture.png) 36 | 37 | To perform more interesting or convincing mixing, better spectral data is 38 | needed. Feel free to contribute improved K/S curves if you develop them. __Do 39 | not__ contribute curves that may be considered IP. 40 | 41 | ## Running 42 | 43 | You can demo the pigment mixing in your browser. For convenience sake, the 44 | `serve` script runs a Python HTTP server in this directory: 45 | 46 | ```bash 47 | $ ./serve 48 | $ # open http://localhost:9119 in your browser 49 | ``` 50 | 51 | ## Contributing 52 | 53 | Try changing the colors (or adding more) in `consts.js`. These are interpreted 54 | and mixed in the fragment shader in `km.js`. 55 | 56 | ## Resources 57 | 58 | - Spectral data source: https://cie.co.at/data-tables. 59 | - Overview of KM mixing: https://scholarworks.rit.edu/theses/4892/ 60 | -------------------------------------------------------------------------------- /consts.js: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2023 Lars Wander 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a 6 | // copy of this software and associated documentation files (the "Software"), 7 | // to deal in the Software without restriction, including without limitation 8 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | // and/or sell copies of the Software, and to permit persons to whom the 10 | // Software is furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in 13 | // all copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | // DEALINGS IN THE SOFTWARE. 22 | 23 | const SPD_MIN_NM = 380; 24 | const SPD_MAX_NM = 750; 25 | const SPD_STEP_SIZE_NM = 10; 26 | export const SPD_BUCKETS = (SPD_MAX_NM - SPD_MIN_NM) / SPD_STEP_SIZE_NM + 1; 27 | 28 | function assertBucketCount(curve, name) { 29 | if (curve.length !== SPD_BUCKETS) { 30 | throw new Error( 31 | `Invalid bucket count for ${name}: ${curve.length} != ${SPD_BUCKETS}` 32 | ); 33 | } 34 | } 35 | 36 | // CIE standard illuminant D65, in 10nm increments from 380-750nm. 37 | // https://en.wikipedia.org/wiki/Illuminant_D65 38 | // https://cie.co.at/data-tables 39 | const D65 = [49.9755, 54.6482, 82.7549, 91.486, 93.4318, 86.6823, 104.865, 40 | 117.008, 117.812, 114.861, 115.923, 108.811, 109.354, 107.802, 104.79, 41 | 107.689, 104.405, 104.046, 100, 96.3342, 95.788, 88.6856, 90.0062, 89.5991, 42 | 87.6987, 83.2886, 83.6992, 80.0268, 80.2146, 82.2778, 78.2842, 69.7213, 43 | 71.6091, 74.349, 61.604, 69.8856, 75.087, 63.5927]; 44 | 45 | assertBucketCount(D65, "D65 illuminant"); 46 | 47 | // CIElab standard observer functions, in 10nm increments from 380-750nm. 48 | // https://en.wikipedia.org/wiki/CIE_1931_color_space#CIE_standard_observer 49 | // https://cie.co.at/data-tables 50 | const X_ = [0.0002, 0.0024, 0.0191, 0.0847, 0.2045, 0.3147, 0.3837, 0.3707, 51 | 0.3023, 0.1956, 0.0805, 0.0162, 0.0038, 0.0375, 0.1177, 0.2365, 0.3768, 52 | 0.5298, 0.7052, 0.8787, 1.0142, 1.1185, 1.124, 1.0305, 0.8563, 0.6475, 53 | 0.4316, 0.2683, 0.1526, 0.0813, 0.0409, 0.0199, 0.0096, 0.0046, 0.0022, 54 | 0.001, 0.0005, 0.0003]; 55 | 56 | assertBucketCount(X_, "CIE X observer"); 57 | 58 | const Y_ = [0, 0.0003, 0.002, 0.0088, 0.0214, 0.0387, 0.0621, 0.0895, 0.1282, 59 | 0.1852, 0.2536, 0.3391, 0.4608, 0.6067, 0.7618, 0.8752, 0.962, 0.9918, 60 | 0.9973, 0.9556, 0.8689, 0.7774, 0.6583, 0.528, 0.3981, 0.2835, 0.1798, 61 | 0.1076, 0.0603, 0.0318, 0.0159, 0.0077, 0.0037, 0.0018, 0.0008, 0.0004, 62 | 0.0002, 0.0001]; 63 | 64 | assertBucketCount(Y_, "CIE Y observer"); 65 | 66 | const Z_ = [0.0007, 0.0105, 0.086, 0.3894, 0.9725, 1.5535, 1.9673, 1.9948, 67 | 1.7454, 1.3176, 0.7721, 0.4153, 0.2185, 0.112, 0.0607, 0.0305, 0.0137, 0.004, 68 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] 69 | 70 | assertBucketCount(Z_, "CIE Z observer"); 71 | 72 | export const D65X = D65.map((v, i) => v * X_[i]); 73 | export const D65Y = D65.map((v, i) => v * Y_[i]); 74 | export const D65Z = D65.map((v, i) => v * Z_[i]); 75 | 76 | // Imaginary K/S curves. 77 | // K is the "absorption" and S is the "scattering" for the pigment. 78 | export const WHITE = { 79 | k: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 80 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 81 | s: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 82 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] , 83 | }; 84 | 85 | assertBucketCount(WHITE.k, "white K"); 86 | assertBucketCount(WHITE.k, "white S"); 87 | 88 | export const YELLOW = { 89 | k: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 90 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 91 | s: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 92 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] , 93 | } 94 | 95 | assertBucketCount(YELLOW.k, "yellow K"); 96 | assertBucketCount(YELLOW.s, "yellow S"); 97 | 98 | export const BLUE = { 99 | k: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 100 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1], 101 | s: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 102 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1] , 103 | } 104 | 105 | assertBucketCount(BLUE.k, "blue K"); 106 | assertBucketCount(BLUE.s, "blue S"); 107 | -------------------------------------------------------------------------------- /doc/blue_ks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lwander/open-km/24222a131c94cdaf8cbbe76c0c228d44a536c99a/doc/blue_ks.png -------------------------------------------------------------------------------- /doc/mixture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lwander/open-km/24222a131c94cdaf8cbbe76c0c228d44a536c99a/doc/mixture.png -------------------------------------------------------------------------------- /doc/white_ks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lwander/open-km/24222a131c94cdaf8cbbe76c0c228d44a536c99a/doc/white_ks.png -------------------------------------------------------------------------------- /doc/yellow_ks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lwander/open-km/24222a131c94cdaf8cbbe76c0c228d44a536c99a/doc/yellow_ks.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Open KM 4 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /km.js: -------------------------------------------------------------------------------- 1 | // MIT License 2 | // 3 | // Copyright (c) 2023 Lars Wander 4 | // 5 | // Permission is hereby granted, free of charge, to any person obtaining a 6 | // copy of this software and associated documentation files (the "Software"), 7 | // to deal in the Software without restriction, including without limitation 8 | // the rights to use, copy, modify, merge, publish, distribute, sublicense, 9 | // and/or sell copies of the Software, and to permit persons to whom the 10 | // Software is furnished to do so, subject to the following conditions: 11 | // 12 | // The above copyright notice and this permission notice shall be included in 13 | // all copies or substantial portions of the Software. 14 | // 15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 20 | // FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 21 | // DEALINGS IN THE SOFTWARE. 22 | 23 | import {D65X, D65Y, D65Z, WHITE, YELLOW, BLUE, SPD_BUCKETS} from './consts.js'; 24 | 25 | // Basic vertex shader to assign unit coordinates. 26 | const KM_VERT_SRC = `#version 300 es 27 | precision highp float; 28 | 29 | in vec2 a_coord; 30 | 31 | out vec2 v_tex; 32 | 33 | void main() { 34 | vec2 pos = (a_coord * 2.0) - 1.0; 35 | gl_Position = vec4(pos, 0.0, 1.0); 36 | v_tex = a_coord; 37 | } 38 | ` 39 | 40 | // Kubelka-Munk fragment shader. 41 | const KM_FRAG_SRC = `#version 300 es 42 | precision highp float; 43 | 44 | // Reflectance constants. 45 | const float K1 = 0.0031; 46 | const float K2 = 0.650; 47 | 48 | // Normalization factor for Y. 49 | const float YD65 = 11619.34742175; 50 | 51 | // XYZ -> RGB conversion matrix. 52 | const mat3 C = mat3( 3.2404542, -1.5371385, -0.4985314, 53 | -0.9692660, 1.8760108, 0.0415560, 54 | 0.0556434, -0.2040259, 1.0572252); 55 | 56 | // Paint K & S curves. 57 | uniform vec3 u_Pk[${SPD_BUCKETS}]; 58 | uniform vec3 u_Ps[${SPD_BUCKETS}]; 59 | 60 | // XYZ spectral sensitivity curve. 61 | uniform vec3 u_obs[${SPD_BUCKETS}]; 62 | 63 | in vec2 v_tex; 64 | out vec4 o_col; 65 | 66 | float reflectance_mix(float ks) { 67 | return 1.0 + ks - sqrt(ks * ks + 2.0 * ks); 68 | } 69 | 70 | float saunderson_mix(float ks) { 71 | float R = reflectance_mix(ks); 72 | return ((1.0 - K1) * (1.0 - K2) * R) / (1.0 - K2 * R); 73 | } 74 | 75 | vec3 rgb_to_srgb(in vec3 rgb) { 76 | return pow(rgb, vec3(1.0 / 2.2)); 77 | } 78 | 79 | vec3 pigment_to_srgb(vec3 P) { 80 | vec3 res = vec3(0.0, 0.0, 0.0); 81 | vec3 table[${SPD_BUCKETS}]; 82 | 83 | for (int f = 0; f < ${SPD_BUCKETS}; f++) { 84 | float K = dot(P, u_Pk[f]); 85 | float S = dot(P, u_Ps[f]); 86 | 87 | float R = saunderson_mix(K / S); 88 | table[f] = u_obs[f] * R; 89 | } 90 | 91 | // Trapezoidal rule for integration. 92 | for (int f = 0; f < ${SPD_BUCKETS} - 1; f++) { 93 | res += table[f] + table[f + 1]; 94 | } 95 | res *= 5.0; 96 | 97 | return rgb_to_srgb(res * C / YD65); 98 | } 99 | 100 | void main(void) { 101 | vec3 pigments = vec3(v_tex.y, v_tex.x, 1.0 - v_tex.x); 102 | o_col = vec4(pigment_to_srgb(pigments), 1.0); 103 | } 104 | `; 105 | 106 | function glZip(arrs) { 107 | const res = Array(arrs[0].length * arrs.length); 108 | for (let i = 0; i < arrs[0].length; i++) { 109 | for (let k = 0; k < arrs.length; k++) { 110 | res[i * arrs.length + k] = arrs[k][i]; 111 | } 112 | } 113 | 114 | return res; 115 | } 116 | 117 | function createShader(gl, shaderType, shaderSrc) { 118 | const shader = gl.createShader(shaderType); 119 | gl.shaderSource(shader, shaderSrc); 120 | gl.compileShader(shader); 121 | if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { 122 | console.error(shaderSrc, gl.getShaderInfoLog(shader)); 123 | gl.deleteShader(shader); 124 | throw new Error("Unable to compile shader, see console"); 125 | } 126 | 127 | return shader; 128 | } 129 | 130 | function createProgram(gl, vs, fs) { 131 | const program = gl.createProgram(); 132 | gl.attachShader(program, vs); 133 | gl.attachShader(program, fs); 134 | gl.linkProgram(program); 135 | if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { 136 | console.error(gl.getProgramInfoLog(program)); 137 | gl.deleteProgram(program); 138 | throw new Error("Unable to link program, see console"); 139 | } 140 | 141 | return program; 142 | } 143 | 144 | // 1-time setup to prepare GL state. 145 | function setupPaintMix(canvas, paints) { 146 | const gl = canvas.getContext("webgl2"); 147 | if (!gl) throw new Error("Unable to acquire webgl2 context"); 148 | 149 | const vertShader = createShader(gl, gl.VERTEX_SHADER, KM_VERT_SRC); 150 | const fragShader = createShader(gl, gl.FRAGMENT_SHADER, KM_FRAG_SRC) 151 | 152 | const program = createProgram(gl, vertShader, fragShader); 153 | 154 | const vertexArray = gl.createVertexArray(); 155 | gl.bindVertexArray(vertexArray); 156 | 157 | const x0 = 0, y0 = 0, x1 = 1, y1 = 1; 158 | const coordBuffer = gl.createBuffer(); 159 | gl.bindBuffer(gl.ARRAY_BUFFER, coordBuffer); 160 | gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ 161 | x0, y0, 162 | x1, y0, 163 | x0, y1, 164 | x0, y1, 165 | x1, y0, 166 | x1, y1, 167 | ]), gl.STATIC_DRAW); 168 | 169 | const coordLoc = gl.getAttribLocation(program, "a_coord"); 170 | gl.bindBuffer(gl.ARRAY_BUFFER, coordBuffer); 171 | gl.enableVertexAttribArray(coordLoc); 172 | gl.vertexAttribPointer(coordLoc, 2, gl.FLOAT, false, 0, 0); 173 | 174 | const locs = {} 175 | for (const u of ["u_obs", "u_Pk", "u_Ps"]) { 176 | locs[u] = gl.getUniformLocation(program, u); 177 | } 178 | 179 | // Zip paint vectors and observer functions into GL-friendly format. 180 | const obs = glZip([D65X, D65Y, D65Z]); 181 | const paintK = glZip(paints.map(paint => paint.k)); 182 | const paintS = glZip(paints.map(paint => paint.s)); 183 | 184 | return { 185 | gl, 186 | canvas, 187 | program, 188 | locs, 189 | vertexArray, 190 | obs, 191 | paintK, 192 | paintS, 193 | } 194 | } 195 | 196 | function ensureCanvasSize(canvas, width, height) { 197 | if (canvas.width === width && canvas.height === height) return; 198 | canvas.width = width; 199 | canvas.height = height; 200 | canvas.style.width = width; 201 | canvas.style.height = height; 202 | } 203 | 204 | // Runs the KM shader. This can be run on every frame. 205 | function runPaintMix(paintMix, width, height) { 206 | const {gl, program, vertexArray, locs, obs, paintS, paintK} = paintMix; 207 | 208 | gl.bindFramebuffer(gl.FRAMEBUFFER, null); 209 | gl.viewport(0, 0, width, height); 210 | gl.clearColor(0, 0, 0, 0); 211 | gl.clear(gl.COLOR_BUFFER_BIT); 212 | 213 | gl.useProgram(program); 214 | gl.bindVertexArray(vertexArray); 215 | 216 | gl.uniform3fv(locs.u_obs, obs); 217 | gl.uniform3fv(locs.u_Pk, paintK); 218 | gl.uniform3fv(locs.u_Ps, paintS); 219 | 220 | gl.drawArrays(gl.TRIANGLES, 0, 6); 221 | } 222 | 223 | function main() { 224 | const canvas = document.createElement("canvas"); 225 | document.body.appendChild(canvas); 226 | 227 | const paintMix = setupPaintMix(canvas, [WHITE, YELLOW, BLUE]); 228 | 229 | function draw() { 230 | const width = window.innerWidth; 231 | const height = window.innerHeight; 232 | ensureCanvasSize(canvas, width, height); 233 | runPaintMix(paintMix, width, height); 234 | } 235 | 236 | window.addEventListener("resize", draw); 237 | draw(); 238 | } 239 | 240 | main(); 241 | -------------------------------------------------------------------------------- /serve: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | python3 -m http.server 9119 4 | --------------------------------------------------------------------------------