Compare commits

..

4 Commits

Author SHA1 Message Date
mmichlol
618059855f fixed-readme 2026-02-05 21:11:16 +01:00
mmichlol
e6bf2a432e README-FIX 2026-02-05 21:09:31 +01:00
mmichlol
d2e09eb22c Bug Fixed 2026-02-05 21:02:45 +01:00
e5591e7dc8 --global-fix 2026-02-04 21:31:57 +01:00
24 changed files with 1683 additions and 0 deletions

28
.gitignore vendored
View File

@@ -1,4 +1,7 @@
<<<<<<< HEAD
=======
# ---> Python # ---> Python
>>>>>>> c572a34cb4ba6d74ef88cda07e35e64b2ee5ab36
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
@@ -168,9 +171,34 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
<<<<<<< HEAD
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the enitre vscode folder
# .vscode/
=======
>>>>>>> c572a34cb4ba6d74ef88cda07e35e64b2ee5ab36
# Ruff stuff: # Ruff stuff:
.ruff_cache/ .ruff_cache/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
<<<<<<< HEAD
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
=======
>>>>>>> c572a34cb4ba6d74ef88cda07e35e64b2ee5ab36

23
LICENSE
View File

@@ -1,3 +1,25 @@
<<<<<<< HEAD
Fable3D License Agreement
Copyright (c) 2025 Michał Lewandowski
All rights reserved.
You are granted a non-exclusive, non-transferable license to download, install, and use Fable3D for personal, educational, or commercial purposes.
You may NOT:
- modify, adapt, translate, or create derivative works based on Fable3D,
- distribute, sublicense, rent, lease, or sell copies of Fable3D or any derivative works,
- reverse engineer, decompile, or disassemble the software.
Any unauthorized use, modification, or distribution is strictly prohibited and may result in legal action.
This license does NOT grant you any rights to access the source code or make changes to it.
---
If you want to request additional rights or commercial licenses, please contact: slawek.1q2w3e4r@gmail.com
=======
MIT License MIT License
Copyright (c) 2026 mmichlol Copyright (c) 2026 mmichlol
@@ -16,3 +38,4 @@ LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE A
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE. USE OR OTHER DEALINGS IN THE SOFTWARE.
>>>>>>> c572a34cb4ba6d74ef88cda07e35e64b2ee5ab36

View File

@@ -1,2 +1,31 @@
# Fable3D # Fable3D
**Fable3D** is a lightweight, modular 3D engine written in Python — designed for game developers, simulation creators, and interactive world builders. It combines the simplicity of Python with powerful 3D rendering capabilities through OpenGL.
> "Every game is a new story — start yours with Fable3D."
---
## Features
- Custom scene graph structure
- Support for 3D models (OBJ, FBX, custom formats)
- Materials and GLSL shaders
- Perspective and orthographic cameras
- Lighting (point, directional, ambient)
- Mesh system with textures and buffers
- Input handling for mouse, keyboard, and controllers
- Real-time engine with deltaTime and FPS management
---
## Requirements
- Python `3.10+`
- `PyOpenGL`
- `numpy`
Install dependencies with:
```bash
pip install Fable3D

View File

@@ -0,0 +1,2 @@
# Blender 4.4.3 MTL File: 'None'
# www.blender.org

View File

@@ -0,0 +1,57 @@
# Blender 4.4.3
# www.blender.org
mtllib Untitled.mtl
o Cube
v -1.000000 -1.000000 1.000000
v -1.000000 -1.000000 -1.000000
v 1.000000 -1.000000 1.000000
v 1.000000 -1.000000 -1.000000
v -0.000000 4.229822 0.000000
v -1.000000 3.229822 1.000000
v -0.000000 4.229822 -0.000000
v -1.000000 3.229822 -1.000000
v 0.000000 4.229822 0.000000
v 1.000000 3.229822 1.000000
v 0.000000 4.229822 -0.000000
v 1.000000 3.229822 -1.000000
vn -0.5774 -0.5774 0.5774
vn -0.5774 -0.5774 -0.5774
vn 0.5774 -0.5774 0.5774
vn 0.5774 -0.5774 -0.5774
vn -0.3002 0.9054 0.3002
vn -0.6507 0.3914 0.6507
vn -0.3002 0.9054 -0.3002
vn -0.6507 0.3914 -0.6507
vn 0.3002 0.9054 0.3002
vn 0.6507 0.3914 0.6507
vn 0.3002 0.9054 -0.3002
vn 0.6507 0.3914 -0.6507
vt 0.750000 0.625000
vt 0.750000 0.625000
vt 0.375000 0.500000
vt 0.577197 0.500000
vt 0.577197 0.750000
vt 0.375000 0.750000
vt 0.375000 0.000000
vt 0.577197 0.000000
vt 0.577197 0.250000
vt 0.375000 0.250000
vt 0.125000 0.500000
vt 0.125000 0.750000
vt 0.875000 0.500000
vt 0.875000 0.750000
vt 0.625000 0.500000
vt 0.625000 0.750000
vt 0.577197 1.000000
vt 0.375000 1.000000
s 1
f 11/1/11 7/1/7 5/2/5 9/1/9
f 4/3/4 12/4/12 10/5/10 3/6/3
f 1/7/1 6/8/6 8/9/8 2/10/2
f 2/11/2 4/3/4 3/6/3 1/12/1
f 2/10/2 8/9/8 12/4/12 4/3/4
f 5/2/5 7/1/7 8/13/8 6/14/6
f 7/1/7 11/1/11 12/15/12 8/13/8
f 11/1/11 9/1/9 10/5/10 12/4/12
f 9/1/9 5/2/5 6/14/6 10/16/10
f 3/6/3 10/5/10 6/17/6 1/18/1

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

BIN
examples/assets/texture.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 B

BIN
examples/assets/water.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

151
examples/game.py Normal file
View File

@@ -0,0 +1,151 @@
import my3dengine as m3d
from OpenGL.GL import *
import time
import numpy as np
class Game:
def __init__(self):
self.WIDTH, self.HEIGHT = m3d.get_fullscreen()
self.window = m3d.Window("3D Game", self.WIDTH, self.HEIGHT)
self.keys = m3d.Key
self.escape_was_pressed = False
self.sources = [
m3d.AudioSource(position=[0, 0, 3], sound_path="assets/dark-atmosphere-background-009-312380.mp3", emit_radius=5, volume=1.0, loop=False, fade_duration=1.0),
]
# FPS counter
self.last_fps_time = time.time()
self.frames = 0
self.camera = m3d.Camera(
position=(0, 0, 3),
target=(0, 0, 0),
up=(0, 1, 0),
fov=60,
aspect=self.WIDTH / self.HEIGHT,
)
self.listener = m3d.AudioListener(self.camera.position)
self.ui = m3d.UI(self.WIDTH, self.HEIGHT)
self.meshes = [
m3d.Mesh.cube(),
m3d.Mesh.sphere(),
m3d.Mesh.capsule(),
m3d.Mesh.cube(),
m3d.Mesh.plane(),
m3d.Mesh.from_obj("assets/untitled.obj"),
]
# WODA
self.water = m3d.Mesh.water(
size=20.0,
resolution=64, # 🧠 Mniejsze resolution = lepsze FPS
wave_speed=0.01,
wave_height=0.5,
wave_scale=(0.4, 0.6),
second_wave=True,
color=(0.0, 0.4, 0.8),
backface=True
)
self.water.set_position(15, 0, 15)
self.water.set_texture("assets/water.png")
self.water.set_uv_transform((10, 10), (0, 0))
# Pozycje
self.meshes[0].set_position(0, 0, 0)
self.meshes[1].set_position(0, 0, 5)
self.meshes[2].set_position(2, 0, 0)
self.meshes[3].set_position(-4, 0, 0)
self.meshes[4].set_position(0, -4, 0)
self.meshes[5].set_position(-8, 0, 0)
# Skale
self.meshes[2].scale_uniform(2)
self.meshes[3].set_scale(1, 0.2, 1)
self.meshes[4].scale_uniform(15)
self.meshes[5].scale_uniform(0.5)
# Tekstury
for i in range(6):
self.meshes[i].set_texture("assets/bricksx64.png")
# UV
self.meshes[5].set_uv_transform(tiling=(10, 10), offset=(0.1, 0.1))
self.meshes[4].set_uv_transform(tiling=(10, 10), offset=(0.1, 0.1))
self.meshes[1].set_uv_transform(tiling=(5, 5), offset=(0.1, 0.1))
self.meshes[2].set_uv_transform(tiling=(5, 5), offset=(0.1, 0.1))
# OpenGL ustawienia
glEnable(GL_DEPTH_TEST)
glEnable(GL_CULL_FACE)
glCullFace(GL_BACK)
def update(self, dt, window):
self.camera.handle_input(dt, window)
glViewport(0, 0, self.WIDTH, self.HEIGHT)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
self.listener.position[:] = self.camera.position
cam_pos = self.camera.position
cam_dir = m3d.normalize(self.camera.target - cam_pos)
view_proj = self.camera.view_proj_matrix
m3d.update_audio_system(self.listener, self.sources)
if (
self.water.is_in_fov(cam_pos, cam_dir, self.camera.fov)
and np.linalg.norm(self.water.position - cam_pos) < 150
):
self.water.update_water()
self.water.shader.use()
self.camera.set_uniforms(self.water.shader)
self.water.draw(
cam_pos=cam_pos,
cam_dir=cam_dir,
fov_deg=self.camera.fov,
view_proj_matrix=view_proj
)
# Rysuj inne meshe
for mesh in self.meshes:
if (
not mesh.is_in_fov(cam_pos, cam_dir, self.camera.fov)
or np.linalg.norm(mesh.position - cam_pos) > 150
):
continue
mesh.shader.use()
self.camera.set_uniforms(mesh.shader)
mesh.draw(
cam_pos=cam_pos,
cam_dir=cam_dir,
fov_deg=self.camera.fov,
view_proj_matrix=view_proj,
debug=False
)
# FPS tracking
self.frames += 1
current_time = time.time()
if current_time - self.last_fps_time >= 1.0:
print(f"FPS: {self.frames}")
self.frames = 0
self.last_fps_time = current_time
def run(self):
try:
m3d.init()
self.window.run(self.update)
finally:
self.water.destroy()
for mesh in self.meshes:
mesh.destroy()
if __name__ == "__main__":
app = Game()
app.run()

73
examples/test.py Normal file
View File

@@ -0,0 +1,73 @@
import time
import my3dengine as m3d
from my3dengine.camera import Camera
from OpenGL.GL import *
class Game:
def __init__(self):
self.WIDTH, self.HEIGHT = m3d.get_fullscreen()
self.window = m3d.Window("3D Game", self.WIDTH, self.HEIGHT)
self.keys = m3d.Key
self.escape_was_pressed = False
self.camera = Camera(
position=(20, 20, 50),
target=(8, 8, 8),
up=(0, 1, 0),
fov=60,
aspect=self.WIDTH / self.HEIGHT,
)
self.base = m3d.Mesh.from_obj("assets/untitled.obj")
self.base.set_texture("assets/bricksx64.png")
spacing = 1.1
self.positions = []
for x in range(4):
for y in range(4):
for z in range(4):
self.positions.append((x * spacing, y * spacing, z * spacing))
# FPS counter
self.last_fps_time = time.time()
self.frames = 0
def update(self, dt, window):
self.camera.handle_input(dt, window)
glViewport(0, 0, self.WIDTH, self.HEIGHT)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
view = self.camera.view_matrix
proj = self.camera.projection_matrix
view_proj = proj @ view
# Rysujemy tę samą siatkę w wielu pozycjach
for pos in self.positions:
self.base.set_position(*pos)
self.base.draw(
cam_pos=self.camera.position,
cam_dir=m3d.normalize(self.camera.target - self.camera.position),
fov_deg=self.camera.fov,
view_proj_matrix=view_proj,
debug=False
)
# FPS tracking
self.frames += 1
current_time = time.time()
if current_time - self.last_fps_time >= 1.0:
print(f"FPS: {self.frames}")
self.frames = 0
self.last_fps_time = current_time
def run(self):
try:
m3d.init()
self.window.run(self.update)
finally:
self.base.destroy()
if __name__ == "__main__":
app = Game()
app.run()

BIN
my3dengine/SDL2.dll Normal file

Binary file not shown.

63
my3dengine/UI.py Normal file
View File

@@ -0,0 +1,63 @@
import my3dengine as m3d
from OpenGL.GL import *
import numpy as np
from .shader import create_ui_shader
import ctypes
class UI:
def __init__(self, window_width, window_height):
self.w = window_width
self.h = window_height
self.shader = create_ui_shader()
self.vao = glGenVertexArrays(1)
self.vbo = glGenBuffers(1)
glBindVertexArray(self.vao)
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
# Bufor na max 6 wierzchołków * 2 float (x,y)
glBufferData(GL_ARRAY_BUFFER, 6 * 2 * 4, None, GL_DYNAMIC_DRAW)
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * 4, ctypes.c_void_p(0))
glEnableVertexAttribArray(0)
glBindBuffer(GL_ARRAY_BUFFER, 0)
glBindVertexArray(0)
self.rects = [] # lista prostokątów do narysowania
def rect(self, x, y, width, height, color):
# dodaj prostokąt do listy, nie rysuj od razu
self.rects.append((x, y, width, height, color))
def draw(self):
glUseProgram(self.shader)
glBindVertexArray(self.vao)
loc_color = glGetUniformLocation(self.shader, "uColor")
loc_win = glGetUniformLocation(self.shader, "uWindowSize")
glUniform2f(loc_win, self.w, self.h)
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
for x, y, w, h, color in self.rects:
vertices = np.array([
x, y,
x + w, y,
x, y + h,
x + w, y,
x + w, y + h,
x, y + h,
], dtype=np.float32)
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
glBufferSubData(GL_ARRAY_BUFFER, 0, vertices.nbytes, vertices)
glBindBuffer(GL_ARRAY_BUFFER, 0)
glUniform4fv(loc_color, 1, color)
glDrawArrays(GL_TRIANGLES, 0, 6)
glBindVertexArray(0)
glUseProgram(0)
self.rects.clear() # po narysowaniu czyścimy listę

15
my3dengine/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
import os
import sys
# Automatyczna ścieżka do SDL2.dll
dll_path = os.path.abspath(os.path.dirname(__file__))
os.environ["PYSDL2_DLL_PATH"] = dll_path
from .key import Key
from .core import Window, Vector3, get_fullscreen, init, normalize, debug_init
from .camera import Camera
from .mesh import Mesh
from .UI import UI
from .audio import AudioSource, AudioListener, update_audio_system
__all__ = ['Window', 'Camera', 'Mesh', 'Key', 'UI', 'audio']

174
my3dengine/audio.py Normal file
View File

@@ -0,0 +1,174 @@
import numpy as np
import sounddevice as sd
import soundfile as sf
import threading
class AudioListener:
def __init__(self, position):
self.position = np.array(position, dtype=float)
class AudioSource:
def __init__(self, position, sound_path, emit_radius=15.0, volume=1.0, loop=False, fade_duration=1.0):
self.position = np.array(position, dtype=float)
self.sound_path = sound_path
self.emit_radius = emit_radius
self.base_volume = volume
self.loop = loop
self._data, self._fs = sf.read(self.sound_path, dtype='float32')
self._stream = None
self._stop_flag = threading.Event()
self._lock = threading.Lock()
self._volume = 0.0 # aktualna głośność (płynna)
self._target_volume = 0.0 # docelowa głośność (fade target)
self._fade_duration = fade_duration # czas fade w sekundach
self._pos = 0
self.playing = False
self._paused = False
self.MIN_VOLUME_TO_PLAY = 0.01
def _callback(self, outdata, frames, time, status):
with self._lock:
if self._stop_flag.is_set():
raise sd.CallbackStop()
chunk_start = self._pos
chunk_end = self._pos + frames
if chunk_end > len(self._data):
if self.loop:
part1 = self._data[chunk_start:]
part2 = self._data[:chunk_end - len(self._data)]
chunk = np.concatenate((part1, part2))
self._pos = chunk_end - len(self._data)
else:
chunk = self._data[chunk_start:]
chunk = np.pad(chunk, ((0, frames - len(chunk)), (0, 0)), mode='constant')
self._pos = len(self._data)
else:
chunk = self._data[chunk_start:chunk_end]
self._pos = chunk_end
current_vol = self._volume
target_vol = self._target_volume
fade_step = 1.0 / (self._fs * self._fade_duration) # ile głośności na 1 próbkę
steps = np.arange(frames, dtype=np.float32)
if target_vol > current_vol:
volumes = current_vol + fade_step * steps * (target_vol - current_vol)
volumes = np.clip(volumes, 0.0, target_vol)
else:
volumes = current_vol - fade_step * steps * (current_vol - target_vol)
volumes = np.clip(volumes, target_vol, 1.0)
self._volume = volumes[-1]
if self._paused or self._volume < self.MIN_VOLUME_TO_PLAY:
outdata[:] = 0
else:
if chunk.ndim > 1:
volumes = volumes[:, np.newaxis]
outdata[:] = chunk * volumes
if not self.loop and self._pos >= len(self._data):
raise sd.CallbackStop()
def _on_finished(self):
with self._lock:
self._stream = None
self.playing = False
self._volume = 0.0
self._target_volume = 0.0
self._pos = 0
self._paused = False
def play(self, volume):
with self._lock:
if self.playing:
if self._paused:
# Jeśli jest pauza, wznow i ustaw docelową głośność
self._paused = False
self._target_volume = volume
return
else:
# Już gra, więc ustaw tylko docelową głośność
self._target_volume = volume
return
# Nowe odtwarzanie od zera z głośnością 0 i fade-in
self._volume = 0.0
self._target_volume = volume
self._pos = 0
self._stop_flag.clear()
self._stream = sd.OutputStream(
samplerate=self._fs,
channels=self._data.shape[1] if self._data.ndim > 1 else 1,
callback=self._callback,
finished_callback=self._on_finished
)
self._stream.start()
self.playing = True
self._paused = False
def pause(self):
with self._lock:
if self.playing and not self._paused:
self._target_volume = 0.0 # fade out do 0, ale stream działa
# _paused ustawiane w callbacku, gdy głośność spadnie do zera
def resume(self):
with self._lock:
if self.playing and self._paused:
self._paused = False
self._volume = 0.0 # zaczynamy z 0, fade-in w callbacku
self._target_volume = self.base_volume # docelowa głośność
def stop(self):
with self._lock:
if self._stream:
self._stop_flag.set()
self.playing = False
self._volume = 0.0
self._target_volume = 0.0
self._pos = 0
self._paused = False
def set_target_volume(self, volume):
with self._lock:
self._target_volume = max(0.0, min(volume, 1.0))
def update_audio_system(listener, sources, debug=False):
for source in sources:
dist = np.linalg.norm(listener.position - source.position)
if dist <= source.emit_radius:
attenuation = 1.0 - dist / source.emit_radius
volume = source.base_volume * attenuation
if debug:
print(f"[Audio] Distance: {dist:.2f}, Volume: {volume:.2f}")
if not source.playing:
source.play(volume)
else:
if source._paused:
# Jeśli był pauzowany, startuj od nowa z głośnością 0 i fade-in
source.stop()
source.play(volume)
else:
source.set_target_volume(volume)
else:
if source.playing:
source.set_target_volume(0.0)
# Poczekaj, aż głośność spadnie prawie do zera, wtedy pauzuj
if source._volume <= 0.01 and not source._paused:
if debug:
print(f"[PAUSE] {source.sound_path} - volume zero")
source.pause()

113
my3dengine/camera.py Normal file
View File

@@ -0,0 +1,113 @@
from OpenGL.GL import *
from .key import Key
from .mesh import Mesh
import sdl2
import numpy as np
import ctypes
def normalize(v):
norm = np.linalg.norm(v)
if norm == 0:
return v
return v / norm
def look_at(eye, center, up):
f = normalize(center - eye)
s = normalize(np.cross(f, up))
u = np.cross(s, f)
mat = np.identity(4, dtype=np.float32)
mat[0, :3] = s
mat[1, :3] = u
mat[2, :3] = -f
mat[:3, 3] = -np.dot(mat[:3, :3], eye)
return mat
def perspective(fov, aspect, near, far):
f = 1.0 / np.tan(np.radians(fov) / 2)
mat = np.zeros((4, 4), dtype=np.float32)
mat[0, 0] = f / aspect
mat[1, 1] = f
mat[2, 2] = (far + near) / (near - far)
mat[2, 3] = (2 * far * near) / (near - far)
mat[3, 2] = -1
return mat
class Camera:
def __init__(self, position, target, up, fov, aspect, near=0.1, far=100.0):
self.position = np.array(position, dtype=np.float32)
self.target = np.array(target, dtype=np.float32)
self.up = np.array(up, dtype=np.float32)
self.fov = fov
self.aspect = aspect
self.near = near
self.far = far
self.move_speed = 5.0
self.mouse_sensitivity = 0.003
@property
def view_matrix(self):
return look_at(self.position, self.target, self.up)
@property
def projection_matrix(self):
return perspective(self.fov, self.aspect, self.near, self.far)
@property
def view_proj_matrix(self):
return self.projection_matrix @ self.view_matrix
def move(self, delta):
self.position += delta
self.target += delta
def handle_input(self, dt, window):
speed = self.move_speed * dt
forward = normalize(self.target - self.position)
right = normalize(np.cross(forward, self.up))
if window.is_key_pressed(Key.W):
self.move(forward * speed)
if window.is_key_pressed(Key.S):
self.move(-forward * speed)
if window.is_key_pressed(Key.A):
self.move(-right * speed)
if window.is_key_pressed(Key.D):
self.move(right * speed)
# Dodane ruchy w górę/dół
if window.is_key_pressed(Key.SPACE): # Spacja - do góry
self.move(self.up * speed)
if window.is_key_pressed(Key.LCTRL): # Lewy Ctrl - w dół
self.move(-self.up * speed)
if getattr(window, "mouse_locked", True):
xrel = ctypes.c_int()
yrel = ctypes.c_int()
sdl2.SDL_GetRelativeMouseState(ctypes.byref(xrel), ctypes.byref(yrel))
yaw = -xrel.value * self.mouse_sensitivity
pitch = -yrel.value * self.mouse_sensitivity
def rotate(v, axis, angle):
axis = normalize(axis)
cos_a = np.cos(angle)
sin_a = np.sin(angle)
return v * cos_a + np.cross(axis, v) * sin_a + axis * np.dot(axis, v) * (1 - cos_a)
offset = self.target - self.position
offset = rotate(offset, self.up, yaw)
right = normalize(np.cross(offset, self.up))
offset = rotate(offset, right, pitch)
self.target = self.position + offset
def set_uniforms(self, shader_program):
program_id = shader_program.program # ID programu
loc_v = glGetUniformLocation(program_id, "uView")
loc_p = glGetUniformLocation(program_id, "uProjection")
glUniformMatrix4fv(loc_v, 1, GL_FALSE, self.view_matrix.T)
glUniformMatrix4fv(loc_p, 1, GL_FALSE, self.projection_matrix.T)

151
my3dengine/core.py Normal file
View File

@@ -0,0 +1,151 @@
import sdl2
import sdl2.ext
from OpenGL.GL import *
import time
from sdl2 import SDL_GL_SetSwapInterval
from .key import Key
import numpy as np
import OpenGL.GL
class Vector3:
def __init__(self, x=0, y=0, z=0):
self.x = float(x)
self.y = float(y)
self.z = float(z)
def __add__(self, other):
return Vector3(self.x + other.x, self.y + other.y, self.z + other.z)
def __sub__(self, other):
return Vector3(self.x - other.x, self.y - other.y, self.z - other.z)
def __mul__(self, scalar):
return Vector3(self.x * scalar, self.y * scalar, self.z * scalar)
def to_list(self):
return [self.x, self.y, self.z]
def __repr__(self):
return f"Vector3({self.x}, {self.y}, {self.z})"
def debug_init():
@staticmethod
def init():
# Włącz przezroczystość (alpha blending)
glEnable(GL_BLEND)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
# Włącz test głębokości (żeby obiekty były dobrze rysowane w 3D)
glEnable(GL_DEPTH_TEST)
glDepthFunc(GL_LESS) # Domyślnie można zostawić, ale warto jawnie ustawić
# Włącz odrzucanie tylnych ścianek (culling)
glEnable(GL_CULL_FACE)
glCullFace(GL_BACK) # Możesz też użyć GL_FRONT, jeśli robisz np. odbicia
glFrontFace(GL_CCW) # Counter-clockwise - domyślnie standard OpenGL
# Głębia na 1.0 - domyślnie, ale dobrze ustawić jawnie
glClearDepth(1.0)
# V-Sync
SDL_GL_SetSwapInterval(1) # 1 = włączony VSync, 0 = wyłączony (lepszy FPS, może tearing)
# Gładkie linie i wielokąty (opcjonalne, zależnie od potrzeb)
glEnable(GL_LINE_SMOOTH)
glHint(GL_LINE_SMOOTH_HINT, GL_NICEST)
glEnable(GL_POLYGON_SMOOTH)
glHint(GL_POLYGON_SMOOTH_HINT, GL_NICEST)
# Antyaliasing multisampling (jeśli masz MSAA ustawione w SDL_GL)
glEnable(GL_MULTISAMPLE)
def normalize(v):
norm = np.linalg.norm(v)
if norm == 0:
return v
return v / norm
@staticmethod
def init():
glEnable(GL_BLEND)
glEnable(GL_DEPTH_TEST)
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)
glEnable(GL_CULL_FACE)
glFrontFace(GL_CCW)
glClearDepth(1.0)
SDL_GL_SetSwapInterval(1)
@staticmethod
def get_fullscreen():
sdl2.SDL_Init(sdl2.SDL_INIT_VIDEO) # upewniamy się że SDL wystartował
mode = sdl2.SDL_DisplayMode()
if sdl2.SDL_GetDesktopDisplayMode(0, mode) != 0:
raise RuntimeError("Nie udało się pobrać trybu ekranu")
return mode.w, mode.h
class Window:
def __init__(self, title, width, height):
sdl2.SDL_Init(sdl2.SDL_INIT_VIDEO)
self.width = width
self.height = height
self.window = sdl2.SDL_CreateWindow(
title.encode("utf-8"),
sdl2.SDL_WINDOWPOS_CENTERED,
sdl2.SDL_WINDOWPOS_CENTERED,
width,
height,
sdl2.SDL_WINDOW_OPENGL | sdl2.SDL_WINDOW_SHOWN,
)
self.context = sdl2.SDL_GL_CreateContext(self.window)
glViewport(0, 0, width, height)
self.mouse_locked = True
sdl2.SDL_SetRelativeMouseMode(sdl2.SDL_TRUE)
sdl2.SDL_ShowCursor(sdl2.SDL_DISABLE)
def is_key_pressed(self, key: Key) -> bool:
keys = sdl2.SDL_GetKeyboardState(None)
return keys[key] != 0
def toggle_mouse_lock(self):
self.mouse_locked = not self.mouse_locked
if self.mouse_locked:
sdl2.SDL_SetRelativeMouseMode(sdl2.SDL_TRUE)
sdl2.SDL_ShowCursor(sdl2.SDL_DISABLE)
else:
sdl2.SDL_SetRelativeMouseMode(sdl2.SDL_FALSE)
sdl2.SDL_ShowCursor(sdl2.SDL_ENABLE)
def prepare_frame(self):
glViewport(0, 0, self.width, self.height)
glClearColor(0.1, 0.1, 0.1, 1.0)
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
glEnable(GL_DEPTH_TEST)
def run(self, update_callback=None, cameras=[]):
last_time = time.time()
event = sdl2.SDL_Event()
running = True
while running:
while sdl2.SDL_PollEvent(event):
if event.type == sdl2.SDL_QUIT:
running = False
elif event.type == sdl2.SDL_KEYDOWN:
if event.key.keysym.scancode == sdl2.SDL_SCANCODE_ESCAPE:
self.toggle_mouse_lock()
now = time.time()
dt = now - last_time
last_time = now
self.prepare_frame()
if update_callback:
update_callback(dt, self)
sdl2.SDL_GL_SwapWindow(self.window)
glFlush()
sdl2.SDL_DestroyWindow(self.window)
sdl2.SDL_Quit()

77
my3dengine/key.py Normal file
View File

@@ -0,0 +1,77 @@
# my3dengine/key.py
import sdl2
from enum import IntEnum
class Key(IntEnum):
# Strzałki
UP = sdl2.SDL_SCANCODE_UP
DOWN = sdl2.SDL_SCANCODE_DOWN
LEFT = sdl2.SDL_SCANCODE_LEFT
RIGHT = sdl2.SDL_SCANCODE_RIGHT
# Klawisze literowe
A = sdl2.SDL_SCANCODE_A
B = sdl2.SDL_SCANCODE_B
C = sdl2.SDL_SCANCODE_C
D = sdl2.SDL_SCANCODE_D
E = sdl2.SDL_SCANCODE_E
F = sdl2.SDL_SCANCODE_F
G = sdl2.SDL_SCANCODE_G
H = sdl2.SDL_SCANCODE_H
I = sdl2.SDL_SCANCODE_I
J = sdl2.SDL_SCANCODE_J
K = sdl2.SDL_SCANCODE_K
L = sdl2.SDL_SCANCODE_L
M = sdl2.SDL_SCANCODE_M
N = sdl2.SDL_SCANCODE_N
O = sdl2.SDL_SCANCODE_O
P = sdl2.SDL_SCANCODE_P
Q = sdl2.SDL_SCANCODE_Q
R = sdl2.SDL_SCANCODE_R
S = sdl2.SDL_SCANCODE_S
T = sdl2.SDL_SCANCODE_T
U = sdl2.SDL_SCANCODE_U
V = sdl2.SDL_SCANCODE_V
W = sdl2.SDL_SCANCODE_W
X = sdl2.SDL_SCANCODE_X
Y = sdl2.SDL_SCANCODE_Y
Z = sdl2.SDL_SCANCODE_Z
# Klawisze numeryczne
NUMBER_0 = sdl2.SDL_SCANCODE_0
NUMBER_1 = sdl2.SDL_SCANCODE_1
NUMBER_2 = sdl2.SDL_SCANCODE_2
NUMBER_3 = sdl2.SDL_SCANCODE_3
NUMBER_4 = sdl2.SDL_SCANCODE_4
NUMBER_5 = sdl2.SDL_SCANCODE_5
NUMBER_6 = sdl2.SDL_SCANCODE_6
NUMBER_7 = sdl2.SDL_SCANCODE_7
NUMBER_8 = sdl2.SDL_SCANCODE_8
NUMBER_9 = sdl2.SDL_SCANCODE_9
# Klawisze funkcyjne
F1 = sdl2.SDL_SCANCODE_F1
F2 = sdl2.SDL_SCANCODE_F2
F3 = sdl2.SDL_SCANCODE_F3
F4 = sdl2.SDL_SCANCODE_F4
F5 = sdl2.SDL_SCANCODE_F5
F6 = sdl2.SDL_SCANCODE_F6
F7 = sdl2.SDL_SCANCODE_F7
F8 = sdl2.SDL_SCANCODE_F8
F9 = sdl2.SDL_SCANCODE_F9
F10 = sdl2.SDL_SCANCODE_F10
F11 = sdl2.SDL_SCANCODE_F11
F12 = sdl2.SDL_SCANCODE_F12
# Inne ważne
SPACE = sdl2.SDL_SCANCODE_SPACE
ESCAPE = sdl2.SDL_SCANCODE_ESCAPE
RETURN = sdl2.SDL_SCANCODE_RETURN
TAB = sdl2.SDL_SCANCODE_TAB
LSHIFT = sdl2.SDL_SCANCODE_LSHIFT
RSHIFT = sdl2.SDL_SCANCODE_RSHIFT
LCTRL = sdl2.SDL_SCANCODE_LCTRL
RCTRL = sdl2.SDL_SCANCODE_RCTRL
ALT = sdl2.SDL_SCANCODE_LALT
BACKSPACE = sdl2.SDL_SCANCODE_BACKSPACE
CAPSLOCK = sdl2.SDL_SCANCODE_CAPSLOCK

489
my3dengine/mesh.py Normal file
View File

@@ -0,0 +1,489 @@
from OpenGL.GL import *
import numpy as np
from .shader import basic_shader
from PIL import Image
import ctypes
import math
import time
_default_shader = None
_last_bound_shader = [None]
_last_bound_vao = [None]
def get_default_shader():
global _default_shader
if _default_shader is None:
_default_shader = basic_shader()
_default_shader.uniforms = {
"uModel": glGetUniformLocation(_default_shader.program, "uModel"),
"uUVTiling": glGetUniformLocation(_default_shader.program, "uUVTiling"),
"uUVOffset": glGetUniformLocation(_default_shader.program, "uUVOffset"),
"uTexture": glGetUniformLocation(_default_shader.program, "uTexture"),
"useTexture": glGetUniformLocation(_default_shader.program, "useTexture"),
}
return _default_shader
def safe_use_shader(shader):
if _last_bound_shader[0] != shader:
glUseProgram(shader.program)
_last_bound_shader[0] = shader
def safe_bind_vao(vao):
if _last_bound_vao[0] != vao:
glBindVertexArray(vao)
_last_bound_vao[0] = vao
def get_model_matrix(position, scale):
model = np.identity(4, dtype=np.float32)
model[0, 0], model[1, 1], model[2, 2] = scale
model[:3, 3] = position # poprawione na kolumnę 3
return model
class Mesh:
def __init__(self, vertices, shader=None):
self.shader = shader or get_default_shader()
if not hasattr(self.shader, "uniforms"):
self.shader.uniforms = {
"uModel": glGetUniformLocation(self.shader.program, "uModel"),
"uUVTiling": glGetUniformLocation(self.shader.program, "uUVTiling"),
"uUVOffset": glGetUniformLocation(self.shader.program, "uUVOffset"),
"uTexture": glGetUniformLocation(self.shader.program, "uTexture"),
"useTexture": glGetUniformLocation(self.shader.program, "useTexture"),
}
self.vertex_count = len(vertices) // 8
self.position = np.zeros(3, dtype=np.float32)
self.scale = np.ones(3, dtype=np.float32)
self.texture = None
self.uv_tiling = np.ones(2, dtype=np.float32)
self.uv_offset = np.zeros(2, dtype=np.float32)
self._last_position = np.array([np.nan, np.nan, np.nan], dtype=np.float32)
self._last_scale = np.array([np.nan, np.nan, np.nan], dtype=np.float32)
self._uniforms_uploaded_once = False
self._init_buffers(vertices)
def copy(self):
new = Mesh.__new__(Mesh)
new.__dict__ = self.__dict__.copy()
new.position = self.position.copy()
new.scale = self.scale.copy()
new._last_position = np.array([np.nan, np.nan, np.nan], dtype=np.float32)
new._last_scale = np.array([np.nan, np.nan, np.nan], dtype=np.float32)
self._uniforms_uploaded_once = False
return new
def is_in_fov(self, cam_pos, cam_dir, fov_deg):
half_fov_rad = math.radians(fov_deg * 1.2)
cam_dir = cam_dir / np.linalg.norm(cam_dir)
to_obj = self.position - cam_pos
dist = np.linalg.norm(to_obj)
if dist == 0:
return True
to_obj_dir = to_obj / dist
angle = math.acos(np.clip(np.dot(cam_dir, to_obj_dir), -1, 1))
return angle <= half_fov_rad
def _init_buffers(self, vertices):
data = np.array(vertices, dtype=np.float32)
self.vao = glGenVertexArrays(1)
self.vbo = glGenBuffers(1)
glBindVertexArray(self.vao)
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
glBufferData(GL_ARRAY_BUFFER, data.nbytes, data, GL_STATIC_DRAW)
stride = 8 * 4
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(0))
glEnableVertexAttribArray(0)
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(12))
glEnableVertexAttribArray(1)
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(24))
glEnableVertexAttribArray(2)
glBindBuffer(GL_ARRAY_BUFFER, 0)
glBindVertexArray(0)
def set_uv_transform(self, tiling=(1.0, 1.0), offset=(0.0, 0.0)):
self.uv_tiling[:] = tiling
self.uv_offset[:] = offset
def upload_uniforms(self):
if (not self._uniforms_uploaded_once or
not (np.array_equal(self.position, self._last_position) and
np.array_equal(self.scale, self._last_scale))):
model = get_model_matrix(self.position, self.scale)
uniforms = self.shader.uniforms
glUniformMatrix4fv(uniforms["uModel"], 1, GL_FALSE, model.T)
glUniform2fv(uniforms["uUVTiling"], 1, self.uv_tiling)
glUniform2fv(uniforms["uUVOffset"], 1, self.uv_offset)
self._last_position = self.position.copy()
self._last_scale = self.scale.copy()
self._uniforms_uploaded_once = True
def draw(self, cam_pos=None, cam_dir=None, fov_deg=70, view_proj_matrix=None, debug=False):
if debug:
print(f"Draw called for mesh at {self.position}")
if cam_pos is not None:
dist = np.linalg.norm(self.position - cam_pos)
if dist > 600: # ustaw granicę zależnie od sceny
return
# if cam_pos is not None and cam_dir is not None:
# if not self.is_in_fov(cam_pos, cam_dir, fov_deg + 50): # +10° tolerancji
# return
safe_use_shader(self.shader)
self.upload_uniforms()
uniforms = self.shader.uniforms
if self.texture:
glActiveTexture(GL_TEXTURE0)
glBindTexture(GL_TEXTURE_2D, self.texture)
glUniform1i(uniforms["uTexture"], 0)
glUniform1i(uniforms["useTexture"], 1)
else:
glUniform1i(uniforms["useTexture"], 0)
safe_bind_vao(self.vao)
glDrawArrays(GL_TRIANGLES, 0, self.vertex_count)
if debug:
print(f"[DEBUG] Mesh at {self.position} został narysowany.")
def set_texture(self, path):
image = Image.open(path).transpose(Image.FLIP_TOP_BOTTOM).convert('RGBA')
img_data = np.array(image, dtype=np.uint8)
if self.texture:
glDeleteTextures(1, [self.texture])
self.texture = glGenTextures(1)
glBindTexture(GL_TEXTURE_2D, self.texture)
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, image.width, image.height, 0,
GL_RGBA, GL_UNSIGNED_BYTE, img_data)
glGenerateMipmap(GL_TEXTURE_2D)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR)
def set_position(self, x, y, z):
self.position[:] = [x, y, z]
def set_scale(self, x, y, z):
self.scale[:] = [x, y, z]
def scale_uniform(self, factor):
self.scale[:] = factor
def destroy(self):
if hasattr(self, 'vao') and glDeleteVertexArrays:
glDeleteVertexArrays(1, [self.vao])
glDeleteBuffers(1, [self.vbo])
if self.texture and glDeleteTextures:
glDeleteTextures(1, [self.texture])
# =========================
# ======== models =========
# =========================
@staticmethod
def from_obj(path, color=(1, 1, 1)):
positions = []
texcoords = []
faces = []
with open(path, "r") as f:
for line in f:
if line.startswith("v "): # vertex position
parts = line.strip().split()
positions.append(tuple(map(float, parts[1:4])))
elif line.startswith("vt "): # texture coordinate
parts = line.strip().split()
texcoords.append(tuple(map(float, parts[1:3])))
elif line.startswith("f "): # face
parts = line.strip().split()[1:]
face = []
for p in parts:
vals = p.split('/')
vi = int(vals[0]) - 1
ti = int(vals[1]) - 1 if len(vals) > 1 and vals[1] else 0
face.append((vi, ti))
faces.append(face)
verts = []
for face in faces:
if len(face) >= 3:
for i in range(1, len(face) - 1):
for idx in [0, i, i + 1]:
vi, ti = face[idx]
pos = positions[vi]
uv = texcoords[ti] if texcoords else (0.0, 0.0)
verts.extend([*pos, *color, *uv])
return Mesh(verts, basic_shader())
@staticmethod
def cube(color=(1, 1, 1)):
# Pozycje wierzchołków sześcianu
p = [(-0.5, -0.5, 0.5), (0.5, -0.5, 0.5), (0.5, 0.5, 0.5), (-0.5, 0.5, 0.5), # front
(-0.5, -0.5, -0.5), (0.5, -0.5, -0.5), (0.5, 0.5, -0.5), (-0.5, 0.5, -0.5)] # back
# UV mapowanie na jedną ścianę (powtarzane dla każdej)
uv = [(0, 0), (1, 0), (1, 1), (0, 1)]
# Indeksy do rysowania ścian (każda ściana: 2 trójkąty)
faces = [
(0, 1, 2, 3), # front
(5, 4, 7, 6), # back
(4, 0, 3, 7), # left
(1, 5, 6, 2), # right
(3, 2, 6, 7), # top
(4, 5, 1, 0) # bottom
]
verts = []
for face in faces:
idx = [face[0], face[1], face[2], face[0], face[2], face[3]]
uv_idx = [0, 1, 2, 0, 2, 3]
for i in range(6):
pos = p[idx[i]]
tex = uv[uv_idx[i]]
verts.extend([*pos, *color, *tex])
return Mesh(verts, basic_shader())
@staticmethod
def sphere(radius=0.5, lat_segments=16, lon_segments=16, color=(1, 1, 1)):
verts = []
for i in range(lat_segments):
theta1 = np.pi * (i / lat_segments - 0.5)
theta2 = np.pi * ((i + 1) / lat_segments - 0.5)
for j in range(lon_segments):
phi1 = 2 * np.pi * (j / lon_segments)
phi2 = 2 * np.pi * ((j + 1) / lon_segments)
def get_pos(theta, phi):
return (
radius * np.cos(theta) * np.cos(phi),
radius * np.sin(theta),
radius * np.cos(theta) * np.sin(phi)
)
# Wierzchołki
p1 = get_pos(theta1, phi1)
p2 = get_pos(theta2, phi1)
p3 = get_pos(theta2, phi2)
p4 = get_pos(theta1, phi2)
# UV mapping (prosty sferyczny)
uv1 = (j / lon_segments, i / lat_segments)
uv2 = (j / lon_segments, (i + 1) / lat_segments)
uv3 = ((j + 1) / lon_segments, (i + 1) / lat_segments)
uv4 = ((j + 1) / lon_segments, i / lat_segments)
# Trójkąty
for vtx, uv in zip([p1, p2, p3], [uv1, uv2, uv3]):
verts.extend([*vtx, *color, *uv])
for vtx, uv in zip([p1, p3, p4], [uv1, uv3, uv4]):
verts.extend([*vtx, *color, *uv])
return Mesh(verts, basic_shader())
@staticmethod
def capsule(radius=0.25, height=1.0, segments=16, color=(1, 1, 1)):
verts = []
half = height / 2
# === Cylinder środkowy ===
for j in range(segments):
theta1 = 2 * np.pi * (j / segments)
theta2 = 2 * np.pi * ((j + 1) / segments)
x1, z1 = np.cos(theta1), np.sin(theta1)
x2, z2 = np.cos(theta2), np.sin(theta2)
p1 = (radius * x1, -half, radius * z1)
p2 = (radius * x1, half, radius * z1)
p3 = (radius * x2, half, radius * z2)
p4 = (radius * x2, -half, radius * z2)
uv = [(j / segments, 0.0), (j / segments, 0.5), ((j + 1) / segments, 0.5), ((j + 1) / segments, 0.0)]
for vtx, tex in zip([p1, p2, p3], [uv[0], uv[1], uv[2]]):
verts.extend([*vtx, *color, *tex])
for vtx, tex in zip([p1, p3, p4], [uv[0], uv[2], uv[3]]):
verts.extend([*vtx, *color, *tex])
# === Półsfera górna ===
for i in range(segments // 2):
theta1 = (np.pi / 2) * (i / (segments // 2))
theta2 = (np.pi / 2) * ((i + 1) / (segments // 2))
for j in range(segments):
phi1 = 2 * np.pi * (j / segments)
phi2 = 2 * np.pi * ((j + 1) / segments)
def pos(theta, phi):
return (
radius * np.cos(theta) * np.cos(phi),
radius * np.sin(theta) + half,
radius * np.cos(theta) * np.sin(phi)
)
p1 = pos(theta1, phi1)
p2 = pos(theta2, phi1)
p3 = pos(theta2, phi2)
p4 = pos(theta1, phi2)
uv1 = (j / segments, 0.5 + (i / (segments * 2)))
uv2 = (j / segments, 0.5 + ((i + 1) / (segments * 2)))
uv3 = ((j + 1) / segments, 0.5 + ((i + 1) / (segments * 2)))
uv4 = ((j + 1) / segments, 0.5 + (i / (segments * 2)))
for vtx, tex in zip([p1, p2, p3], [uv1, uv2, uv3]):
verts.extend([*vtx, *color, *tex])
for vtx, tex in zip([p1, p3, p4], [uv1, uv3, uv4]):
verts.extend([*vtx, *color, *tex])
# === Półsfera dolna ===
for i in range(segments // 2):
theta1 = (np.pi / 2) * (i / (segments // 2))
theta2 = (np.pi / 2) * ((i + 1) / (segments // 2))
for j in range(segments):
phi1 = 2 * np.pi * (j / segments)
phi2 = 2 * np.pi * ((j + 1) / segments)
def pos(theta, phi):
return (
radius * np.cos(theta) * np.cos(phi),
-radius * np.sin(theta) - half,
radius * np.cos(theta) * np.sin(phi)
)
p1 = pos(theta1, phi1)
p2 = pos(theta2, phi1)
p3 = pos(theta2, phi2)
p4 = pos(theta1, phi2)
uv1 = (j / segments, 0.5 - (i / (segments * 2)))
uv2 = (j / segments, 0.5 - ((i + 1) / (segments * 2)))
uv3 = ((j + 1) / segments, 0.5 - ((i + 1) / (segments * 2)))
uv4 = ((j + 1) / segments, 0.5 - (i / (segments * 2)))
# UWAGA: zamieniona kolejność rysowania — PRAWIDŁOWY winding
for vtx, tex in zip([p1, p3, p2], [uv1, uv3, uv2]):
verts.extend([*vtx, *color, *tex])
for vtx, tex in zip([p1, p4, p3], [uv1, uv4, uv3]):
verts.extend([*vtx, *color, *tex])
return Mesh(verts, basic_shader())
@staticmethod
def plane(size=1.0, color=(1, 1, 1)):
hs = size / 2
positions = [(-hs, 0, -hs), (hs, 0, -hs), (hs, 0, hs), (-hs, 0, hs)]
uvs = [(0, 0), (1, 0), (1, 1), (0, 1)]
indices = [(0, 1, 2), (0, 2, 3)] # front
back_indices = [(2, 1, 0), (3, 2, 0)] # back side (odwrócone)
verts = []
for face in indices + back_indices:
for idx in face:
verts.extend([*positions[idx], *color, *uvs[idx]])
return Mesh(verts, basic_shader())
@staticmethod
def water(
size=10.0,
resolution=64,
wave_speed=0.03,
wave_height=0.1,
wave_scale=(0.3, 0.4),
second_wave=True,
color=(0.2, 0.5, 1.0),
backface=True
):
half = size / 2
step = size / resolution
verts = []
for z in range(resolution):
for x in range(resolution):
x0 = -half + x * step
x1 = x0 + step
z0 = -half + z * step
z1 = z0 + step
u0, u1 = x / resolution, (x + 1) / resolution
v0, v1 = z / resolution, (z + 1) / resolution
pos = [(x0, 0.0, z0), (x1, 0.0, z0), (x1, 0.0, z1), (x0, 0.0, z1)]
uv = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)]
# Indeksy dla frontu i ewentualnie backface
indices = [(0, 1, 2), (0, 2, 3)]
if backface:
indices += [(2, 1, 0), (3, 2, 0)]
for a, b, c in indices:
for i in [a, b, c]:
verts.extend([*pos[i], *color, *uv[i]])
mesh = Mesh(verts, basic_shader())
mesh._water_size = size
mesh._water_res = resolution
mesh._water_time = 0.0
mesh._wave_speed = wave_speed
mesh._wave_height = wave_height
mesh._wave_scale = wave_scale
mesh._second_wave = second_wave
mesh._water_verts = np.array(verts, dtype=np.float32).reshape(-1, 8)
# Wysokości bazowe (y)
mesh._water_initial_y = mesh._water_verts[:, 1].copy()
# Dynamiczny buffer
glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo)
glBufferData(GL_ARRAY_BUFFER, mesh._water_verts.nbytes, mesh._water_verts.flatten(), GL_DYNAMIC_DRAW)
glBindBuffer(GL_ARRAY_BUFFER, 0)
def update_water():
mesh._water_time += mesh._wave_speed
verts = mesh._water_verts
X = verts[:, 0]
Z = verts[:, 2]
# Fale
verts[:, 1] = (
np.sin(X * mesh._wave_scale[0] + mesh._water_time) +
(np.cos(Z * mesh._wave_scale[1] + mesh._water_time) if mesh._second_wave else 0.0)
) * (mesh._wave_height * 0.5)
glBindBuffer(GL_ARRAY_BUFFER, mesh.vbo)
glBufferSubData(GL_ARRAY_BUFFER, 0, verts.nbytes, verts.flatten())
glBindBuffer(GL_ARRAY_BUFFER, 0)
mesh.update_water = update_water
return mesh
def update_water(self):
if not hasattr(self, '_original_vertices'):
return
verts = self._original_vertices.copy()
t = time.time() - self._start_time
for i in range(0, len(verts), 8):
x = verts[i]
z = verts[i + 2]
verts[i + 1] = math.sin(x * 0.5 + t) * 0.2 + math.cos(z * 0.5 + t) * 0.2
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
glBufferSubData(GL_ARRAY_BUFFER, 0, verts.nbytes, verts)
glBindBuffer(GL_ARRAY_BUFFER, 0)

131
my3dengine/shader.py Normal file
View File

@@ -0,0 +1,131 @@
from OpenGL.GL import *
ui_vertex_src = """
#version 330 core
layout(location = 0) in vec2 aPos;
layout(location = 1) in vec2 aUV;
out vec2 vUV;
void main() {
gl_Position = vec4(aPos, 0.0, 1.0);
vUV = aUV;
}
"""
ui_fragment_src = """
#version 330 core
in vec2 vUV;
uniform sampler2D uTexture;
uniform vec4 uColor;
uniform int useTexture;
out vec4 FragColor;
void main() {
if (useTexture == 1)
FragColor = texture(uTexture, vUV) * uColor;
else
FragColor = uColor;
}
"""
basic_vertex_src = """
#version 330 core
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aColor;
layout(location = 2) in vec2 aUV;
uniform mat4 uModel;
uniform mat4 uView;
uniform mat4 uProjection;
uniform vec2 uUVTiling;
uniform vec2 uUVOffset;
out vec3 vColor;
out vec2 vUV;
void main() {
gl_Position = uProjection * uView * uModel * vec4(aPos, 1.0);
vColor = aColor;
vUV = aUV * uUVTiling + uUVOffset;
}
"""
basic_fragment_src = """
#version 330 core
in vec3 vColor;
in vec2 vUV;
uniform sampler2D uTexture;
uniform int useTexture;
out vec4 FragColor;
void main() {
if (useTexture == 1)
FragColor = texture(uTexture, vUV);
else
FragColor = vec4(vColor, 1.0);
}
"""
def compile_shader(src, shader_type):
shader = glCreateShader(shader_type)
glShaderSource(shader, src)
glCompileShader(shader)
if glGetShaderiv(shader, GL_COMPILE_STATUS) != GL_TRUE:
error = glGetShaderInfoLog(shader).decode()
raise RuntimeError(f"Shader compile error: {error}")
return shader
def create_shader(vertex_src, fragment_src):
vert = compile_shader(vertex_src, GL_VERTEX_SHADER)
frag = compile_shader(fragment_src, GL_FRAGMENT_SHADER)
program = glCreateProgram()
glAttachShader(program, vert)
glAttachShader(program, frag)
glLinkProgram(program)
if glGetProgramiv(program, GL_LINK_STATUS) != GL_TRUE:
error = glGetProgramInfoLog(program).decode()
raise RuntimeError(f"Program link error: {error}")
glDeleteShader(vert)
glDeleteShader(frag)
return program
class ShaderProgram:
def __init__(self, vertex_src, fragment_src):
self.program = create_shader(vertex_src, fragment_src)
self.uniform_locations = {}
def use(self):
glUseProgram(self.program)
def get_uniform_location(self, name):
if name not in self.uniform_locations:
self.uniform_locations[name] = glGetUniformLocation(self.program, name)
return self.uniform_locations[name]
def set_uniform_1i(self, name, value):
glUniform1i(self.get_uniform_location(name), value)
def set_uniform_1f(self, name, value):
glUniform1f(self.get_uniform_location(name), value)
def set_uniform_vec2(self, name, vec):
glUniform2fv(self.get_uniform_location(name), 1, vec)
def set_uniform_vec3(self, name, vec):
glUniform3fv(self.get_uniform_location(name), 1, vec)
def set_uniform_mat4(self, name, mat, transpose=False):
glUniformMatrix4fv(self.get_uniform_location(name), 1, transpose, mat)
def create_ui_shader():
return ShaderProgram(ui_vertex_src, ui_fragment_src)
def basic_shader():
return ShaderProgram(basic_vertex_src, basic_fragment_src)

84
my3dengine/water.py Normal file
View File

@@ -0,0 +1,84 @@
import numpy as np
from OpenGL.GL import *
import ctypes
import time
class WaterMesh:
def __init__(self, size=10.0, resolution=100):
self.size = size
self.resolution = resolution
self.vertices = self.generate_vertices()
self.vertex_count = len(self.vertices) // 8
self.vao = glGenVertexArrays(1)
self.vbo = glGenBuffers(1)
glBindVertexArray(self.vao)
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
glBufferData(GL_ARRAY_BUFFER, self.vertices.nbytes, self.vertices, GL_DYNAMIC_DRAW)
stride = 8 * 4
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(0)) # pos
glEnableVertexAttribArray(0)
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(12)) # color
glEnableVertexAttribArray(1)
glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, stride, ctypes.c_void_p(24)) # uv
glEnableVertexAttribArray(2)
glBindBuffer(GL_ARRAY_BUFFER, 0)
glBindVertexArray(0)
self.start_time = time.time()
def generate_vertices(self):
step = self.size / self.resolution
verts = []
for z in range(self.resolution):
for x in range(self.resolution):
# cztery rogi jednego kwadratu
x0 = x * step - self.size / 2
x1 = (x + 1) * step - self.size / 2
z0 = z * step - self.size / 2
z1 = (z + 1) * step - self.size / 2
uv0 = (x / self.resolution, z / self.resolution)
uv1 = ((x + 1) / self.resolution, z / self.resolution)
uv2 = ((x + 1) / self.resolution, (z + 1) / self.resolution)
uv3 = (x / self.resolution, (z + 1) / self.resolution)
color = (0.2, 0.4, 1.0)
# dwa trójkąty
verts += [x0, 0, z0, *color, *uv0]
verts += [x1, 0, z0, *color, *uv1]
verts += [x1, 0, z1, *color, *uv2]
verts += [x0, 0, z0, *color, *uv0]
verts += [x1, 0, z1, *color, *uv2]
verts += [x0, 0, z1, *color, *uv3]
return np.array(verts, dtype=np.float32)
def update(self):
time_now = time.time() - self.start_time
verts = self.vertices
for i in range(0, len(verts), 8):
x = verts[i]
z = verts[i + 2]
verts[i + 1] = np.sin(x * 0.5 + time_now) * 0.2 + np.cos(z * 0.5 + time_now) * 0.2
glBindBuffer(GL_ARRAY_BUFFER, self.vbo)
glBufferSubData(GL_ARRAY_BUFFER, 0, verts.nbytes, verts)
glBindBuffer(GL_ARRAY_BUFFER, 0)
def draw(self, shader):
shader.use()
glBindVertexArray(self.vao)
glDrawArrays(GL_TRIANGLES, 0, self.vertex_count)
glBindVertexArray(0)
def destroy(self):
glDeleteVertexArrays(1, [self.vao])
glDeleteBuffers(1, [self.vbo])

23
setup.py Normal file
View File

@@ -0,0 +1,23 @@
from setuptools import setup, find_packages
setup(
name="twoja-nazwa-pakietu",
version="0.1.0",
author="Twoje Imię",
author_email="twoj.email@example.com",
description="Krótki opis twojej biblioteki",
long_description=open("README.md").read(),
long_description_content_type="text/markdown",
packages=find_packages(),
package_data={
'my3dengine': ['sdl2.dll'],
},
include_package_data=True,
python_requires=">=3.6",
install_requires=[],
classifiers=[
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
],
)