Rendering text

2D overlays, 3D billboards, text baked onto surfaces, and CJK fallback.

▶ Run in browser

Tags: 3d

Shows the three ways to put text on screen and how non-Latin scripts are handled:

  • Text2D nodes draw a screen-space overlay on top of the 3D scene. Set text and font_scale; position with the usual Node2D transform.

  • Text3D floats a depth-tested MSDF billboard at a 3D world position, so it is occluded by geometry in front of it instead of being pasted over the frame.

  • Text painted onto a cube face is a normal texture: engine.create_text_texture() rasterises a string to an image that a Material samples like any other texture.

  • Japanese (hiragana, katakana, kanji) renders with no extra setup, by two different routes. On the desktop, glyphs the primary font lacks are borrowed from a CJK face found on the machine and packed into the same atlas. A web export bakes only the glyphs the bundled font itself can draw, so those characters are rasterised on demand by the browser’s own text engine instead.

Controls: ESC: Quit

Usage: uv run python examples/features/3d/text.py

Source

  1#!/usr/bin/env python3
  2"""Rendering text: 2D overlays, 3D billboards, text baked onto surfaces, and CJK fallback.
  3
  4Shows the three ways to put text on screen and how non-Latin scripts are handled:
  5
  6  - Text2D nodes draw a screen-space overlay on top of the 3D scene. Set ``text``
  7    and ``font_scale``; position with the usual Node2D transform.
  8  - Text3D floats a depth-tested MSDF billboard at a 3D world position, so it is
  9    occluded by geometry in front of it instead of being pasted over the frame.
 10  - Text painted onto a cube face is a normal texture: ``engine.create_text_texture()``
 11    rasterises a string to an image that a Material samples like any other texture.
 12  - Japanese (hiragana, katakana, kanji) renders with no extra setup, by two
 13    different routes. On the desktop, glyphs the primary font lacks are borrowed
 14    from a CJK face found on the machine and packed into the same atlas. A web
 15    export bakes only the glyphs the bundled font itself can draw, so those
 16    characters are rasterised on demand by the browser's own text engine instead.
 17
 18Controls:
 19    ESC: Quit
 20
 21Usage:
 22    uv run python examples/features/3d/text.py
 23"""
 24
 25import math
 26
 27from simvx.core import (
 28    Camera3D,
 29    DirectionalLight3D,
 30    Input,
 31    InputMap,
 32    Key,
 33    Material,
 34    Mesh,
 35    MeshInstance3D,
 36    Node,
 37    Text2D,
 38    Text3D,
 39    Vec3,
 40)
 41from simvx.graphics import App
 42
 43
 44class RotatingCube(MeshInstance3D):
 45    """A cube that rotates around its Y axis."""
 46
 47    def __init__(self, speed: float = 45.0, **kwargs):
 48        super().__init__(**kwargs)
 49        self.speed = speed
 50
 51    def on_update(self, dt: float):
 52        self.rotate_y(math.radians(self.speed) * dt)
 53
 54
 55class TextDemoScene(Node):
 56    """Main demo scene with labeled cubes and text overlays."""
 57
 58    def on_ready(self):
 59        InputMap.add_action("escape", [Key.ESCAPE])
 60
 61        engine = self.app.engine
 62
 63        # Camera: positioned to see cubes at origin
 64        camera = self.add_child(
 65            Camera3D(
 66                name="Camera",
 67                position=Vec3(0, 3, 8),
 68            )
 69        )
 70        camera.look_at(Vec3(0, 0, 0))
 71        camera.fov = 50.0
 72
 73        light = self.add_child(DirectionalLight3D(name="Sun"))
 74        light.look_at(Vec3(-1, -2, -1))
 75
 76        # Create text textures for each cube label
 77        labels = [
 78            ("RED", (1.0, 0.3, 0.3, 1.0)),
 79            ("GREEN", (0.3, 1.0, 0.4, 1.0)),
 80            ("BLUE", (0.4, 0.5, 1.0, 1.0)),
 81        ]
 82        text_textures = []
 83        for label, colour in labels:
 84            tt = engine.create_text_texture(size=48, width=256, height=64)
 85            tt.colour = colour
 86            tt.text = label
 87            text_textures.append(tt)
 88
 89        # Red cube: left, rotating slowly
 90        red = self.add_child(
 91            RotatingCube(
 92                name="RedCube",
 93                position=Vec3(-2.5, 0, 0),
 94                speed=30.0,
 95            )
 96        )
 97        red.mesh = Mesh.cube(size=1.5)
 98        red.material = Material(colour=(0.9, 0.2, 0.2, 1.0))
 99        red.material.albedo_tex_index = text_textures[0].texture_index
100
101        # Green cube: center, rotating faster
102        green = self.add_child(
103            RotatingCube(
104                name="GreenCube",
105                position=Vec3(0, 0, 0),
106                speed=60.0,
107            )
108        )
109        green.mesh = Mesh.cube(size=1.5)
110        green.material = Material(colour=(0.2, 0.8, 0.3, 1.0))
111        green.material.albedo_tex_index = text_textures[1].texture_index
112
113        # Blue cube: right, counter-rotating
114        blue = self.add_child(
115            RotatingCube(
116                name="BlueCube",
117                position=Vec3(2.5, 0, 0),
118                speed=-45.0,
119            )
120        )
121        blue.mesh = Mesh.cube(size=1.5)
122        blue.material = Material(colour=(0.2, 0.4, 0.9, 1.0))
123        blue.material.albedo_tex_index = text_textures[2].texture_index
124
125        # --- 3D text billboard ---
126        # A world-space label above the centre cube. It faces the camera but is
127        # depth-tested against the scene, so a cube passing in front hides it.
128        self.add_child(
129            Text3D(
130                name="CubeLabel",
131                text="Text3D billboard",
132                position=Vec3(0, 1.6, 0),
133                font_scale=3.0,
134                colour=(1.0, 0.85, 0.4, 1.0),
135            )
136        )
137
138        # --- 2D text overlays ---
139        self.add_child(
140            Text2D(
141                name="Title",
142                text="Text Rendering",
143                position=(10.0, 10.0),
144                font_scale=2.0,
145                colour=(1.0, 1.0, 1.0, 1.0),
146            )
147        )
148
149        self.add_child(
150            Text2D(
151                name="Subtitle",
152                text="Baked text textures on the cubes, a Text3D billboard, and these Text2D overlays",
153                position=(10.0, 50.0),
154                font_scale=1.2,
155                colour=(0.71, 0.71, 0.71, 1.0),
156            )
157        )
158
159        self.add_child(
160            Text2D(
161                name="Hiragana",
162                text="ひらがな: あいうえお かきくけこ",
163                position=(10.0, 90.0),
164                font_scale=1.2,
165                colour=(1.0, 0.59, 0.78, 1.0),
166            )
167        )
168        self.add_child(
169            Text2D(
170                name="Katakana",
171                text="カタカナ: アイウエオ カキクケコ",
172                position=(10.0, 120.0),
173                font_scale=1.2,
174                colour=(0.59, 0.78, 1.0, 1.0),
175            )
176        )
177        self.add_child(
178            Text2D(
179                name="Kanji",
180                text="漢字: 東京都 日本語テスト",
181                position=(10.0, 150.0),
182                font_scale=1.2,
183                colour=(1.0, 0.9, 0.39, 1.0),
184            )
185        )
186
187        # Dynamic frame counter
188        self.frame_text = self.add_child(
189            Text2D(
190                name="FrameCounter",
191                text="Frame: 0",
192                position=(10.0, 190.0),
193                font_scale=1.0,
194                colour=(0.39, 1.0, 0.39, 1.0),
195            )
196        )
197
198        self.frame = 0
199
200    def on_update(self, dt: float):
201        self.frame += 1
202        self.frame_text.text = f"Frame: {self.frame}  dt: {dt*1000:.1f}ms"
203
204        if Input.is_action_just_pressed("escape"):
205            self.app.quit()
206
207
208def main():
209    app = App(
210        title="Text Rendering",
211        width=1280,
212        height=720,
213    )
214    app.run(TextDemoScene())
215
216
217if __name__ == "__main__":
218    main()