├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── imgui.ini ├── main.py └── resources ├── blur.glsl ├── render_texture.glsl └── update.glsl /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .nox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | .pytest_cache/ 50 | 51 | # Translations 52 | *.mo 53 | *.pot 54 | 55 | # Django stuff: 56 | *.log 57 | local_settings.py 58 | db.sqlite3 59 | 60 | # Flask stuff: 61 | instance/ 62 | .webassets-cache 63 | 64 | # Scrapy stuff: 65 | .scrapy 66 | 67 | # Sphinx documentation 68 | docs/_build/ 69 | 70 | # PyBuilder 71 | target/ 72 | 73 | # Jupyter Notebook 74 | .ipynb_checkpoints 75 | 76 | # IPython 77 | profile_default/ 78 | ipython_config.py 79 | 80 | # pyenv 81 | .python-version 82 | 83 | # celery beat schedule file 84 | celerybeat-schedule 85 | 86 | # SageMath parsed files 87 | *.sage.py 88 | 89 | # Environments 90 | .env 91 | .venv 92 | env/ 93 | venv/ 94 | ENV/ 95 | env.bak/ 96 | venv.bak/ 97 | 98 | # Spyder project settings 99 | .spyderproject 100 | .spyproject 101 | 102 | # Rope project settings 103 | .ropeproject 104 | 105 | # mkdocs documentation 106 | /site 107 | 108 | # mypy 109 | .mypy_cache/ 110 | .dmypy.json 111 | dmypy.json 112 | 113 | # Pyre type checker 114 | .pyre/ 115 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Leterax 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 | # slimes 2 | 3 | A quick implementation heavily inspired by [Sebastian Lague's](https://www.youtube.com/watch?v=X-iSQQgOd1A) implementation. 4 | 5 | See my implementation in action on the accompanying [blog post](https://technicallyruns.com/posts/slimes/) 6 | -------------------------------------------------------------------------------- /imgui.ini: -------------------------------------------------------------------------------- 1 | [Window][Debug##Default] 2 | Pos=60,60 3 | Size=400,400 4 | Collapsed=0 5 | 6 | [Window][Settings] 7 | Pos=930,128 8 | Size=309,223 9 | Collapsed=0 10 | 11 | [Window][Appearance] 12 | Pos=0,1 13 | Size=576,86 14 | Collapsed=1 15 | 16 | [Window][Actions] 17 | Pos=1036,21 18 | Size=327,87 19 | Collapsed=0 20 | 21 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | import math 2 | import pathlib 3 | import moderngl_window as mglw 4 | from moderngl_window.capture.ffmpeg import FFmpegCapture 5 | from moderngl_window.geometry import quad_fs 6 | import moderngl as mgl 7 | import numpy as np 8 | import imgui 9 | from moderngl_window.integrations.imgui import ModernglWindowRenderer 10 | 11 | 12 | def gen_data(N, size): 13 | r = 500 14 | angles = np.random.random(N) * 2 * np.pi 15 | dst = np.random.random(N) * r 16 | 17 | a = size[0] / 2 + np.cos(angles) * dst 18 | b = size[1] / 2 + np.sin(angles) * dst 19 | 20 | return np.c_[a, b, (np.pi + angles), np.empty(N)] 21 | 22 | 23 | class SlimeConfig: 24 | N = 1_000_000 25 | move_speed = 50.0 26 | turn_speed = 50.0 27 | 28 | evaporation_speed = 5.0 29 | diffusion_speed = 10.0 30 | sensor_angle = 0.83 31 | sensor_size = 1 32 | sensor_distance = 10.0 33 | 34 | color1 = (1,1,1) 35 | color2 = (66/255, 135/255, 245/255) 36 | 37 | 38 | class SlimeWindow(mglw.WindowConfig): 39 | title = "Slimes" 40 | gl_version = (4, 5) 41 | window_size = (1280, 720) 42 | resource_dir = (pathlib.Path(__file__).parent / "resources").resolve() 43 | map_size = (2560, 1440) 44 | aspect_ratio = None 45 | local_size = 1024 46 | vsync = False 47 | samples = 16 48 | 49 | def __init__(self, *args, **kwargs): 50 | super().__init__(*args, **kwargs) 51 | imgui.create_context() 52 | 53 | self.imgui = ModernglWindowRenderer(self.wnd) 54 | 55 | self.world_texture01 = self.ctx.texture(self.map_size, 1, dtype="f1") 56 | self.world_texture01.repeat_x, self.world_texture01.repeat_y = False, False 57 | self.world_texture01.filter = mgl.NEAREST, mgl.NEAREST 58 | 59 | self.world_texture02 = self.ctx.texture(self.map_size, 1, dtype="f1") 60 | self.world_texture02.repeat_x, self.world_texture02.repeat_y = False, False 61 | self.world_texture02.filter = mgl.NEAREST, mgl.NEAREST 62 | 63 | data = gen_data(SlimeConfig.N, self.map_size).astype("f4") 64 | self.slimes = self.ctx.buffer(data) # each slime has a position and angle 65 | 66 | self.load_programs() 67 | 68 | self.update_uniforms() 69 | 70 | self.quad_fs = quad_fs(normals=False) 71 | 72 | self.videocapture = mglw.capture.FFmpegCapture(source=self.wnd.fbo) 73 | 74 | def restart_sim(self): 75 | self.world_texture01.release() 76 | self.world_texture02.release() 77 | 78 | self.world_texture01 = self.ctx.texture(self.map_size, 1, dtype="f1") 79 | self.world_texture01.repeat_x, self.world_texture01.repeat_y = False, False 80 | self.world_texture01.filter = mgl.NEAREST, mgl.NEAREST 81 | 82 | self.world_texture02 = self.ctx.texture(self.map_size, 1, dtype="f1") 83 | self.world_texture02.repeat_x, self.world_texture02.repeat_y = False, False 84 | self.world_texture02.filter = mgl.NEAREST, mgl.NEAREST 85 | 86 | data = gen_data(SlimeConfig.N, self.map_size).astype("f4") 87 | self.slimes.orphan(SlimeConfig.N * 4 * 4) 88 | self.slimes.write(data) 89 | 90 | def update_uniforms(self): 91 | self.blurr["diffuseSpeed"] = SlimeConfig.diffusion_speed 92 | self.blurr["evaporateSpeed"] = SlimeConfig.evaporation_speed 93 | 94 | self.compute_shader["moveSpeed"] = SlimeConfig.move_speed 95 | self.compute_shader["turnSpeed"] = SlimeConfig.turn_speed 96 | 97 | self.compute_shader["senorAngleSpacing"] = SlimeConfig.sensor_angle 98 | self.compute_shader["sensorDst"] = SlimeConfig.sensor_distance 99 | self.compute_shader["sensorSize"] = SlimeConfig.sensor_size 100 | 101 | self.render_program["color1"] = SlimeConfig.color1 102 | self.render_program["color2"] = SlimeConfig.color2 103 | 104 | self.compute_shader["N"] = SlimeConfig.N 105 | 106 | def load_programs(self): 107 | self.render_program = self.load_program("render_texture.glsl") 108 | self.render_program["texture0"] = 0 109 | self.compute_shader = self.load_compute_shader( 110 | "update.glsl", 111 | { 112 | "width": self.map_size[0], 113 | "height": self.map_size[1], 114 | "local_size": self.local_size, 115 | }, 116 | ) 117 | self.blurr = self.load_compute_shader("blur.glsl") 118 | 119 | def render(self, time: float, frame_time: float): 120 | self.world_texture01.use(0) 121 | self.quad_fs.render(self.render_program) 122 | 123 | self.world_texture01.bind_to_image(1, read=True, write=False) 124 | self.world_texture02.bind_to_image(0, read=False, write=True) 125 | self.slimes.bind_to_storage_buffer(2) 126 | 127 | # self.compute_shader["dt"] = frame_time 128 | # self.blurr["dt"] = frame_time 129 | 130 | group_size = int(math.ceil(SlimeConfig.N / self.local_size)) 131 | self.compute_shader.run(group_size, 1, 1) 132 | 133 | self.world_texture01.bind_to_image(0, read=True, write=False) 134 | self.world_texture02.bind_to_image(1, read=True, write=True) 135 | self.blurr.run(self.map_size[0] // 16 + 1, self.map_size[1] // 16 + 1) 136 | 137 | self.world_texture01, self.world_texture02 = ( 138 | self.world_texture02, 139 | self.world_texture01, 140 | ) 141 | 142 | self.videocapture.save() 143 | 144 | self.render_ui() 145 | 146 | def render_ui(self): 147 | imgui.new_frame() 148 | if imgui.begin("Settings"): 149 | imgui.push_item_width(imgui.get_window_width() * 0.33) 150 | changed = False 151 | c, SlimeConfig.move_speed = imgui.slider_float( 152 | "Movement speed", SlimeConfig.move_speed, 0.5, 50 153 | ) 154 | changed = changed or c 155 | c, SlimeConfig.turn_speed = imgui.slider_float( 156 | "Turn speed", 157 | SlimeConfig.turn_speed, 158 | 0.5, 159 | 50, 160 | ) 161 | changed = changed or c 162 | c, SlimeConfig.evaporation_speed = imgui.slider_float( 163 | "Evaporation speed", SlimeConfig.evaporation_speed, 0.1, 20 164 | ) 165 | changed = changed or c 166 | c, SlimeConfig.diffusion_speed = imgui.slider_float( 167 | "Diffusion speed", 168 | SlimeConfig.diffusion_speed, 169 | 0.1, 170 | 20, 171 | ) 172 | changed = changed or c 173 | c, SlimeConfig.sensor_angle = imgui.slider_float( 174 | "Sensor-angle", 175 | SlimeConfig.sensor_angle, 176 | 0, 177 | np.pi, 178 | ) 179 | changed = changed or c 180 | c, SlimeConfig.sensor_size = imgui.slider_int( 181 | "Sensor-size", 182 | SlimeConfig.sensor_size, 183 | 1, 184 | 3, 185 | ) 186 | changed = changed or c 187 | c, SlimeConfig.sensor_distance = imgui.slider_int( 188 | "Sensor distance", 189 | SlimeConfig.sensor_distance, 190 | 1, 191 | 25, 192 | ) 193 | changed = changed or c 194 | if changed: 195 | self.update_uniforms() 196 | imgui.pop_item_width() 197 | 198 | imgui.end() 199 | 200 | if imgui.begin("Appearance"): 201 | imgui.push_item_width(imgui.get_window_width() * 0.33) 202 | changed_c1, SlimeConfig.color1 = imgui.color_edit3( 203 | "Color1", *SlimeConfig.color1 204 | ) 205 | changed_c2, SlimeConfig.color2 = imgui.color_edit3( 206 | "Color2", *SlimeConfig.color2 207 | ) 208 | if changed_c1 or changed_c2: 209 | self.update_uniforms() 210 | 211 | imgui.end() 212 | 213 | if imgui.begin("Actions"): 214 | imgui.push_item_width(imgui.get_window_width() * 0.33) 215 | changed, SlimeConfig.N = imgui.input_int( 216 | "Number of Slimes", SlimeConfig.N, step=1024, step_fast=2**15 217 | ) 218 | SlimeConfig.N = min(max(2048, SlimeConfig.N), 2**24) 219 | if imgui.button("Restart Slimes"): 220 | self.restart_sim() 221 | 222 | imgui.pop_item_width() 223 | 224 | imgui.end() 225 | imgui.render() 226 | self.imgui.render(imgui.get_draw_data()) 227 | 228 | def resize(self, width: int, height: int): 229 | self.imgui.resize(width, height) 230 | 231 | def key_event(self, key, action, modifiers): 232 | keys = self.wnd.keys 233 | if action == keys.ACTION_PRESS and key == keys.R: 234 | self.videocapture.start_capture( 235 | filename="video.mp4", 236 | framerate=30 237 | ) 238 | if action == keys.ACTION_PRESS and key == keys.F: 239 | self.videocapture.release() 240 | self.imgui.key_event(key, action, modifiers) 241 | 242 | def mouse_position_event(self, x, y, dx, dy): 243 | self.imgui.mouse_position_event(x, y, dx, dy) 244 | 245 | def mouse_drag_event(self, x, y, dx, dy): 246 | self.imgui.mouse_drag_event(x, y, dx, dy) 247 | 248 | def mouse_scroll_event(self, x_offset, y_offset): 249 | self.imgui.mouse_scroll_event(x_offset, y_offset) 250 | 251 | def mouse_press_event(self, x, y, button): 252 | self.imgui.mouse_press_event(x, y, button) 253 | 254 | def mouse_release_event(self, x: int, y: int, button: int): 255 | self.imgui.mouse_release_event(x, y, button) 256 | 257 | def unicode_char_entered(self, char): 258 | self.imgui.unicode_char_entered(char) 259 | 260 | 261 | SlimeWindow.run() 262 | -------------------------------------------------------------------------------- /resources/blur.glsl: -------------------------------------------------------------------------------- 1 | #version 450 2 | 3 | // reduce fermones and blur fermones 4 | 5 | layout (local_size_x = 16, local_size_y = 16) in; 6 | 7 | layout(r8, location=0) restrict readonly uniform image2D fromTex; 8 | layout(r8, location=1) uniform image2D destTex; 9 | 10 | float fetchValue(ivec2 co) { 11 | return imageLoad(fromTex, co).r; 12 | } 13 | 14 | float blured(ivec2 co) { 15 | float sum = 16 | fetchValue(co) + 17 | fetchValue(co + ivec2(-1, -1)) + 18 | fetchValue(co + ivec2( 0, -1)) + 19 | fetchValue(co + ivec2( 1, -1)) + 20 | fetchValue(co + ivec2( 1, 0)) + 21 | fetchValue(co + ivec2( 1, 1)) + 22 | fetchValue(co + ivec2( 0, 1)) + 23 | fetchValue(co + ivec2(-1, 1)) + 24 | fetchValue(co + ivec2(-1, 0)); 25 | 26 | return sum / 9.; 27 | } 28 | 29 | // uniform float dt; 30 | uniform float diffuseSpeed; 31 | uniform float evaporateSpeed; 32 | 33 | #define dt 0.0166 34 | 35 | void main() { 36 | ivec2 texelPos = ivec2(gl_GlobalInvocationID.xy); 37 | float original_value = imageLoad(fromTex, texelPos).r; 38 | float v = blured(texelPos); 39 | 40 | float diffused = mix(original_value, v, diffuseSpeed * dt); 41 | float evaporated = max(0, diffused - evaporateSpeed * dt); 42 | 43 | imageStore(destTex, texelPos, vec4(evaporated)); 44 | } 45 | -------------------------------------------------------------------------------- /resources/render_texture.glsl: -------------------------------------------------------------------------------- 1 | #version 430 2 | 3 | #if defined VERTEX_SHADER 4 | 5 | in vec3 in_position; 6 | in vec2 in_texcoord_0; 7 | out vec2 uv; 8 | 9 | void main() { 10 | gl_Position = vec4(in_position, 1.0); 11 | uv = in_texcoord_0; 12 | } 13 | 14 | #elif defined FRAGMENT_SHADER 15 | 16 | 17 | uniform sampler2D texture0; 18 | out vec4 fragColor; 19 | in vec2 uv; 20 | 21 | uniform vec3 color1; 22 | uniform vec3 color2; 23 | 24 | void main() { 25 | vec3 c = mix(color1, color2, texture(texture0, uv).r); 26 | fragColor = vec4(c, 1.); 27 | } 28 | 29 | #endif -------------------------------------------------------------------------------- /resources/update.glsl: -------------------------------------------------------------------------------- 1 | #version 450 2 | 3 | // Update and move all slimes. Then lay down fermones onto world texture 4 | 5 | #define width 0 6 | #define height 0 7 | #define local_size 1 8 | 9 | #define PI 3.141592653 10 | #define dt 0.0166 11 | 12 | 13 | layout (local_size_x = local_size, local_size_y = 1, local_size_z = 1) in; 14 | 15 | layout(r8, location=0) restrict writeonly uniform image2D destTex; 16 | layout(r8, location=1) restrict readonly uniform image2D fromTex; 17 | 18 | struct Slime { 19 | float x, y, angle, padding; 20 | }; 21 | 22 | layout(std430, binding=2) restrict buffer inslimes { 23 | Slime slimes[]; 24 | } SlimeBuffer; 25 | 26 | uniform int N; 27 | 28 | // uniform float dt; 29 | uniform float moveSpeed; 30 | uniform float senorAngleSpacing; 31 | uniform float sensorDst; 32 | uniform float turnSpeed; 33 | uniform int sensorSize; 34 | 35 | 36 | float rand(vec2 co){ 37 | return fract(sin(dot(co.xy ,vec2(12.9898,78.233))) * 43758.5453); 38 | } 39 | 40 | void drawSlime(Slime slime) { 41 | imageStore(destTex, ivec2(slime.x, slime.y), vec4(1.)); 42 | } 43 | 44 | float sense(Slime slime, float sensorAngleOffset) { 45 | float sensorAngle = slime.angle + sensorAngleOffset; 46 | vec2 sensorDir = vec2(cos(sensorAngle), sin(sensorAngle)); 47 | ivec2 sensorCentre = ivec2(slime.x, slime.y) + ivec2(sensorDir * sensorDst); 48 | 49 | float sum = 0; 50 | for (int offsetX = -sensorSize; offsetX <= sensorSize; offsetX++) { 51 | for (int offsetY = -sensorSize; offsetY <= sensorSize; offsetY++) { 52 | ivec2 pos = sensorCentre + ivec2(offsetX, offsetY); 53 | 54 | if (pos.x >= 0 && pos.x < width && pos.y >=0 && pos.y < height) { 55 | sum += imageLoad(fromTex, pos).r; 56 | } 57 | } 58 | } 59 | return sum; 60 | 61 | } 62 | 63 | void main() { 64 | int index = int(gl_GlobalInvocationID); 65 | if (index >= N) {return;} 66 | Slime slime = SlimeBuffer.slimes[index]; 67 | 68 | float weightForward = sense(slime, 0); 69 | float weightLeft = sense(slime, senorAngleSpacing); 70 | float weightRight = sense(slime, -senorAngleSpacing); 71 | 72 | float randomSteerStrength = rand(vec2(slime.x, slime.y)*dt*slime.angle); 73 | 74 | 75 | if (weightForward > weightLeft && weightForward > weightRight) {} 76 | else if (weightForward < weightLeft && weightForward < weightRight) { 77 | slime.angle += (randomSteerStrength - 0.5) * 2 * turnSpeed * dt; 78 | } 79 | else if (weightRight > weightLeft) { 80 | slime.angle -= randomSteerStrength * turnSpeed * dt; 81 | } 82 | else if (weightLeft > weightRight) { 83 | slime.angle += randomSteerStrength * turnSpeed * dt; 84 | } 85 | 86 | 87 | vec2 direction = vec2(cos(slime.angle), sin(slime.angle)); 88 | vec2 newPos = vec2(slime.x, slime.y) + (direction * moveSpeed * dt); 89 | 90 | if (newPos.x < 0. || newPos.x >= width || newPos.y < 0. || newPos.y >= height) { 91 | newPos.x = min(width-0.01, max(0., newPos.x)); 92 | newPos.y = min(height-0.01, max(0., newPos.y)); 93 | slime.angle = rand(newPos) * 2 * PI; 94 | } 95 | slime.x = newPos.x; 96 | slime.y = newPos.y; 97 | drawSlime(slime); 98 | SlimeBuffer.slimes[index] = Slime(slime.x, slime.y, slime.angle, 0.); 99 | } --------------------------------------------------------------------------------