Depth of Field Blur

Screenshot of a time-dependent depth of field blur effect.
depth_of_field.py
  1"""An experimental depth-of-field example.
  2
  3It uses the depth attribute of along with blurring and shaders to
  4roughly approximate depth-based blur effects. The focus bounces
  5back forth automatically between a maximum and minimum depth value
  6based on time. Change the speed and focus via either the constants
  7at the top of the file or the arguments passed to it at the bottom of
  8the file.
  9
 10This example works by doing the following for each frame:
 11
 121. Render a depth value for pixel into a buffer
 132. Render a gaussian blurred version of the scene
 143. For each pixel, use the current depth value to lerp between the
 15   blurred and un-blurred versions of the scene.
 16
 17This is more expensive than rendering the scene directly, but it's
 18both easier and more performant than more accurate blur approaches.
 19
 20If Python and Arcade are installed, this example can be run from the command line with:
 21python -m arcade.examples.depth_of_field
 22"""
 23
 24from __future__ import annotations
 25
 26from contextlib import contextmanager
 27from math import cos, pi
 28from random import randint, uniform
 29from textwrap import dedent
 30from typing import cast
 31
 32from pyglet.graphics import Batch
 33
 34import arcade
 35from arcade import get_window, SpriteList, SpriteSolidColor, Text, Window, View
 36from arcade.camera.data_types import DEFAULT_NEAR_ORTHO, DEFAULT_FAR
 37from arcade.color import RED
 38from arcade.experimental.postprocessing import GaussianBlur
 39from arcade.gl import NEAREST, Program, Texture2D, geometry
 40from arcade.types import RGBA255, Color
 41
 42WINDOW_TITLE = "Depth of Field Example"
 43
 44WINDOW_WIDTH = 1280
 45WINDOW_HEIGHT = 720
 46BACKGROUND_GRAY = Color(155, 155, 155, 255)
 47
 48
 49class DepthOfField:
 50    """A depth-of-field effect we can use as a render context manager.
 51
 52    Args:
 53        size:
 54            The size of the buffers.
 55        clear_color:
 56            The color which will be used as the background.
 57    """
 58
 59    def __init__(
 60        self,
 61        size: tuple[int, int] | None = None,
 62        clear_color: RGBA255 = BACKGROUND_GRAY
 63    ):
 64        self._geo = geometry.quad_2d_fs()
 65        self._win: Window = get_window()
 66
 67        size = cast(tuple[int, int], size or self._win.size)
 68        self._clear_color: Color = Color.from_iterable(clear_color)
 69
 70        self.stale = True
 71
 72        # Set up our depth buffer to hold per-pixel depth
 73        self._render_target = self._win.ctx.framebuffer(
 74            color_attachments=[
 75                self._win.ctx.texture(
 76                    size,
 77                    components=4,
 78                    filter=(NEAREST, NEAREST),
 79                    wrap_x=self._win.ctx.REPEAT,
 80                    wrap_y=self._win.ctx.REPEAT,
 81                ),
 82            ],
 83            depth_attachment=self._win.ctx.depth_texture(size),
 84        )
 85
 86        # Set up everything we need to perform blur and store results.
 87        # This includes the blur effect, a framebuffer, and an instance
 88        # variable to store the returned texture holding blur results.
 89        self._blur_process = GaussianBlur(size, kernel_size=10, sigma=2.0, multiplier=2.0, step=4)
 90        self._blur_target = self._win.ctx.framebuffer(
 91            color_attachments=[
 92                self._win.ctx.texture(
 93                    size,
 94                    components=4,
 95                    filter=(NEAREST, NEAREST),
 96                    wrap_x=self._win.ctx.REPEAT,
 97                    wrap_y=self._win.ctx.REPEAT,
 98                )
 99            ]
100        )
101        self._blurred: Texture2D | None = None
102
103        # To keep this example in one file, we use strings for our
104        # our shaders. You may want to use pathlib.Path.read_text in
105        # your own code instead.
106        self._render_program = self._win.ctx.program(
107            vertex_shader=dedent(
108                """#version 330
109
110                in vec2 in_vert;
111                in vec2 in_uv;
112
113                out vec2 out_uv;
114
115                void main(){
116                   gl_Position = vec4(in_vert, 0.0, 1.0);
117                   out_uv = in_uv;
118                }"""
119            ),
120            fragment_shader=dedent(
121                """#version 330
122
123                uniform sampler2D texture_0;
124                uniform sampler2D texture_1;
125                uniform sampler2D depth_0;
126
127                uniform float focus_depth;
128
129                in vec2 out_uv;
130
131                out vec4 frag_colour;
132
133                void main() {
134                   float depth_val = texture(depth_0, out_uv).x;
135                   float depth_adjusted = min(1.0, 2.0 * abs(depth_val - focus_depth));
136                   vec4 crisp_tex = texture(texture_0, out_uv);
137                   vec3 blur_tex = texture(texture_1, out_uv).rgb;
138                   frag_colour = mix(crisp_tex, vec4(blur_tex, crisp_tex.a), depth_adjusted);
139                   //if (depth_adjusted < 0.1){frag_colour = vec4(1.0, 0.0, 0.0, 1.0);}
140                }"""
141            ),
142        )
143
144        # Set the buffers the shader program will use
145        self._render_program["texture_0"] = 0
146        self._render_program["texture_1"] = 1
147        self._render_program["depth_0"] = 2
148
149    @property
150    def render_program(self) -> Program:
151        """The compiled shader for this effect."""
152        return self._render_program
153
154    @contextmanager
155    def draw_into(self):
156        self.stale = True
157        previous_fbo = self._win.ctx.active_framebuffer
158        try:
159            self._win.ctx.enable(self._win.ctx.DEPTH_TEST)
160            self._render_target.clear(color=self._clear_color)
161            self._render_target.use()
162            yield self._render_target
163        finally:
164            self._win.ctx.disable(self._win.ctx.DEPTH_TEST)
165            previous_fbo.use()
166
167    def process(self):
168        self._blurred = self._blur_process.render(self._render_target.color_attachments[0])
169        self._win.use()
170
171        self.stale = False
172
173    def render(self):
174        if self.stale:
175            self.process()
176
177        self._render_target.color_attachments[0].use(0)
178        self._blurred.use(1)
179        self._render_target.depth_attachment.use(2)
180        self._geo.render(self._render_program)
181
182
183class GameView(View):
184    """Window subclass to hold sprites and rendering helpers.
185
186    To keep the code simpler, this example uses a default camera. That means
187    that any sprite outside Arcade's default camera near and far render cutoffs
188    (``-100.0`` to ``100.0``) will not be drawn.
189
190    Args:
191        text_color:
192            The color of the focus indicator.
193        focus_range:
194            The range the focus value will oscillate between.
195        focus_change_speed:
196            How fast the focus bounces back and forth
197            between the ``-focus_range`` and ``focus_range``.
198        min_sprite_depth:
199            The minimum sprite depth we'll generate sprites between
200         max_sprite_depth:
201            The maximum sprite depth we'll generate sprites between.
202    """
203
204    def __init__(
205        self,
206        text_color: RGBA255 = RED,
207        focus_range: float = 16.0,
208        focus_change_speed: float = 0.1,
209        min_sprite_depth: float = DEFAULT_NEAR_ORTHO,
210        max_sprite_depth: float = DEFAULT_FAR
211    ):
212        super().__init__()
213        self.sprites: SpriteList = SpriteList()
214        self._batch = Batch()
215        self.focus_range: float = focus_range
216        self.focus_change_speed: float = focus_change_speed
217        self.indicator_label = Text(
218            f"Focus depth: {0:.3f} / {focus_range}",
219            self.width / 2,
220            self.height / 2,
221            text_color,
222            align="center",
223            anchor_x="center",
224            batch=self._batch,
225        )
226
227        # Randomize sprite depth, size, and angle, but set color from depth.
228        for _ in range(100):
229            depth = uniform(min_sprite_depth, max_sprite_depth)
230            color = Color.from_gray(int(255 * (depth + 100) / 200))
231            s = SpriteSolidColor(
232                randint(100, 200),
233                randint(100, 200),
234                uniform(20, self.width - 20),
235                uniform(20, self.height - 20),
236                color,
237                uniform(0, 360),
238            )
239            s.depth = depth
240            self.sprites.append(s)
241
242        self.dof = DepthOfField()
243
244    def on_update(self, delta_time: float):
245        time = self.window.time
246        raw_focus = self.focus_range * (cos(pi * self.focus_change_speed * time) * 0.5 + 0.5)
247        self.dof.render_program["focus_depth"] = raw_focus / self.focus_range
248        self.indicator_label.value = f"Focus depth: {raw_focus:.3f} / {self.focus_range}"
249
250    def on_draw(self):
251        self.clear()
252
253        # Render the depth-of-field layer's frame buffer
254        with self.dof.draw_into():
255            self.sprites.draw(pixelated=True)
256
257        # Draw the blurred frame buffer and then the focus display
258        window = self.window
259        window.use()
260        self.dof.render()
261        self._batch.draw()
262
263
264def main():
265    # Create a Window to show the view defined above.
266    window = arcade.Window(WINDOW_WIDTH, WINDOW_HEIGHT, WINDOW_TITLE)
267
268    # Create the view
269    app = GameView()
270
271    # Show GameView on screen
272    window.show_view(app)
273
274    # Start the arcade game loop
275    window.run()
276
277
278if __name__ == "__main__":
279    main()