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