commit e5591e7dc8789fb0fe708a8599d472874225b3b6 Author: mmichlol Date: Wed Feb 4 21:31:57 2026 +0100 --global-fix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7b004e5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,194 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# 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/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# 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 \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4efe9c7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..aaefcd1 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# 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 \ No newline at end of file diff --git a/examples/assets/Untitled.mtl b/examples/assets/Untitled.mtl new file mode 100644 index 0000000..7ec6792 --- /dev/null +++ b/examples/assets/Untitled.mtl @@ -0,0 +1,2 @@ +# Blender 4.4.3 MTL File: 'None' +# www.blender.org diff --git a/examples/assets/Untitled.obj b/examples/assets/Untitled.obj new file mode 100644 index 0000000..cc356cb --- /dev/null +++ b/examples/assets/Untitled.obj @@ -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 diff --git a/examples/assets/bricksx64.png b/examples/assets/bricksx64.png new file mode 100644 index 0000000..71d18c9 Binary files /dev/null and b/examples/assets/bricksx64.png differ diff --git a/examples/assets/dark-atmosphere-background-008-312381.mp3 b/examples/assets/dark-atmosphere-background-008-312381.mp3 new file mode 100644 index 0000000..a611b83 Binary files /dev/null and b/examples/assets/dark-atmosphere-background-008-312381.mp3 differ diff --git a/examples/assets/dark-atmosphere-background-009-312380.mp3 b/examples/assets/dark-atmosphere-background-009-312380.mp3 new file mode 100644 index 0000000..f31bb9c Binary files /dev/null and b/examples/assets/dark-atmosphere-background-009-312380.mp3 differ diff --git a/examples/assets/sword-sound.mp3 b/examples/assets/sword-sound.mp3 new file mode 100644 index 0000000..1d812a3 Binary files /dev/null and b/examples/assets/sword-sound.mp3 differ diff --git a/examples/assets/texture.png b/examples/assets/texture.png new file mode 100644 index 0000000..c4c35ea Binary files /dev/null and b/examples/assets/texture.png differ diff --git a/examples/assets/water.png b/examples/assets/water.png new file mode 100644 index 0000000..9cfacfc Binary files /dev/null and b/examples/assets/water.png differ diff --git a/examples/game.py b/examples/game.py new file mode 100644 index 0000000..c3b6ad6 --- /dev/null +++ b/examples/game.py @@ -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() diff --git a/examples/test.py b/examples/test.py new file mode 100644 index 0000000..91bcca1 --- /dev/null +++ b/examples/test.py @@ -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() diff --git a/my3dengine/SDL2.dll b/my3dengine/SDL2.dll new file mode 100644 index 0000000..7e378b9 Binary files /dev/null and b/my3dengine/SDL2.dll differ diff --git a/my3dengine/UI.py b/my3dengine/UI.py new file mode 100644 index 0000000..e6ab531 --- /dev/null +++ b/my3dengine/UI.py @@ -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ę diff --git a/my3dengine/__init__.py b/my3dengine/__init__.py new file mode 100644 index 0000000..007b95b --- /dev/null +++ b/my3dengine/__init__.py @@ -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'] \ No newline at end of file diff --git a/my3dengine/audio.py b/my3dengine/audio.py new file mode 100644 index 0000000..27ffcbf --- /dev/null +++ b/my3dengine/audio.py @@ -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() diff --git a/my3dengine/camera.py b/my3dengine/camera.py new file mode 100644 index 0000000..a7972df --- /dev/null +++ b/my3dengine/camera.py @@ -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) diff --git a/my3dengine/core.py b/my3dengine/core.py new file mode 100644 index 0000000..7c1151d --- /dev/null +++ b/my3dengine/core.py @@ -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() diff --git a/my3dengine/key.py b/my3dengine/key.py new file mode 100644 index 0000000..8254d32 --- /dev/null +++ b/my3dengine/key.py @@ -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 \ No newline at end of file diff --git a/my3dengine/mesh.py b/my3dengine/mesh.py new file mode 100644 index 0000000..df58269 --- /dev/null +++ b/my3dengine/mesh.py @@ -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) diff --git a/my3dengine/shader.py b/my3dengine/shader.py new file mode 100644 index 0000000..d68658d --- /dev/null +++ b/my3dengine/shader.py @@ -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) diff --git a/my3dengine/water.py b/my3dengine/water.py new file mode 100644 index 0000000..28253fc --- /dev/null +++ b/my3dengine/water.py @@ -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]) \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c1d5057 --- /dev/null +++ b/setup.py @@ -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", + ], +) \ No newline at end of file