diff --git a/CHANGELOG.md b/CHANGELOG.md index 718ea84df..f97f28fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Arcade [PyPi Release History](https://pypi.org/project/arcade/#history) page. ### Misc Changes - Sped up `arcade.draw_text()`. It no longer flushes OpenGL after every call (an unchanged call went from 76 to 15 µs), and several lines drawn in the same style each keep their own cached label instead of re-laying out one label every call (5 static lines went from 1.24 ms to 0.08 ms per frame). - Sped up `arcade.Text` updates. Setting a property to the value it already has no longer lays out the text again (for example `font_size`, `bold`, or `width` went from about 165 µs to under 1 µs), and a `with text:` block only lays out the text if something in it needs a new layout. An unchanged `arcade.TextPool` frame with 5 lines went from 760 µs to 6 µs. +- Added a "Drawing Text" page to the programming guide: choosing between `draw_text`, `Text`, `TextPool`, and `create_text_sprite`, what's slow and fast to change, batches, fonts (names, font files, and the bundled Kenney and Liberation fonts), and styles. Updated the text section of the performance tips, and the `draw_text` warning and docstring, which overstated how slow it is. ## 4.0.0.dev8 diff --git a/arcade/text.py b/arcade/text.py index a86a4d7de..c4c5daa88 100644 --- a/arcade/text.py +++ b/arcade/text.py @@ -1211,8 +1211,8 @@ def _get_draw_text_label(label_cache, style: tuple, text: str) -> "Text | None": @warning( message=( - "draw_text is an extremely slow function for displaying text. " - "Consider using Text objects instead." + "draw_text is much slower than drawing arcade.Text objects, especially " + "for many lines or changing text. Consider using Text objects instead." ), warning_type=PerformanceWarning, ) @@ -1238,10 +1238,11 @@ def draw_text( .. warning:: Use :py:class:`arcade.Text` objects instead. - This method of drawing text is very slow - and might be removed in the near future. - Text objects can be 10-100 times faster - depending on the use case. + This method of drawing text is slower than ``Text`` objects + and might be removed in the near future. Each call has some + overhead, text that changes is laid out again on every call, + and many ``Text`` objects can be drawn at once in a batch. + See :ref:`text_guide`. .. warning:: Cameras affect text drawing! diff --git a/doc/index.rst b/doc/index.rst index b737a3b95..4f25b509d 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -124,6 +124,7 @@ help improve Arcade. programming_guide/input/index programming_guide/sound programming_guide/textures + programming_guide/text programming_guide/event_loop programming_guide/camera programming_guide/sections diff --git a/doc/programming_guide/performance_tips.rst b/doc/programming_guide/performance_tips.rst index 7caaa1175..e7ec4f0b4 100644 --- a/doc/programming_guide/performance_tips.rst +++ b/doc/programming_guide/performance_tips.rst @@ -303,15 +303,27 @@ They also lack many of the features you may be used to in Arcade. Text drawing performance ------------------------ -The slowest thing aside from disk access is :meth:`arcade.draw_text`. +Laying out text is slow, so the fastest text is text that isn't laid out +again every frame. To improve performance: -#. Use :py:class:`arcade.Text` instead -#. (Optional) Pass a pyglet :py:class:`~pyglet.graphics.Batch` object at creation +#. Use :py:class:`arcade.Text` objects instead of :py:func:`arcade.draw_text`. + Create them once, and only change their text when it changes. +#. Change position, color, and rotation freely. They don't lay out the + text again, but changing the text, font, size, or style does. +#. To change several layout properties at once, do it in a + ``with text:`` block, so the text is laid out once. +#. Add many ``Text`` objects to a pyglet :py:class:`~pyglet.graphics.Batch` + and draw them with one call. +#. If you prefer ``draw_text`` style code, use :py:class:`arcade.TextPool`. +#. For text that doesn't change but moves, rotates, or scales, use + :py:func:`arcade.create_text_sprite` and draw it in a + :py:class:`~arcade.SpriteList`. See the following to learn more: +* :ref:`text_guide` * :ref:`drawing_text_objects` * :ref:`drawing_text_objects_batch` diff --git a/doc/programming_guide/text.rst b/doc/programming_guide/text.rst new file mode 100644 index 000000000..d3b905227 --- /dev/null +++ b/doc/programming_guide/text.rst @@ -0,0 +1,284 @@ +.. _text_guide: + +Drawing Text +============ + +Arcade has four ways to draw text: + +.. list-table:: + :header-rows: 1 + :widths: 25 40 35 + + * - Way + - Best for + - Cost + * - :py:func:`arcade.draw_text` + - Quick prototypes, and a few lines + - Slowest per line drawn + * - :py:class:`arcade.Text` + - Most games: scores, menus, labels + - Fast to draw, slow to create or change + * - :py:class:`arcade.TextPool` + - Many lines that change, without managing ``Text`` objects + - Like ``Text`` + * - :py:func:`arcade.create_text_sprite` + - Text that moves, rotates, or scales like a sprite + - Slow to create, very fast to draw + +All of them use the same arguments for the font, size, color and layout. + +.. note:: + + Text is drawn with the current camera, just like sprites. If you + scroll the game world with a :py:class:`~arcade.Camera2D`, use a second + camera for text that should stay in place, such as a score. See + :ref:`sprite_move_scrolling`. + +The numbers on this page were measured on one desktop computer. +Your numbers will differ, but the comparisons between them should hold. + +draw_text +--------- + +:py:func:`arcade.draw_text` draws text in one call, which is handy while +you're getting something working: + +.. code-block:: python + + def on_draw(self): + self.clear() + arcade.draw_text("Hello, World!", 100, 300, arcade.color.WHITE, 24) + +Laying out text is slow, so ``draw_text`` keeps the text it laid out and +reuses it when you draw the same text again. Even so, each call has +overhead, and text that changes every frame, such as a timer, has to be +laid out again every frame. Drawing many lines with ``draw_text`` gets +slow quickly. For anything more than a few lines, use +:py:class:`arcade.Text`. ``draw_text`` reminds you of this with a +:py:class:`~arcade.exceptions.PerformanceWarning`. + +Text objects +------------ + +An :py:class:`arcade.Text` object lays out its text once, when it's +created, and then draws it as often as you like. Create it once, for +example in ``__init__`` or a ``setup`` method, and draw it in ``on_draw``: + +.. code-block:: python + + class MyGame(arcade.Window): + def __init__(self): + super().__init__() + self.score = 0 + self.score_text = arcade.Text("Score: 0", 10, 10, arcade.color.WHITE, 16) + + def on_update(self, delta_time): + self.score_text.text = f"Score: {self.score}" + + def on_draw(self): + self.clear() + self.score_text.draw() + +What costs what: + +* **Creating** a ``Text`` takes around half a millisecond. Don't create + them every frame. +* **Drawing** one takes a few tens of microseconds. +* **Moving**, **recoloring**, **rotating**, or hiding it is almost free. + Use :py:attr:`~arcade.Text.position` to change ``x`` and ``y`` together. +* **Changing the text**, font, size, bold, italic, width, or alignment + lays the text out again, which takes about as long as drawing it + several times. Setting a property to the value it already has costs nothing, + so code like the example above only pays when the score changes. + +To change several of these at once, use a ``with`` block. The text is +laid out once, at the end of the block, instead of once for each change: + +.. code-block:: python + + with self.title: + self.title.text = "Game Over" + self.title.font_size = 48 + self.title.color = arcade.color.RED + +See :ref:`drawing_text_objects` for an example. + +Batches +~~~~~~~ + +Drawing many ``Text`` objects one at a time adds up. If you add them to a +pyglet :py:class:`~pyglet.graphics.Batch`, one call draws all of them: + +.. code-block:: python + + import pyglet + + self.batch = pyglet.graphics.Batch() + self.lines = [ + arcade.Text(f"Line {i}", 10, 20 * i, batch=self.batch) + for i in range(20) + ] + + def on_draw(self): + self.clear() + self.batch.draw() + +In one measurement, drawing 20 ``Text`` objects one at a time took about +570 µs, and drawing them in one batch took about 30 µs. + +You can still change texts in a batch. Set ``text.batch = None`` to take +one out. See :ref:`drawing_text_objects_batch` for an example. + +TextPool +-------- + +If you'd rather write ``draw_text`` style code but want the speed of +``Text`` objects, use a :py:class:`arcade.TextPool`. You give each line +a name, and the pool keeps one ``Text`` object per name, updating it +only when something changes: + +.. code-block:: python + + self.pool = arcade.TextPool() + + def on_draw(self): + self.clear() + self.pool.draw("score", f"Score: {self.score}", 10, 580, font_size=16) + self.pool.draw("lives", f"Lives: {self.lives}", 10, 560, font_size=16) + +Text sprites +------------ + +:py:func:`arcade.create_text_sprite` draws text into a texture once and +returns an :py:class:`arcade.Sprite` that shows it. Creating one takes a +few milliseconds, so it's for text that doesn't change, but after that it +draws as fast as any other sprite, and can be added to a +:py:class:`~arcade.SpriteList`, moved, rotated, and scaled: + +.. code-block:: python + + self.sprites = arcade.SpriteList() + label = arcade.create_text_sprite("Bonus!", color=arcade.color.YELLOW, font_size=20) + label.position = 400, 300 + self.sprites.append(label) + +To change the text, create a new sprite. Scaling a text sprite up makes +it blurry, so create it at the size it will be drawn. + +Fonts +----- + +``font_name`` can be: + +* **The name of a font** installed on the computer, such as ``"arial"``. +* **A tuple or list of names.** The first one that's found is used, + which helps when different computers have different fonts. The default + is ``("calibri", "arial")``. +* **The path to a font file**, such as ``"fonts/MyFont.ttf"``, or a + :ref:`resource handle `, such as + ``":resources:fonts/ttf/MyFont.ttf"``. The file is loaded the first + time it's used. + +If no font is found, the platform's default font is used instead, without +an error. If text shows up in the wrong font, check the name. + +Fonts that come with your game +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To use a font file by its name, load it first with +:py:func:`arcade.load_font`, and then use the font's name, not the +file name: + +.. code-block:: python + + arcade.load_font("fonts/MyFont.ttf") + text = arcade.Text("Hello", 10, 10, font_name="My Font") + +The name is set inside the font file. Your operating system's font +viewer shows it. + +Fonts that come with Arcade +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Arcade includes the Kenney and Liberation fonts. They aren't loaded +automatically, so load them before using them: + +.. code-block:: python + + arcade.resources.load_kenney_fonts() + arcade.resources.load_liberation_fonts() + + text = arcade.Text("Hello", 10, 10, font_name="Kenney Future") + +The Kenney fonts are ``"Kenney Blocks"``, ``"Kenney Future"``, +``"Kenney Future Narrow"``, ``"Kenney High"``, ``"Kenney High Square"``, +``"Kenney Mini"``, ``"Kenney Mini Square"``, ``"Kenney Pixel"``, +``"Kenney Pixel Square"``, ``"Kenney Rocket"`` and +``"Kenney Rocket Square"``. The Liberation fonts are +``"Liberation Sans"``, ``"Liberation Serif"`` and ``"Liberation Mono"``, +which can stand in for Arial, Times New Roman and Courier New. + +.. note:: + + Windows treats ``"Kenney Future Narrow"`` as a narrower width of + ``"Kenney Future"``, so it can't be found by its own name there. On + Windows, use ``font_name="Kenney Future"`` with + ``stretch="semicondensed"`` instead. + +See :ref:`drawing_text` for an example with these fonts. + +Style +----- + +``bold`` + ``True`` or ``False``, or a font weight such as ``"thin"``, + ``"light"``, ``"medium"``, ``"semibold"``, ``"extrabold"`` or + ``"black"``. Not every font has every weight. If a font doesn't have + the one you ask for, a nearby weight is used instead. + +``italic`` + ``True`` or ``False``, or ``"oblique"``. + +``color`` + Any Arcade color, such as ``arcade.color.WHITE`` or + ``(255, 128, 0, 200)``. The alpha value makes the text partly + transparent. For a gradient from the left edge of the text to the + right, use a :py:class:`pyglet.text.LinearGradient`, which takes two + RGBA colors: + + .. code-block:: python + + from pyglet.text import LinearGradient + + title = arcade.Text( + "Arcade", 100, 300, font_size=48, + color=LinearGradient((255, 0, 0, 255), (0, 0, 255, 255)), + ) + +Layout +------ + +By default, ``x`` and ``y`` are the left end of the text's baseline, the +line most letters sit on. ``anchor_x`` (``"left"``, ``"center"``, +``"right"``) and ``anchor_y`` (``"baseline"``, ``"bottom"``, +``"center"``, ``"top"``) change which point of the text ``x`` and ``y`` +refer to. For example, to center a title on the screen: + +.. code-block:: python + + title = arcade.Text( + "My Game", window.width / 2, window.height / 2, font_size=48, + anchor_x="center", anchor_y="center", + ) + +For text that wraps across several lines, set ``multiline=True`` and a +``width`` in pixels. ``align`` (``"left"``, ``"center"``, ``"right"``) +then aligns each line within that width. + +To find out how big the text is, use +:py:attr:`~arcade.Text.content_width` and +:py:attr:`~arcade.Text.content_height`, or +:py:attr:`~arcade.Text.left`, :py:attr:`~arcade.Text.right`, +:py:attr:`~arcade.Text.top` and :py:attr:`~arcade.Text.bottom`. + +For more options, see the :py:class:`arcade.Text` API documentation.