-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtext.py
More file actions
348 lines (289 loc) · 13.3 KB
/
text.py
File metadata and controls
348 lines (289 loc) · 13.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
"""
A module for rendering text in OpenGL using pre-rendered font atlases.
This implementation uses the 'freetype-py' library to rasterize characters
from a given font file into a single texture atlas. This atlas is then
used by a set of shaders (vertex, geometry, and fragment) to render
text efficiently.
The process is as follows:
1. FontAtlas class:
- Loads a font file.
- Renders glyphs for a range of characters (ASCII ' ' to '~').
- Packs these glyphs into a single large texture atlas in memory.
- Calculates and stores metadata for each glyph (size, bearing,
advance, and UV coordinates within the atlas).
- Generates a single OpenGL texture for the atlas.
2. _Text class (exported as Text):
- Manages multiple fonts by creating and storing FontAtlas objects.
- Provides a `render_dynamic_text` method to draw text strings.
- `_build_instances`: For a given string, this method generates a
list of vertex attributes for each character. Each character is
represented as a single point with attributes for position, UVs,
and size.
- `render_dynamic_text`: This method sends the generated instance
data to the GPU and draws it using GL_POINTS.
3. Shaders:
- Vertex Shader: A simple pass-through shader that sends point
data to the geometry shader.
- Geometry Shader: Receives points and generates a textured quad
for each character on the fly.
- Fragment Shader: Samples the font atlas texture to color the
quad, effectively drawing the character.
"""
from typing import Any, Dict, List
import freetype
import numpy as np
import OpenGL.GL as gl
from .log import logger
from .shader_lib import DefaultShader, ShaderLib
from .simple_vao import VertexData
from .vao_factory import VAOFactory, VAOType
from .vec3 import Vec3
class FontAtlas:
"""
Manages the creation of a font texture atlas for efficient text rendering.
This class uses FreeType to render glyphs for a specified font and packs them
into a single texture. It also stores metadata for each glyph.
"""
def __init__(self, font_path: str, font_size: int = 48, debug: bool = False):
"""
Initializes the FontAtlas.
Args:
font_path: The file path to the font (e.g., a .ttf file).
font_size: The font size in pixels to be used for rendering the atlas.
debug: If True, saves the generated atlas as a PNG for debugging.
"""
try:
self.face = freetype.Face(font_path)
self.face.set_pixel_sizes(0, font_size)
self.font_size: int = font_size
self.glyphs: Dict[str, Dict[str, Any]] = {}
self.texture: int = 0
self.atlas_w: int = 0
self.atlas_h: int = 0
self.atlas: np.ndarray | None = None
self.build_atlas(debug)
except freetype.FT_Exception as e:
logger.error(f"{font_path} could not be loaded {e}")
def __str__(self) -> str:
"""Returns a string representation of the FontAtlas."""
return f"TextureID: {self.texture}, FontSize: {self.font_size}"
def build_atlas(self, debug: bool = False) -> None:
"""
Renders characters and packs them into a texture atlas.
This method iterates through ASCII characters 32-126, renders each one
using FreeType, and arranges them in a single large numpy array which
will later be used to create an OpenGL texture.
Args:
debug: If True, saves the generated atlas as 'debug_atlas.png'.
"""
padding = 2 # Padding between glyphs in the atlas
atlas_w = 1024 # Fixed width for the atlas texture
x, y, row_h = 0, 0, 0
bitmaps_data = []
# Iterate through printable ASCII characters
for charcode in range(ord(" "), ord("~")):
self.face.load_char(chr(charcode), freetype.FT_LOAD_RENDER)
bmp = self.face.glyph.bitmap
w, h = bmp.width, bmp.rows
# Move to the next row if the current glyph doesn't fit
if x + w + padding > atlas_w:
x = 0
y += row_h + padding
row_h = 0
# Copy bitmap data as the buffer is overwritten for each glyph
if w > 0 and h > 0:
buffer_copy = np.array(bmp.buffer, dtype=np.ubyte).reshape(h, w)
bitmaps_data.append((buffer_copy, x, y))
# Store glyph metadata
self.glyphs[chr(charcode)] = {
"size": (w, h),
"bearing": (self.face.glyph.bitmap_left, self.face.glyph.bitmap_top),
"advance": self.face.glyph.advance.x >> 6, # Advance is in 1/64 pixels
"uv": (x, y, x + w, y + h), # UVs in pixel coordinates
}
x += w + padding
row_h = max(row_h, h)
atlas_h = y + row_h + padding
self.atlas_w, self.atlas_h = atlas_w, atlas_h
atlas = np.zeros((atlas_h, atlas_w), dtype=np.ubyte)
# Blit all the individual glyph bitmaps onto the atlas
for arr, dest_x, dest_y in bitmaps_data:
h, w = arr.shape
atlas[dest_y : dest_y + h, dest_x : dest_x + w] = arr
self.atlas = atlas
if debug:
from PIL import Image
img = Image.fromarray(self.atlas, mode="L")
img.save("debug_atlas.png")
print(f"Saved debug_atlas.png, size: {self.atlas.shape}")
def generate_texture(self) -> None:
"""Generates and configures the OpenGL texture for the font atlas."""
if self.atlas is None:
return
tex = gl.glGenTextures(1)
gl.glPixelStorei(gl.GL_UNPACK_ALIGNMENT, 1)
gl.glBindTexture(gl.GL_TEXTURE_2D, tex)
# Create a single-channel RED texture from our numpy atlas
gl.glTexImage2D(
gl.GL_TEXTURE_2D,
0,
gl.GL_RED,
self.atlas_w,
self.atlas_h,
0,
gl.GL_RED,
gl.GL_UNSIGNED_BYTE,
self.atlas,
)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_CLAMP_TO_EDGE)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_CLAMP_TO_EDGE)
# Use texture swizzling to use the RED channel as ALPHA.
# This allows us to color the font using a uniform in the shader,
# while using the glyph's intensity for transparency.
# We set the texture's RGB channels to 1.0, and the A channel to the
# value from the RED channel of the source.
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_SWIZZLE_R, gl.GL_ONE)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_SWIZZLE_G, gl.GL_ONE)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_SWIZZLE_B, gl.GL_ONE)
gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_SWIZZLE_A, gl.GL_RED)
self.texture = tex
class _Text:
"""
Main class for managing and rendering text.
This class acts as a controller, loading fonts and providing methods
to render text strings to the screen. It is designed to be used as a
singleton instance.
"""
def __init__(self) -> None:
"""Initializes the Text renderer."""
self._fonts: Dict[str, FontAtlas] = {}
self._static_text: List[Any] = [] # Reserved for future use
def add_font(self, name: str, font_file: str, size: int) -> None:
"""
Loads a font and makes it available for rendering.
Args:
name: A unique name to identify this font (e.g., "main_font").
font_file: The path to the font file.
size: The font size in pixels.
"""
if not hasattr(self, "vao"):
self.vao = VAOFactory.create_vao(VAOType.SIMPLE, gl.GL_POINTS)
font = FontAtlas(font_file, size)
font.generate_texture()
print(f"Font '{name}' added with texture ID: {font.texture}")
self._fonts[name] = font
def set_screen_size(self, w: int, h: int) -> None:
"""
Sets the screen dimensions for the text shader.
This should be called whenever the window is resized.
Args:
w: The width of the screen in pixels.
h: The height of the screen in pixels.
"""
ShaderLib.use(DefaultShader.TEXT)
ShaderLib.set_uniform("textureID", 0)
ShaderLib.set_uniform("screenSize", float(w), float(h))
ShaderLib.set_uniform("fontSize", 1.0)
ShaderLib.set_uniform("textColour", 1.0, 1.0, 1.0, 1.0)
def render_text(
self, font: str, x: int, y: int, text: str, colour: Vec3 = Vec3(1.0, 1.0, 1.0)
) -> None:
"""
Renders a string of text to the screen.
Args:
font: The name of the font to use (previously added with add_font).
x: The x-coordinate of the starting position (baseline).
y: The y-coordinate of the starting position (baseline).
text: The string of text to render.
colour: The color of the text as a Vec4.
"""
render_data = self._build_instances(font, text, x, y)
if not render_data:
return
buffer_data = np.array(render_data, dtype=np.float32)
atlas = self._fonts[font]
# Enable blending for transparency
gl.glEnable(gl.GL_BLEND)
gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA)
# Ensure text is rendered filled and restore state afterwards.
polygon_mode = gl.glGetIntegerv(gl.GL_POLYGON_MODE)[0]
if polygon_mode != gl.GL_FILL:
gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL)
# Disable depth testing to ensure text is always drawn on top
depth_test_enabled = gl.glIsEnabled(gl.GL_DEPTH_TEST)
if depth_test_enabled:
gl.glDisable(gl.GL_DEPTH_TEST)
with self.vao as vao:
data = VertexData(data=buffer_data, size=buffer_data.nbytes)
stride = 32 # 8 floats * 4 bytes
vao.set_data(data)
# Vertex Attributes:
# 0: vec2 a_position (screen position of the glyph)
# 1: vec4 a_uvRect (u0, v0, u1, v1)
# 2: vec2 a_size (width, height of the glyph quad)
vao.set_vertex_attribute_pointer(0, 2, gl.GL_FLOAT, stride, 0)
vao.set_vertex_attribute_pointer(1, 4, gl.GL_FLOAT, stride, 8)
vao.set_vertex_attribute_pointer(2, 2, gl.GL_FLOAT, stride, 24)
gl.glActiveTexture(gl.GL_TEXTURE0)
gl.glBindTexture(gl.GL_TEXTURE_2D, atlas.texture)
ShaderLib.use(DefaultShader.TEXT)
ShaderLib.set_uniform(
"textColour", float(colour.x), float(colour.y), float(colour.z), 1.0
)
# We are drawing one point per character
vao.set_num_indices(len(render_data) // 8)
vao.draw()
# Restore OpenGL state
gl.glDisable(gl.GL_BLEND)
if depth_test_enabled:
gl.glEnable(gl.GL_DEPTH_TEST)
if polygon_mode != gl.GL_FILL:
gl.glPolygonMode(gl.GL_FRONT_AND_BACK, polygon_mode)
def _build_instances(
self, font: str, text: str, start_x: int, start_y: int
) -> List[float]:
"""
Generates vertex attribute data for each character in a string.
This data is sent to the GPU as a single buffer. The geometry shader
then uses this data to construct a quad for each character.
Args:
font: The name of the font to use.
text: The string to process.
start_x: The initial x-coordinate for the text baseline.
start_y: The initial y-coordinate for the text baseline.
Returns:
A list of floats representing the packed vertex data for all characters.
"""
inst = []
atlas = self._fonts.get(font)
if atlas:
x, y = float(start_x), float(start_y) # Use floats for positioning
for ch in text:
if ch not in atlas.glyphs:
continue
g = atlas.glyphs[ch]
w, h = g["size"]
adv = g["advance"]
bearing_x, bearing_y = g["bearing"]
# UV coordinates from atlas (in pixels)
u0_px, v0_px, u1_px, v1_px = g["uv"]
# Normalize UVs to the range [0, 1]
u0 = u0_px / atlas.atlas_w
v0 = v0_px / atlas.atlas_h
u1 = u1_px / atlas.atlas_w
v1 = v1_px / atlas.atlas_h
# Calculate the screen position for the top-left corner of the quad.
# FreeType's origin is at the baseline, with +y going up.
# Screen coordinates usually have +y going down, so we adjust.
pos_x = x + bearing_x
pos_y = y - bearing_y
# Each character is defined by 8 floats:
# pos_x, pos_y, u0, v0, u1, v1, w, h
inst.extend([pos_x, pos_y, u0, v0, u1, v1, float(w), float(h)])
# Advance the cursor for the next character
x += adv
return inst
# Create a singleton instance of the Text class for global use.
Text = _Text()