eagle-engine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- eagle/__init__.py +6 -0
- eagle/assets/logo.png +0 -0
- eagle/assets/shaders/basic.frag +32 -0
- eagle/assets/shaders/basic.vert +22 -0
- eagle/assets/shaders/unlit.frag +7 -0
- eagle/assets/shaders/unlit.vert +8 -0
- eagle/core/__init__.py +3 -0
- eagle/core/engine.py +255 -0
- eagle/core/splash.py +88 -0
- eagle/gfx/__init__.py +10 -0
- eagle/gfx/camera.py +41 -0
- eagle/gfx/material.py +11 -0
- eagle/gfx/mesh.py +83 -0
- eagle/gfx/model.py +46 -0
- eagle/gfx/orbit_camera.py +105 -0
- eagle/gfx/shader.py +76 -0
- eagle/gfx/texture.py +49 -0
- eagle/loaders/__init__.py +5 -0
- eagle/loaders/mtl.py +51 -0
- eagle/loaders/obj.py +151 -0
- eagle/loaders/stl.py +55 -0
- eagle/math/__init__.py +5 -0
- eagle/math/mat.py +122 -0
- eagle/math/transform.py +37 -0
- eagle/math/vec.py +132 -0
- eagle/primitives/__init__.py +12 -0
- eagle/primitives/axes.py +17 -0
- eagle/primitives/cone.py +55 -0
- eagle/primitives/cube.py +29 -0
- eagle/primitives/cylinder.py +46 -0
- eagle/primitives/grid.py +22 -0
- eagle/primitives/icosphere.py +50 -0
- eagle/primitives/plane.py +19 -0
- eagle/primitives/sphere.py +29 -0
- eagle/primitives/torus.py +32 -0
- eagle/scene/__init__.py +4 -0
- eagle/scene/object.py +43 -0
- eagle/scene/scene.py +40 -0
- eagle_engine-0.1.0.dist-info/METADATA +88 -0
- eagle_engine-0.1.0.dist-info/RECORD +43 -0
- eagle_engine-0.1.0.dist-info/WHEEL +5 -0
- eagle_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
- eagle_engine-0.1.0.dist-info/top_level.txt +1 -0
eagle/__init__.py
ADDED
eagle/assets/logo.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#version 330 core
|
|
2
|
+
|
|
3
|
+
in vec3 v_world_pos;
|
|
4
|
+
in vec3 v_normal;
|
|
5
|
+
in vec2 v_uv;
|
|
6
|
+
|
|
7
|
+
uniform vec3 u_camera_pos;
|
|
8
|
+
uniform vec3 u_light_dir;
|
|
9
|
+
uniform vec3 u_light_color;
|
|
10
|
+
uniform vec3 u_ambient;
|
|
11
|
+
uniform vec3 u_color;
|
|
12
|
+
uniform float u_shininess;
|
|
13
|
+
uniform float u_specular;
|
|
14
|
+
uniform int u_use_texture;
|
|
15
|
+
uniform sampler2D u_texture;
|
|
16
|
+
|
|
17
|
+
out vec4 frag_color;
|
|
18
|
+
|
|
19
|
+
void main() {
|
|
20
|
+
vec3 albedo = u_color;
|
|
21
|
+
if (u_use_texture == 1) {
|
|
22
|
+
albedo *= texture(u_texture, v_uv).rgb;
|
|
23
|
+
}
|
|
24
|
+
vec3 N = normalize(v_normal);
|
|
25
|
+
vec3 L = normalize(-u_light_dir);
|
|
26
|
+
vec3 V = normalize(u_camera_pos - v_world_pos);
|
|
27
|
+
vec3 H = normalize(L + V);
|
|
28
|
+
float diff = max(dot(N, L), 0.0);
|
|
29
|
+
float spec = pow(max(dot(N, H), 0.0), u_shininess) * u_specular;
|
|
30
|
+
vec3 color = albedo * (u_ambient + u_light_color * diff) + u_light_color * spec;
|
|
31
|
+
frag_color = vec4(color, 1.0);
|
|
32
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#version 330 core
|
|
2
|
+
|
|
3
|
+
in vec3 in_position;
|
|
4
|
+
in vec3 in_normal;
|
|
5
|
+
in vec2 in_uv;
|
|
6
|
+
|
|
7
|
+
uniform mat4 u_model;
|
|
8
|
+
uniform mat4 u_view;
|
|
9
|
+
uniform mat4 u_proj;
|
|
10
|
+
uniform mat3 u_normal_mat;
|
|
11
|
+
|
|
12
|
+
out vec3 v_world_pos;
|
|
13
|
+
out vec3 v_normal;
|
|
14
|
+
out vec2 v_uv;
|
|
15
|
+
|
|
16
|
+
void main() {
|
|
17
|
+
vec4 world = u_model * vec4(in_position, 1.0);
|
|
18
|
+
v_world_pos = world.xyz;
|
|
19
|
+
v_normal = normalize(u_normal_mat * in_normal);
|
|
20
|
+
v_uv = in_uv;
|
|
21
|
+
gl_Position = u_proj * u_view * world;
|
|
22
|
+
}
|
eagle/core/__init__.py
ADDED
eagle/core/engine.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Engine — окно + GL-контекст + цикл + 2D-оверлей + splash."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pygame
|
|
7
|
+
import moderngl
|
|
8
|
+
|
|
9
|
+
from eagle.gfx.shader import shader_from_package, Shader
|
|
10
|
+
from eagle.gfx.texture import Texture
|
|
11
|
+
from eagle.gfx.camera import Camera
|
|
12
|
+
from eagle.gfx.mesh import Mesh
|
|
13
|
+
from eagle.math.mat import Mat4
|
|
14
|
+
from eagle.core.splash import load_logo, make_icon, Splash
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CallbackList:
|
|
18
|
+
"""Список подписчиков. Как декоратор — регистрирует, как dispatch — исполняет."""
|
|
19
|
+
__slots__ = ("_subs",)
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self._subs: list[Callable] = []
|
|
23
|
+
|
|
24
|
+
def __call__(self, fn: Callable) -> Callable:
|
|
25
|
+
self._subs.append(fn)
|
|
26
|
+
return fn
|
|
27
|
+
|
|
28
|
+
def dispatch(self, *args) -> None:
|
|
29
|
+
for fn in self._subs:
|
|
30
|
+
fn(*args)
|
|
31
|
+
|
|
32
|
+
def __len__(self):
|
|
33
|
+
return len(self._subs)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Engine:
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
width: int = 800,
|
|
40
|
+
height: int = 600,
|
|
41
|
+
title: str = "Eagle Engine",
|
|
42
|
+
*,
|
|
43
|
+
vsync: bool = True,
|
|
44
|
+
target_fps: int | None = 60,
|
|
45
|
+
logo_path: str | None = None,
|
|
46
|
+
logo: pygame.Surface | None = None,
|
|
47
|
+
splash: bool = True,
|
|
48
|
+
):
|
|
49
|
+
pygame.init()
|
|
50
|
+
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3)
|
|
51
|
+
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3)
|
|
52
|
+
pygame.display.gl_set_attribute(pygame.GL_CONTEXT_PROFILE_MASK, pygame.GL_CONTEXT_PROFILE_CORE)
|
|
53
|
+
pygame.display.gl_set_attribute(pygame.GL_DEPTH_SIZE, 24)
|
|
54
|
+
pygame.display.gl_set_attribute(pygame.GL_DOUBLEBUFFER, 1)
|
|
55
|
+
pygame.display.gl_set_attribute(pygame.GL_MULTISAMPLESAMPLES, 4)
|
|
56
|
+
|
|
57
|
+
self.width = width
|
|
58
|
+
self.height = height
|
|
59
|
+
self.screen = pygame.display.set_mode(
|
|
60
|
+
(width, height),
|
|
61
|
+
pygame.OPENGL | pygame.DOUBLEBUF | pygame.RESIZABLE,
|
|
62
|
+
vsync=1 if vsync else 0,
|
|
63
|
+
)
|
|
64
|
+
pygame.display.set_caption(title)
|
|
65
|
+
|
|
66
|
+
# --- логотип и иконка ---
|
|
67
|
+
if logo is not None:
|
|
68
|
+
_logo_surf = logo.convert_alpha()
|
|
69
|
+
elif logo_path is not None:
|
|
70
|
+
_logo_surf = load_logo(logo_path)
|
|
71
|
+
else:
|
|
72
|
+
_logo_surf = load_logo() # встроенный eagle/assets/logo.png
|
|
73
|
+
|
|
74
|
+
pygame.display.set_icon(make_icon(_logo_surf, size=64))
|
|
75
|
+
|
|
76
|
+
self._splash = Splash(_logo_surf, width, height) if splash else None
|
|
77
|
+
|
|
78
|
+
# --- OpenGL / ModernGL ---
|
|
79
|
+
self.ctx = moderngl.create_context(require=330)
|
|
80
|
+
self.ctx.enable(moderngl.DEPTH_TEST | moderngl.CULL_FACE | moderngl.BLEND)
|
|
81
|
+
self.ctx.blend_func = moderngl.SRC_ALPHA, moderngl.ONE_MINUS_SRC_ALPHA
|
|
82
|
+
self.ctx.gc_mode = "auto"
|
|
83
|
+
|
|
84
|
+
self.surface = pygame.Surface((width, height), pygame.SRCALPHA)
|
|
85
|
+
|
|
86
|
+
self._overlay_tex = Texture(self.surface, flip=False)
|
|
87
|
+
self._overlay_tex.build(self.ctx)
|
|
88
|
+
self._overlay_shader = shader_from_package("unlit.vert", "unlit.frag")
|
|
89
|
+
self._overlay_shader.build(self.ctx)
|
|
90
|
+
# CCW-порядок для TRIANGLE_STRIP, v=0 сверху, v=1 снизу.
|
|
91
|
+
quad = np.array([
|
|
92
|
+
-1, -1, 0, 1, # лево-низ
|
|
93
|
+
1, -1, 1, 1, # право-низ
|
|
94
|
+
-1, 1, 0, 0, # лево-верх
|
|
95
|
+
1, 1, 1, 0, # право-верх
|
|
96
|
+
], dtype="f4")
|
|
97
|
+
self._overlay_vao = self.ctx.vertex_array(
|
|
98
|
+
self._overlay_shader.program,
|
|
99
|
+
[(self.ctx.buffer(quad.tobytes()), "2f 2f", "in_position", "in_uv")],
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
self._basic_shader: Shader = shader_from_package("basic.vert", "basic.frag")
|
|
103
|
+
self._basic_shader.build(self.ctx)
|
|
104
|
+
|
|
105
|
+
self.running = True
|
|
106
|
+
self.clock = pygame.time.Clock()
|
|
107
|
+
self.target_fps = target_fps
|
|
108
|
+
self._elapsed = 0.0
|
|
109
|
+
self._last_dt = 0.0
|
|
110
|
+
|
|
111
|
+
# --- колбэки (декораторы) ---
|
|
112
|
+
self.on_update = CallbackList()
|
|
113
|
+
self.on_render_3d = CallbackList()
|
|
114
|
+
self.on_render_2d = CallbackList()
|
|
115
|
+
self.on_event = CallbackList()
|
|
116
|
+
|
|
117
|
+
# --- освещение ---
|
|
118
|
+
self.light_dir = (0.3, -0.7, -0.6)
|
|
119
|
+
self.light_color = (1.0, 1.0, 1.0)
|
|
120
|
+
self.ambient = (0.25, 0.25, 0.28)
|
|
121
|
+
|
|
122
|
+
# ============================================================
|
|
123
|
+
# ЦИКЛ
|
|
124
|
+
# ============================================================
|
|
125
|
+
def tick(self) -> float:
|
|
126
|
+
dt = self.clock.tick(self.target_fps if self.target_fps else 0) / 1000.0
|
|
127
|
+
self._last_dt = dt
|
|
128
|
+
self._elapsed += dt
|
|
129
|
+
|
|
130
|
+
skip_splash = False
|
|
131
|
+
for ev in pygame.event.get():
|
|
132
|
+
if ev.type == pygame.QUIT:
|
|
133
|
+
self.running = False
|
|
134
|
+
elif ev.type == pygame.VIDEORESIZE:
|
|
135
|
+
self.width, self.height = ev.w, ev.h
|
|
136
|
+
self.surface = pygame.Surface((self.width, self.height), pygame.SRCALPHA)
|
|
137
|
+
self.ctx.viewport = (0, 0, self.width, self.height)
|
|
138
|
+
self._overlay_tex.release()
|
|
139
|
+
self._overlay_tex = Texture(self.surface, flip=False)
|
|
140
|
+
self._overlay_tex.build(self.ctx)
|
|
141
|
+
elif ev.type in (pygame.KEYDOWN, pygame.MOUSEBUTTONDOWN):
|
|
142
|
+
skip_splash = True
|
|
143
|
+
self.on_event.dispatch(ev)
|
|
144
|
+
|
|
145
|
+
if self._splash is not None and not self._splash.done:
|
|
146
|
+
self._splash.update(dt)
|
|
147
|
+
if skip_splash:
|
|
148
|
+
self._splash._t = self._splash.duration + self._splash.fade
|
|
149
|
+
self._splash._done = True
|
|
150
|
+
return dt
|
|
151
|
+
|
|
152
|
+
self.on_update.dispatch(dt)
|
|
153
|
+
return dt
|
|
154
|
+
|
|
155
|
+
def clear(self, color=(0.10, 0.10, 0.14, 1.0)):
|
|
156
|
+
self.ctx.clear(*color, depth=1.0)
|
|
157
|
+
self.ctx.viewport = (0, 0, self.width, self.height)
|
|
158
|
+
|
|
159
|
+
# ============================================================
|
|
160
|
+
# РИСОВАНИЕ
|
|
161
|
+
# ============================================================
|
|
162
|
+
def draw(self, obj, camera: Camera, *, model: Mat4 | None = None,
|
|
163
|
+
color=(0.85, 0.85, 0.9), shininess=32.0, specular=0.4, texture=None):
|
|
164
|
+
"""obj — Mesh (голая геометрия) или Model (несколько частей с материалами)."""
|
|
165
|
+
from eagle.gfx.model import Model
|
|
166
|
+
|
|
167
|
+
if isinstance(obj, Model):
|
|
168
|
+
for part in obj.parts:
|
|
169
|
+
self._draw_mesh(part.mesh, camera, model=model,
|
|
170
|
+
color=part.color, shininess=part.shininess,
|
|
171
|
+
specular=part.specular, texture=part.texture)
|
|
172
|
+
else:
|
|
173
|
+
self._draw_mesh(obj, camera, model=model, color=color,
|
|
174
|
+
shininess=shininess, specular=specular, texture=texture)
|
|
175
|
+
|
|
176
|
+
def _draw_mesh(self, mesh, camera, *, model, color, shininess, specular, texture):
|
|
177
|
+
if model is None:
|
|
178
|
+
model = Mat4.identity()
|
|
179
|
+
camera.set_aspect(self.width / max(1, self.height))
|
|
180
|
+
|
|
181
|
+
sh = self._basic_shader
|
|
182
|
+
sh["u_model"] = model
|
|
183
|
+
sh["u_view"] = camera.view_matrix()
|
|
184
|
+
sh["u_proj"] = camera.projection_matrix()
|
|
185
|
+
nm = np.linalg.inv(model.array[:3, :3]).T.astype(np.float32)
|
|
186
|
+
sh.program["u_normal_mat"].write(nm.tobytes(order="C"))
|
|
187
|
+
sh["u_camera_pos"] = camera.position.xyz
|
|
188
|
+
sh["u_light_dir"] = self.light_dir
|
|
189
|
+
sh["u_light_color"] = self.light_color
|
|
190
|
+
sh["u_ambient"] = self.ambient
|
|
191
|
+
sh["u_color"] = color
|
|
192
|
+
sh["u_shininess"] = shininess
|
|
193
|
+
sh["u_specular"] = specular
|
|
194
|
+
|
|
195
|
+
if texture is not None:
|
|
196
|
+
if texture._tex is None:
|
|
197
|
+
texture.build(self.ctx)
|
|
198
|
+
texture.use(0)
|
|
199
|
+
sh["u_texture"] = 0
|
|
200
|
+
sh["u_use_texture"] = 1
|
|
201
|
+
else:
|
|
202
|
+
sh["u_use_texture"] = 0
|
|
203
|
+
|
|
204
|
+
sh.use()
|
|
205
|
+
if mesh._vao is None:
|
|
206
|
+
mesh.build(self.ctx, sh.program)
|
|
207
|
+
mesh.render()
|
|
208
|
+
|
|
209
|
+
def present(self):
|
|
210
|
+
# 2D-оверлей: без depth-test, без culling (это плоский квад лицом к камере)
|
|
211
|
+
self.ctx.disable(moderngl.DEPTH_TEST)
|
|
212
|
+
self.ctx.disable(moderngl.CULL_FACE)
|
|
213
|
+
self.ctx.enable(moderngl.BLEND)
|
|
214
|
+
self._overlay_tex.update_from_surface(self.surface)
|
|
215
|
+
self._overlay_shader.use()
|
|
216
|
+
self._overlay_tex.use(0)
|
|
217
|
+
self._overlay_shader["u_texture"] = 0
|
|
218
|
+
self._overlay_vao.render(moderngl.TRIANGLE_STRIP)
|
|
219
|
+
self.ctx.enable(moderngl.DEPTH_TEST)
|
|
220
|
+
self.ctx.enable(moderngl.CULL_FACE)
|
|
221
|
+
pygame.display.flip()
|
|
222
|
+
|
|
223
|
+
# ============================================================
|
|
224
|
+
# ГЛАВНЫЙ ЦИКЛ
|
|
225
|
+
# ============================================================
|
|
226
|
+
def run(self):
|
|
227
|
+
try:
|
|
228
|
+
while self.running:
|
|
229
|
+
self.tick()
|
|
230
|
+
self.clear()
|
|
231
|
+
|
|
232
|
+
if self._splash is not None and not self._splash.done:
|
|
233
|
+
self.surface.fill((0, 0, 0, 0))
|
|
234
|
+
self._splash.draw(self.surface)
|
|
235
|
+
self.present()
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
self.on_render_3d.dispatch()
|
|
239
|
+
self.surface.fill((0, 0, 0, 0))
|
|
240
|
+
self.on_render_2d.dispatch()
|
|
241
|
+
self.present()
|
|
242
|
+
finally:
|
|
243
|
+
self.shutdown()
|
|
244
|
+
|
|
245
|
+
def shutdown(self):
|
|
246
|
+
self.running = False
|
|
247
|
+
self._overlay_tex.release()
|
|
248
|
+
self._overlay_shader.release()
|
|
249
|
+
self._overlay_vao.release()
|
|
250
|
+
self._basic_shader.release()
|
|
251
|
+
pygame.quit()
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def time(self):
|
|
255
|
+
return self._elapsed
|
eagle/core/splash.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Splash-заставка с логотипом Eagle."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import pygame
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_logo(path: str | None = None) -> pygame.Surface:
|
|
8
|
+
"""Загружает логотип. Если path=None — встроенный eagle/assets/logo.png."""
|
|
9
|
+
if path is None:
|
|
10
|
+
here = os.path.dirname(os.path.abspath(__file__))
|
|
11
|
+
path = os.path.join(os.path.dirname(here), "assets", "logo.png")
|
|
12
|
+
if not os.path.exists(path):
|
|
13
|
+
# Fallback: пустая поверхность, чтобы не падать
|
|
14
|
+
surf = pygame.Surface((256, 256), pygame.SRCALPHA)
|
|
15
|
+
return surf
|
|
16
|
+
return pygame.image.load(path).convert_alpha()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_icon(logo: pygame.Surface, size: int = 64) -> pygame.Surface:
|
|
20
|
+
"""Ресайз логотипа под иконку окна. Сохраняет пропорции, паддинг прозрачный."""
|
|
21
|
+
w, h = logo.get_size()
|
|
22
|
+
scale = size / max(w, h)
|
|
23
|
+
nw, nh = max(1, int(w * scale)), max(1, int(h * scale))
|
|
24
|
+
resized = pygame.transform.smoothscale(logo, (nw, nh))
|
|
25
|
+
icon = pygame.Surface((size, size), pygame.SRCALPHA)
|
|
26
|
+
icon.blit(resized, ((size - nw) // 2, (size - nh) // 2))
|
|
27
|
+
return icon
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Splash:
|
|
31
|
+
"""Splash-заставка в окне. Показывается duration секунд, потом fade-out за fade секунд."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, logo: pygame.Surface, engine_w: int, engine_h: int,
|
|
34
|
+
duration: float = 2.0, fade: float = 0.6):
|
|
35
|
+
self.duration = duration
|
|
36
|
+
self.fade = fade
|
|
37
|
+
self._t = 0.0
|
|
38
|
+
self._done = False
|
|
39
|
+
|
|
40
|
+
# Размер логотипа на экране: min(0.35 * размер окна)
|
|
41
|
+
target = int(min(engine_w, engine_h) * 0.35)
|
|
42
|
+
w, h = logo.get_size()
|
|
43
|
+
scale = target / max(w, h)
|
|
44
|
+
self._logo = pygame.transform.smoothscale(logo, (int(w * scale), int(h * scale)))
|
|
45
|
+
self._engine_w = engine_w
|
|
46
|
+
self._engine_h = engine_h
|
|
47
|
+
|
|
48
|
+
# Шрифт и текст подписи рендерим один раз — не пересоздаём каждый кадр
|
|
49
|
+
self._font = pygame.font.SysFont("Arial", 18, bold=True)
|
|
50
|
+
self._label = self._font.render("Eagle Engine", True, (240, 240, 240))
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def done(self) -> bool:
|
|
54
|
+
return self._done
|
|
55
|
+
|
|
56
|
+
def update(self, dt: float) -> None:
|
|
57
|
+
if self._done:
|
|
58
|
+
return
|
|
59
|
+
self._t += dt
|
|
60
|
+
if self._t >= self.duration + self.fade:
|
|
61
|
+
self._done = True
|
|
62
|
+
|
|
63
|
+
def draw(self, surface: pygame.Surface) -> None:
|
|
64
|
+
if self._done:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
# Фон — тот же цвет, что у engine.clear
|
|
68
|
+
surface.fill((25, 25, 35, 255))
|
|
69
|
+
|
|
70
|
+
# Прозрачность: 255 → 0 при fade-out
|
|
71
|
+
alpha = 255
|
|
72
|
+
if self._t > self.duration:
|
|
73
|
+
alpha = int(255 * (1.0 - (self._t - self.duration) / self.fade))
|
|
74
|
+
alpha = max(0, min(255, alpha))
|
|
75
|
+
|
|
76
|
+
lw, lh = self._logo.get_size()
|
|
77
|
+
x = (self._engine_w - lw) // 2
|
|
78
|
+
y = (self._engine_h - lh) // 2
|
|
79
|
+
|
|
80
|
+
logo_copy = self._logo.copy()
|
|
81
|
+
logo_copy.set_alpha(alpha)
|
|
82
|
+
surface.blit(logo_copy, (x, y))
|
|
83
|
+
|
|
84
|
+
# Подпись
|
|
85
|
+
label = self._label.copy()
|
|
86
|
+
label.set_alpha(alpha)
|
|
87
|
+
tw = label.get_width()
|
|
88
|
+
surface.blit(label, ((self._engine_w - tw) // 2, y + lh + 20))
|
eagle/gfx/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from eagle.gfx.shader import Shader, shader_from_files, shader_from_package
|
|
2
|
+
from eagle.gfx.mesh import Mesh
|
|
3
|
+
from eagle.gfx.model import Model, ModelPart
|
|
4
|
+
from eagle.gfx.camera import Camera
|
|
5
|
+
from eagle.gfx.orbit_camera import OrbitCamera
|
|
6
|
+
from eagle.gfx.texture import Texture
|
|
7
|
+
from eagle.gfx.material import Material
|
|
8
|
+
|
|
9
|
+
__all__ = ["Shader", "shader_from_files", "shader_from_package",
|
|
10
|
+
"Mesh", "Model", "ModelPart", "Camera", "OrbitCamera", "Texture", "Material"]
|
eagle/gfx/camera.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Camera — перспектива / орто + look-at."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from eagle.math.vec import Vec3
|
|
4
|
+
from eagle.math.mat import Mat4
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _as_vec3(v, default: Vec3) -> Vec3:
|
|
8
|
+
if v is None:
|
|
9
|
+
return default
|
|
10
|
+
if isinstance(v, Vec3):
|
|
11
|
+
return v
|
|
12
|
+
if isinstance(v, (tuple, list)):
|
|
13
|
+
return Vec3.from_array(v)
|
|
14
|
+
raise TypeError(f"Camera: cannot convert {type(v)} to Vec3")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Camera:
|
|
18
|
+
def __init__(self, position=None, target=None, fov_deg=60.0, near=0.1, far=1000.0):
|
|
19
|
+
self.position = _as_vec3(position, Vec3(0.0, 0.0, 3.0))
|
|
20
|
+
self.target = _as_vec3(target, Vec3.zero())
|
|
21
|
+
self.up = Vec3.up()
|
|
22
|
+
self.fov_deg = fov_deg
|
|
23
|
+
self.near = near
|
|
24
|
+
self.far = far
|
|
25
|
+
self._aspect = 1.0
|
|
26
|
+
self._ortho = None
|
|
27
|
+
|
|
28
|
+
def set_aspect(self, aspect): self._aspect = aspect
|
|
29
|
+
def set_ortho(self, l, r, b, t, near=-1.0, far=1.0): self._ortho = (l, r, b, t, near, far)
|
|
30
|
+
def clear_ortho(self): self._ortho = None
|
|
31
|
+
|
|
32
|
+
def view_matrix(self) -> Mat4:
|
|
33
|
+
return Mat4.look_at(self.position, self.target, self.up)
|
|
34
|
+
|
|
35
|
+
def projection_matrix(self) -> Mat4:
|
|
36
|
+
if self._ortho is not None:
|
|
37
|
+
return Mat4.ortho(*self._ortho)
|
|
38
|
+
return Mat4.perspective(self.fov_deg, self._aspect, self.near, self.far)
|
|
39
|
+
|
|
40
|
+
def view_proj(self) -> Mat4:
|
|
41
|
+
return self.projection_matrix() @ self.view_matrix()
|
eagle/gfx/material.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Material — базовый Blinn-Phong."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from eagle.gfx.texture import Texture
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Material:
|
|
7
|
+
def __init__(self, color=(0.8, 0.8, 0.85), shininess=32.0, specular=0.4, texture: Texture | None = None):
|
|
8
|
+
self.color = color
|
|
9
|
+
self.shininess = shininess
|
|
10
|
+
self.specular = specular
|
|
11
|
+
self.texture = texture
|
eagle/gfx/mesh.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Mesh — VAO + VBO + (опционально) EBO. Формат вершины: 8 float32."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import numpy as np
|
|
4
|
+
import moderngl
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Mesh:
|
|
8
|
+
def __init__(self):
|
|
9
|
+
self._vertices: np.ndarray | None = None
|
|
10
|
+
self._indices: np.ndarray | None = None
|
|
11
|
+
self._vao: moderngl.VertexArray | None = None
|
|
12
|
+
self._vbo: moderngl.Buffer | None = None
|
|
13
|
+
self._ibo: moderngl.Buffer | None = None
|
|
14
|
+
self._mode = moderngl.TRIANGLES
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def from_arrays(cls, vertices, indices=None):
|
|
18
|
+
m = cls()
|
|
19
|
+
m._vertices = np.asarray(vertices, dtype=np.float32).reshape(-1, 8)
|
|
20
|
+
if indices is not None:
|
|
21
|
+
m._indices = np.asarray(indices, dtype=np.uint32).reshape(-1)
|
|
22
|
+
return m
|
|
23
|
+
|
|
24
|
+
def _fix_winding(self) -> None:
|
|
25
|
+
"""Развернуть треугольники, у которых обход не совпадает с нормалями.
|
|
26
|
+
Работает только для TRIANGLES с индексами и нормалями."""
|
|
27
|
+
if self._indices is None or self._mode != moderngl.TRIANGLES:
|
|
28
|
+
return
|
|
29
|
+
tri = self._indices.reshape(-1, 3)
|
|
30
|
+
V = self._vertices
|
|
31
|
+
|
|
32
|
+
p0, p1, p2 = V[tri[:, 0], :3], V[tri[:, 1], :3], V[tri[:, 2], :3]
|
|
33
|
+
n_geo = np.cross(p1 - p0, p2 - p0)
|
|
34
|
+
n_v = (V[tri[:, 0], 3:6] + V[tri[:, 1], 3:6] + V[tri[:, 2], 3:6])
|
|
35
|
+
# dot(n_geo, n_vert) < 0 → обход "наизнанку"
|
|
36
|
+
flip = np.einsum("ij,ij->i", n_geo, n_v) < 0.0
|
|
37
|
+
|
|
38
|
+
if flip.any():
|
|
39
|
+
tri_f = tri.copy()
|
|
40
|
+
tri_f[flip, 1], tri_f[flip, 2] = tri[flip, 2].copy(), tri[flip, 1].copy()
|
|
41
|
+
self._indices = tri_f.flatten().astype(np.uint32)
|
|
42
|
+
|
|
43
|
+
def build(self, ctx: moderngl.Context, prog: moderngl.Program) -> None:
|
|
44
|
+
self.release()
|
|
45
|
+
if self._vertices is None:
|
|
46
|
+
raise RuntimeError("Mesh has no vertices")
|
|
47
|
+
self._fix_winding()
|
|
48
|
+
|
|
49
|
+
self._vbo = ctx.buffer(self._vertices.tobytes(order="C"))
|
|
50
|
+
if self._indices is not None:
|
|
51
|
+
self._ibo = ctx.buffer(self._indices.tobytes(order="C"))
|
|
52
|
+
self._vao = ctx.vertex_array(
|
|
53
|
+
prog,
|
|
54
|
+
[(self._vbo, "3f 3f 2f", "in_position", "in_normal", "in_uv")],
|
|
55
|
+
self._ibo,
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
self._vao = ctx.vertex_array(
|
|
59
|
+
prog,
|
|
60
|
+
[(self._vbo, "3f 3f 2f", "in_position", "in_normal", "in_uv")],
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def release(self) -> None:
|
|
64
|
+
if self._vao: self._vao.release(); self._vao = None
|
|
65
|
+
if self._vbo: self._vbo.release(); self._vbo = None
|
|
66
|
+
if self._ibo: self._ibo.release(); self._ibo = None
|
|
67
|
+
|
|
68
|
+
def render(self) -> None:
|
|
69
|
+
if self._vao is None:
|
|
70
|
+
raise RuntimeError("Mesh not built: call build(ctx, prog)")
|
|
71
|
+
if self._indices is not None:
|
|
72
|
+
self._vao.render(self._mode)
|
|
73
|
+
else:
|
|
74
|
+
self._vao.render(self._mode, vertices=self._vertices.shape[0])
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def vertex_count(self): return 0 if self._vertices is None else self._vertices.shape[0]
|
|
78
|
+
@property
|
|
79
|
+
def index_count(self): return 0 if self._indices is None else self._indices.shape[0]
|
|
80
|
+
@property
|
|
81
|
+
def vertices(self): return self._vertices
|
|
82
|
+
@property
|
|
83
|
+
def indices(self): return self._indices
|
eagle/gfx/model.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Model — контейнер sub-meshes с материалами."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
from eagle.gfx.mesh import Mesh
|
|
4
|
+
from eagle.gfx.texture import Texture
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ModelPart:
|
|
8
|
+
def __init__(self, mesh: Mesh, *, color=(0.8, 0.8, 0.85),
|
|
9
|
+
texture: Texture | None = None,
|
|
10
|
+
shininess: float = 32.0, specular: float = 0.4,
|
|
11
|
+
name: str = ""):
|
|
12
|
+
self.mesh = mesh
|
|
13
|
+
self.color = color
|
|
14
|
+
self.texture = texture
|
|
15
|
+
self.shininess = shininess
|
|
16
|
+
self.specular = specular
|
|
17
|
+
self.name = name
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Model:
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.parts: list[ModelPart] = []
|
|
23
|
+
|
|
24
|
+
def add_part(self, part: ModelPart) -> "Model":
|
|
25
|
+
self.parts.append(part)
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
def build_textures(self, ctx) -> None:
|
|
29
|
+
"""Строит GL-текстуры для всех частей (после создания Engine)."""
|
|
30
|
+
for p in self.parts:
|
|
31
|
+
if p.texture is not None and p.texture._tex is None:
|
|
32
|
+
p.texture.build(ctx)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def vertices(self):
|
|
36
|
+
"""Bounding-box-friendly: склеиваем вершины всех частей (для авто-подгонки камеры)."""
|
|
37
|
+
import numpy as np
|
|
38
|
+
if not self.parts:
|
|
39
|
+
return None
|
|
40
|
+
return np.vstack([p.mesh.vertices for p in self.parts if p.mesh.vertices is not None])
|
|
41
|
+
|
|
42
|
+
def __iter__(self):
|
|
43
|
+
return iter(self.parts)
|
|
44
|
+
|
|
45
|
+
def __len__(self):
|
|
46
|
+
return len(self.parts)
|