From fb3b098299483f66d6a69a693fe6732d8febb279 Mon Sep 17 00:00:00 2001 From: Exil Productions Date: Mon, 7 Apr 2025 22:07:52 +0200 Subject: [PATCH] Engine Code --- assets/cube.obj | 38 +++++++++++++ engine/__init__.py | 0 engine/camera.py | 85 ++++++++++++++++++++++++++++ engine/engine.py | 75 +++++++++++++++++++++++++ engine/light.py | 65 ++++++++++++++++++++++ engine/mesh.py | 128 ++++++++++++++++++++++++++++++++++++++++++ engine/mesh_utils.py | 91 ++++++++++++++++++++++++++++++ engine/model.py | 117 +++++++++++++++++++++++++++++++++++++++ engine/obj_loader.py | 32 +++++++++++ engine/scene.py | 37 +++++++++++++ engine/shader.py | 69 +++++++++++++++++++++++ engine/texture.py | 30 ++++++++++ engine/window.py | 119 +++++++++++++++++++++++++++++++++++++++ main.py | 129 +++++++++++++++++++++++++++++++++++++++++++ shaders/phong.frag | 111 +++++++++++++++++++++++++++++++++++++ shaders/phong.vert | 21 +++++++ 16 files changed, 1147 insertions(+) create mode 100644 assets/cube.obj create mode 100644 engine/__init__.py create mode 100644 engine/camera.py create mode 100644 engine/engine.py create mode 100644 engine/light.py create mode 100644 engine/mesh.py create mode 100644 engine/mesh_utils.py create mode 100644 engine/model.py create mode 100644 engine/obj_loader.py create mode 100644 engine/scene.py create mode 100644 engine/shader.py create mode 100644 engine/texture.py create mode 100644 engine/window.py create mode 100644 main.py create mode 100644 shaders/phong.frag create mode 100644 shaders/phong.vert diff --git a/assets/cube.obj b/assets/cube.obj new file mode 100644 index 0000000..41ed9e6 --- /dev/null +++ b/assets/cube.obj @@ -0,0 +1,38 @@ +# Blender 4.4.0 +# www.blender.org +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 -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 +vn -0.0000 1.0000 -0.0000 +vn -0.0000 -0.0000 1.0000 +vn -1.0000 -0.0000 -0.0000 +vn -0.0000 -1.0000 -0.0000 +vn 1.0000 -0.0000 -0.0000 +vn -0.0000 -0.0000 -1.0000 +vt 0.625000 0.500000 +vt 0.875000 0.500000 +vt 0.875000 0.750000 +vt 0.625000 0.750000 +vt 0.375000 0.750000 +vt 0.625000 1.000000 +vt 0.375000 1.000000 +vt 0.375000 0.000000 +vt 0.625000 0.000000 +vt 0.625000 0.250000 +vt 0.375000 0.250000 +vt 0.125000 0.500000 +vt 0.375000 0.500000 +vt 0.125000 0.750000 +s 0 +f 1/1/1 5/2/1 7/3/1 3/4/1 +f 4/5/2 3/4/2 7/6/2 8/7/2 +f 8/8/3 7/9/3 5/10/3 6/11/3 +f 6/12/4 2/13/4 4/5/4 8/14/4 +f 2/13/5 1/1/5 3/4/5 4/5/5 +f 6/11/6 5/10/6 1/1/6 2/13/6 diff --git a/engine/__init__.py b/engine/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/engine/camera.py b/engine/camera.py new file mode 100644 index 0000000..9caac63 --- /dev/null +++ b/engine/camera.py @@ -0,0 +1,85 @@ +import numpy as np +import pyrr + + +class Camera: + def __init__(self, position=np.array([0.0, 0.0, 3.0], dtype=np.float32), + target=np.array([0.0, 0.0, 0.0], dtype=np.float32), + up=np.array([0.0, 1.0, 0.0], dtype=np.float32), + yaw=-90.0, pitch=0.0): + self.position = np.array(position, dtype=np.float32) + self.world_up = np.array(up, dtype=np.float32) + self.yaw = yaw + self.pitch = pitch + self.zoom = 45.0 + + if target is not None: + target = np.array(target, dtype=np.float32) + direction = target - self.position + direction = direction / np.linalg.norm(direction) + self.yaw = np.degrees(np.arctan2(direction[2], direction[0])) + self.pitch = np.degrees(np.arcsin(direction[1])) + + self._update_camera_vectors() + + def _update_camera_vectors(self): + front = np.array([ + np.cos(np.radians(self.yaw)) * np.cos(np.radians(self.pitch)), + np.sin(np.radians(self.pitch)), + np.sin(np.radians(self.yaw)) * np.cos(np.radians(self.pitch)) + ], dtype=np.float32) + self.front = front / np.linalg.norm(front) + + self.right = np.cross(self.front, self.world_up) + self.right = self.right / np.linalg.norm(self.right) + + self.up = np.cross(self.right, self.front) + self.up = self.up / np.linalg.norm(self.up) + + def get_view_matrix(self): + return pyrr.matrix44.create_look_at(self.position, self.position + self.front, self.up) + + def get_projection_matrix(self, aspect_ratio): + return pyrr.matrix44.create_perspective_projection( + self.zoom, aspect_ratio, 0.1, 100.0 + ) + + def set_position(self, position): + self.position = np.array(position, dtype=np.float32) + self._update_camera_vectors() + + + # moving camera, is extremely yanky but does the job + def move(self, direction, amount): + if direction == "forward": + self.position += self.front * amount + elif direction == "backward": + self.position -= self.front * amount + elif direction == "left": + self.position -= self.right * amount + elif direction == "right": + self.position += self.right * amount + elif direction == "up": + self.position += self.up * amount + elif direction == "down": + self.position -= self.up * amount + + def rotate(self, yaw_offset, pitch_offset, constrain_pitch=True): + self.yaw += yaw_offset + self.pitch += pitch_offset + + if constrain_pitch: + if self.pitch > 89.0: + self.pitch = 89.0 + if self.pitch < -89.0: + self.pitch = -89.0 + + self._update_camera_vectors() + + # basically the fov + def set_zoom(self, zoom): + self.zoom = zoom + if self.zoom < 1.0: + self.zoom = 1.0 + if self.zoom > 45.0: + self.zoom = 45.0 \ No newline at end of file diff --git a/engine/engine.py b/engine/engine.py new file mode 100644 index 0000000..adf19f4 --- /dev/null +++ b/engine/engine.py @@ -0,0 +1,75 @@ +import os +from .window import Window +from .scene import Scene +from .shader import Shader + + +class Engine: + def __init__(self, width=800, height=600, title="3D Game Render Engine"): + self.window = Window(width, height, title) + self.scene = Scene() + self.running = False + self.last_time = 0 + self.delta_time = 0 + + self.default_shader = None + self._init_default_shader() + #get shaders from shader dir + def _init_default_shader(self): + shaders_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "shaders") + vert_shader = os.path.join(shaders_dir, "phong.vert") + frag_shader = os.path.join(shaders_dir, "phong.frag") + self.default_shader = Shader(vert_shader, frag_shader) + + def start(self): + import glfw + self.running = True + self.last_time = glfw.get_time() + + while self.running and not self.window.should_close(): + current_time = glfw.get_time() + self.delta_time = current_time - self.last_time + self.last_time = current_time + self.window.poll_events() + self.update(self.delta_time) + self.render() + self.window.swap_buffers() + + self.shutdown() + + def stop(self): + self.running = False + + #only gets used by application + def update(self, delta_time): + pass + + def render(self): + self.window.clear(0.1, 0.1, 0.1, 1.0) + camera = self.scene.camera + if not camera: + return + + aspect_ratio = self.window.get_aspect_ratio() + view_matrix = camera.get_view_matrix() + projection_matrix = camera.get_projection_matrix(aspect_ratio) + + self.default_shader.use() + self.default_shader.set_mat4("view", view_matrix) + self.default_shader.set_mat4("projection", projection_matrix) + self.default_shader.set_vec3("viewPos", camera.position) + self.scene.render(self.default_shader) + + def shutdown(self): + self.window.terminate() + + def get_delta_time(self): + return self.delta_time + + def get_window(self): + """Get the window""" + return self.window + + def get_scene(self): + """Get the scene""" + return self.scene \ No newline at end of file diff --git a/engine/light.py b/engine/light.py new file mode 100644 index 0000000..9711049 --- /dev/null +++ b/engine/light.py @@ -0,0 +1,65 @@ +import numpy as np + +class Light: + def __init__(self, ambient, diffuse, specular): + self.ambient = ambient + self.diffuse = diffuse + self.specular = specular + + def apply(self, shader, index): + pass + +#here comes the sun duh duh duh duh +class DirectionalLight(Light): + def __init__(self, direction, ambient, diffuse, specular): + super().__init__(ambient, diffuse, specular) + self.direction = direction + + def apply(self, shader, index): + shader.set_vec3(f"dirLight.direction", self.direction) + shader.set_vec3(f"dirLight.ambient", self.ambient) + shader.set_vec3(f"dirLight.diffuse", self.diffuse) + shader.set_vec3(f"dirLight.specular", self.specular) + +#point light implementation +class PointLight(Light): + def __init__(self, position, ambient, diffuse, specular, constant, linear, quadratic): + super().__init__(ambient, diffuse, specular) + self.position = position + self.constant = constant + self.linear = linear + self.quadratic = quadratic + + def apply(self, shader, index): + shader.set_vec3(f"pointLights[{index}].position", self.position) + shader.set_vec3(f"pointLights[{index}].ambient", self.ambient) + shader.set_vec3(f"pointLights[{index}].diffuse", self.diffuse) + shader.set_vec3(f"pointLights[{index}].specular", self.specular) + shader.set_float(f"pointLights[{index}].constant", self.constant) + shader.set_float(f"pointLights[{index}].linear", self.linear) + shader.set_float(f"pointLights[{index}].quadratic", self.quadratic) + + +class SpotLight(Light): + def __init__(self, position, direction, ambient, diffuse, specular, + constant, linear, quadratic, cut_off, outer_cut_off): + super().__init__(ambient, diffuse, specular) + self.position = position + self.direction = direction + self.constant = constant + self.linear = linear + self.quadratic = quadratic + self.cut_off = cut_off + self.outer_cut_off = outer_cut_off + + def apply(self, shader, index): + shader.set_vec3(f"spotLight.position", self.position) + shader.set_vec3(f"spotLight.direction", self.direction) + shader.set_vec3(f"spotLight.ambient", self.ambient) + shader.set_vec3(f"spotLight.diffuse", self.diffuse) + shader.set_vec3(f"spotLight.specular", self.specular) + shader.set_float(f"spotLight.constant", self.constant) + shader.set_float(f"spotLight.linear", self.linear) + shader.set_float(f"spotLight.quadratic", self.quadratic) + shader.set_float(f"spotLight.cutOff", self.cut_off) + shader.set_float(f"spotLight.outerCutOff", self.outer_cut_off) \ No newline at end of file diff --git a/engine/mesh.py b/engine/mesh.py new file mode 100644 index 0000000..f8731a2 --- /dev/null +++ b/engine/mesh.py @@ -0,0 +1,128 @@ +from OpenGL.GL import * +import numpy as np +import open3d as o3d +import ctypes + + +class Mesh: + def __init__(self, o3d_mesh=None, vertices=None, indices=None, textures=None): + self.textures = textures if textures else [] + self.VAO = glGenVertexArrays(1) + self.VBO = glGenBuffers(1) + self.EBO = glGenBuffers(1) + + #if we use a mesh from open3d we should use vertices and indices + + if o3d_mesh is not None: + self._init_from_open3d(o3d_mesh) + #if not just use the vertices and indices + elif vertices is not None and indices is not None: + self._init_from_arrays(vertices, indices) + else: + raise ValueError("Either an Open3D mesh or vertices and indices must be provided") + + def _init_from_open3d(self, o3d_mesh): + if not o3d_mesh.has_vertex_normals(): + o3d_mesh.compute_vertex_normals() + vertices = np.asarray(o3d_mesh.vertices) + normals = np.asarray(o3d_mesh.vertex_normals) + + if o3d_mesh.has_triangle_uvs(): + uvs = np.asarray(o3d_mesh.triangle_uvs) + #this is only some workaround though, still need to implement this right + if len(uvs) > 0: + tex_coords = np.zeros((len(vertices), 2), dtype=np.float32) + for i, triangle in enumerate(o3d_mesh.triangles): + for j in range(3): + tex_coords[triangle[j]] = uvs[i * 3 + j] + else: + tex_coords = np.zeros((len(vertices), 2), dtype=np.float32) + else: + tex_coords = np.zeros((len(vertices), 2), dtype=np.float32) + vertex_data = np.zeros((len(vertices), 8), dtype=np.float32) + vertex_data[:, 0:3] = vertices + vertex_data[:, 3:6] = normals + vertex_data[:, 6:8] = tex_coords + + indices = np.asarray(o3d_mesh.triangles).flatten() + + self._init_from_arrays(vertex_data, indices) + + self.o3d_mesh = o3d_mesh + + def _init_from_arrays(self, vertices, indices): + self.vertices = vertices + self.indices = indices + glBindVertexArray(self.VAO) + glBindBuffer(GL_ARRAY_BUFFER, self.VBO) + glBufferData(GL_ARRAY_BUFFER, self.vertices.nbytes, self.vertices, GL_STATIC_DRAW) + + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, self.EBO) + glBufferData(GL_ELEMENT_ARRAY_BUFFER, self.indices.nbytes, self.indices, GL_STATIC_DRAW) + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 8 * 4, ctypes.c_void_p(0)) + glEnableVertexAttribArray(0) + + glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 8 * 4, ctypes.c_void_p(3 * 4)) + glEnableVertexAttribArray(1) + + glVertexAttribPointer(2, 2, GL_FLOAT, GL_FALSE, 8 * 4, ctypes.c_void_p(6 * 4)) + glEnableVertexAttribArray(2) + + glBindVertexArray(0) + + @staticmethod + def create_box(width=1.0, height=1.0, depth=1.0): + box = o3d.geometry.TriangleMesh.create_box(width=width, height=height, depth=depth) + box.compute_vertex_normals() + return Mesh(o3d_mesh=box) + + @staticmethod + def create_sphere(radius=1.0, resolution=20): + sphere = o3d.geometry.TriangleMesh.create_sphere(radius=radius, resolution=resolution) + sphere.compute_vertex_normals() + return Mesh(o3d_mesh=sphere) + + @staticmethod + def create_cylinder(radius=1.0, height=2.0, resolution=20, split=4): + cylinder = o3d.geometry.TriangleMesh.create_cylinder(radius=radius, height=height, resolution=resolution, + split=split) + cylinder.compute_vertex_normals() + return Mesh(o3d_mesh=cylinder) + + @staticmethod + def create_cone(radius=1.0, height=2.0, resolution=20, split=1): + cone = o3d.geometry.TriangleMesh.create_cone(radius=radius, height=height, resolution=resolution, split=split) + cone.compute_vertex_normals() + return Mesh(o3d_mesh=cone) + + @staticmethod + def create_torus(torus_radius=1.0, tube_radius=0.2, radial_resolution=30, tubular_resolution=20): + torus = o3d.geometry.TriangleMesh.create_torus(torus_radius=torus_radius, tube_radius=tube_radius, + radial_resolution=radial_resolution, + tubular_resolution=tubular_resolution) + torus.compute_vertex_normals() + return Mesh(o3d_mesh=torus) + + def draw(self, shader): + diffuse_nr = 1 + specular_nr = 1 + + for i, texture in enumerate(self.textures): + glActiveTexture(GL_TEXTURE0 + i) + number = "" + name = texture.type + if name == "texture_diffuse": + number = str(diffuse_nr) + diffuse_nr += 1 + elif name == "texture_specular": + number = str(specular_nr) + specular_nr += 1 + + shader.set_int(f"material.{name}{number}", i) + + glBindTexture(GL_TEXTURE_2D, texture.id) + + glBindVertexArray(self.VAO) + glDrawElements(GL_TRIANGLES, len(self.indices), GL_UNSIGNED_INT, None) + glBindVertexArray(0) + glActiveTexture(GL_TEXTURE0) \ No newline at end of file diff --git a/engine/mesh_utils.py b/engine/mesh_utils.py new file mode 100644 index 0000000..b7bc5d5 --- /dev/null +++ b/engine/mesh_utils.py @@ -0,0 +1,91 @@ +import open3d as o3d +import numpy as np +from .mesh import Mesh + + +def mesh_from_point(points, normals=None, colors=None): + pcd = o3d.geometry.PointCloud() + pcd.points = o3d.utility.Vector3dVector(points) + if normals is not None: + pcd.normals = o3d.utility.Vector3dVector(normals) + else: + pcd.estimate_normals() + if colors is not None: + pcd.colors = o3d.utility.Vector3dVector(colors) + mesh, _ = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson(pcd, depth=8) + return Mesh(o3d_mesh=mesh) + + +def merge_meshes(meshes): + if not meshes: + return None + + o3d_meshes = [] + for mesh in meshes: + if hasattr(mesh, 'o3d_mesh'): + o3d_meshes.append(mesh.o3d_mesh) + else: + o3d_meshes.append(mesh) + + merged_mesh = o3d_meshes[0] + for mesh in o3d_meshes[1:]: + merged_mesh += mesh + if not merged_mesh.has_vertex_normals(): + merged_mesh.compute_vertex_normals() + + return Mesh(o3d_mesh=merged_mesh) + +def subdivide_mesh(mesh, iterations=1): + if hasattr(mesh, 'o3d_mesh'): + o3d_mesh = mesh.o3d_mesh + else: + o3d_mesh = mesh + subdivided_mesh = o3d_mesh.subdivide_midpoint(number_of_iterations=iterations) + + if not subdivided_mesh.has_vertex_normals(): + subdivided_mesh.compute_vertex_normals() + return Mesh(o3d_mesh=subdivided_mesh) + +#basically decimate +def simplify_mesh(mesh, target_triangles): + if hasattr(mesh, 'o3d_mesh'): + o3d_mesh = mesh.o3d_mesh + else: + o3d_mesh = mesh + + simplified_mesh = o3d_mesh.simplify_quadric_decimation(target_number_of_triangles=target_triangles) + + if not simplified_mesh.has_vertex_normals(): + simplified_mesh.compute_vertex_normals() + + return Mesh(o3d_mesh=simplified_mesh) + +def create_terrain(width, length, height_function, resolution=100): + x = np.linspace(-width / 2, width / 2, resolution) + z = np.linspace(-length / 2, length / 2, resolution) + X, Z = np.meshgrid(x, z) + Y = np.zeros_like(X) + for i in range(resolution): + for j in range(resolution): + Y[i, j] = height_function(X[i, j], Z[i, j]) + vertices = [] + for i in range(resolution): + for j in range(resolution): + vertices.append([X[i, j], Y[i, j], Z[i, j]]) + triangles = [] + for i in range(resolution - 1): + for j in range(resolution - 1): + idx1 = i * resolution + j + idx2 = i * resolution + (j + 1) + idx3 = (i + 1) * resolution + j + idx4 = (i + 1) * resolution + (j + 1) + + triangles.append([idx1, idx2, idx3]) + triangles.append([idx2, idx4, idx3]) + + o3d_mesh = o3d.geometry.TriangleMesh() + o3d_mesh.vertices = o3d.utility.Vector3dVector(vertices) + o3d_mesh.triangles = o3d.utility.Vector3iVector(triangles) + o3d_mesh.compute_vertex_normals() + + return Mesh(o3d_mesh=o3d_mesh) \ No newline at end of file diff --git a/engine/model.py b/engine/model.py new file mode 100644 index 0000000..f475968 --- /dev/null +++ b/engine/model.py @@ -0,0 +1,117 @@ +import numpy as np +import pyrr +import open3d as o3d +from .mesh import Mesh +import os +from .obj_loader import load_obj + + +class Model: + def __init__(self, path=None, mesh=None): + self.meshes = [] + self.textures_loaded = {} + self.position = np.array([0.0, 0.0, 0.0]) + self.rotation = np.array([0.0, 0.0, 0.0]) + self.scale = np.array([1.0, 1.0, 1.0]) + self.color = np.array([0.8, 0.8, 0.8]) + self.shininess = 32.0 + + if path: + self._load_model(path) + elif mesh: + self.meshes.append(mesh) + else: + self._create_cube() + + def _load_model(self, path): + directory = os.path.dirname(path) + if path.lower().endswith('.obj'): + mesh = load_obj(path, directory) + self.meshes.append(mesh) + elif path.lower().endswith('.ply'): + o3d_mesh = o3d.io.read_triangle_mesh(path) + if not o3d_mesh.has_vertex_normals(): + o3d_mesh.compute_vertex_normals() + mesh = Mesh(o3d_mesh=o3d_mesh) + self.meshes.append(mesh) + elif path.lower().endswith('.stl'): + o3d_mesh = o3d.io.read_triangle_mesh(path) + if not o3d_mesh.has_vertex_normals(): + o3d_mesh.compute_vertex_normals() + mesh = Mesh(o3d_mesh=o3d_mesh) + self.meshes.append(mesh) + else: + self._create_cube() + + def _create_cube(self): + mesh = Mesh.create_box() + self.meshes.append(mesh) + + @staticmethod + def create_box(width=1.0, height=1.0, depth=1.0): + mesh = Mesh.create_box(width, height, depth) + return Model(mesh=mesh) + + @staticmethod + def create_sphere(radius=1.0, resolution=20): + mesh = Mesh.create_sphere(radius, resolution) + return Model(mesh=mesh) + + @staticmethod + def create_cylinder(radius=1.0, height=2.0, resolution=20, split=4): + mesh = Mesh.create_cylinder(radius, height, resolution, split) + return Model(mesh=mesh) + + @staticmethod + def create_cone(radius=1.0, height=2.0, resolution=20, split=1): + mesh = Mesh.create_cone(radius, height, resolution, split) + return Model(mesh=mesh) + + @staticmethod + def create_torus(torus_radius=1.0, tube_radius=0.2, radial_resolution=30, tubular_resolution=20): + mesh = Mesh.create_torus(torus_radius, tube_radius, radial_resolution, tubular_resolution) + return Model(mesh=mesh) + + def set_color(self, color): + self.color = np.array(color, dtype=np.float32) + + def set_shininess(self, shininess): + self.shininess = shininess + + def draw(self, shader): + model_matrix = np.identity(4, dtype=np.float32) + model_matrix = pyrr.matrix44.create_from_scale(self.scale, dtype=np.float32) + rotation_x = pyrr.matrix44.create_from_x_rotation(np.radians(self.rotation[0]), dtype=np.float32) + rotation_y = pyrr.matrix44.create_from_y_rotation(np.radians(self.rotation[1]), dtype=np.float32) + rotation_z = pyrr.matrix44.create_from_z_rotation(np.radians(self.rotation[2]), dtype=np.float32) + rotation_matrix = pyrr.matrix44.multiply(rotation_x, rotation_y) + rotation_matrix = pyrr.matrix44.multiply(rotation_matrix, rotation_z) + model_matrix = pyrr.matrix44.multiply(model_matrix, rotation_matrix) + translation = pyrr.matrix44.create_from_translation(self.position, dtype=np.float32) + model_matrix = pyrr.matrix44.multiply(model_matrix, translation) + shader.set_mat4("model", model_matrix) + shader.set_float("material.shininess", self.shininess) + shader.set_vec3("objectColor", self.color) + + for mesh in self.meshes: + has_textures = len(mesh.textures) > 0 + shader.set_bool("hasTexture", has_textures) + + mesh.draw(shader) + + def set_position(self, position): + self.position = position + + def set_rotation(self, rotation): + self.rotation = rotation + + def set_scale(self, scale): + self.scale = scale + + def rotate(self, axis, angle): + if axis[0] > 0: + self.rotation[0] += np.degrees(angle) + if axis[1] > 0: + self.rotation[1] += np.degrees(angle) + if axis[2] > 0: + self.rotation[2] += np.degrees(angle) \ No newline at end of file diff --git a/engine/obj_loader.py b/engine/obj_loader.py new file mode 100644 index 0000000..8739351 --- /dev/null +++ b/engine/obj_loader.py @@ -0,0 +1,32 @@ +import numpy as np +import open3d as o3d +from .mesh import Mesh +from .texture import Texture +import os + + +def load_obj(file_path, directory=""): + mesh = o3d.io.read_triangle_mesh(file_path) + if not mesh.has_vertex_normals(): + mesh.compute_vertex_normals() + textures = [] + mtl_path = file_path.replace('.obj', '.mtl') + if os.path.exists(mtl_path): + with open(mtl_path, 'r') as f: + current_material = None + for line in f: + if line.startswith('newmtl'): + current_material = line.split()[1] + elif line.startswith('map_Kd') and current_material: + tex_path = os.path.join(directory, line.split()[1]) + if os.path.exists(tex_path): + diffuse_texture = Texture(tex_path, "texture_diffuse") + textures.append(diffuse_texture) + elif line.startswith('map_Ks') and current_material: + tex_path = os.path.join(directory, line.split()[1]) + if os.path.exists(tex_path): + specular_texture = Texture(tex_path, "texture_specular") + textures.append(specular_texture) + + # Create and return the mesh with textures + return Mesh(o3d_mesh=mesh, textures=textures) \ No newline at end of file diff --git a/engine/scene.py b/engine/scene.py new file mode 100644 index 0000000..87e3472 --- /dev/null +++ b/engine/scene.py @@ -0,0 +1,37 @@ +from engine.light import PointLight + + +class Scene: + def __init__(self): + self.models = [] + self.lights = [] + self.camera = None + + def add_model(self, model): + self.models.append(model) + + def add_light(self, light): + self.lights.append(light) + + def set_camera(self, camera): + self.camera = camera + + def render(self, shader): + if not self.camera: + raise ValueError("Camera not set in scene") + #TODO: Actually do something with this boolean + dir_light_set = False + point_light_count = 0 + + for light in self.lights: + if isinstance(light, PointLight): + light.apply(shader, point_light_count) + point_light_count += 1 + else: + light.apply(shader, 0) + dir_light_set = True + + shader.set_int("numPointLights", point_light_count) + + for model in self.models: + model.draw(shader) \ No newline at end of file diff --git a/engine/shader.py b/engine/shader.py new file mode 100644 index 0000000..af33c0c --- /dev/null +++ b/engine/shader.py @@ -0,0 +1,69 @@ +from OpenGL.GL import * + +class Shader: + def __init__(self, vertex_path, fragment_path): + with open(vertex_path, 'r') as file: + vertex_source = file.read() + + with open(fragment_path, 'r') as file: + fragment_source = file.read() + + vertex_shader = self._compile_shader(vertex_source, GL_VERTEX_SHADER) + fragment_shader = self._compile_shader(fragment_source, GL_FRAGMENT_SHADER) + + self.program = glCreateProgram() + glAttachShader(self.program, vertex_shader) + glAttachShader(self.program, fragment_shader) + glLinkProgram(self.program) + + if not glGetProgramiv(self.program, GL_LINK_STATUS): + info_log = glGetProgramInfoLog(self.program) + glDeleteProgram(self.program) + glDeleteShader(vertex_shader) + glDeleteShader(fragment_shader) + raise RuntimeError(f"Shader program linking failed: {info_log}") + glDeleteShader(vertex_shader) + glDeleteShader(fragment_shader) + + def _compile_shader(self, source, shader_type): + shader = glCreateShader(shader_type) + glShaderSource(shader, source) + glCompileShader(shader) + + if not glGetShaderiv(shader, GL_COMPILE_STATUS): + info_log = glGetShaderInfoLog(shader) + glDeleteShader(shader) + shader_type_name = "vertex" if shader_type == GL_VERTEX_SHADER else "fragment" + raise RuntimeError(f"{shader_type_name} shader compilation failed: {info_log}") + + return shader + + def use(self): + glUseProgram(self.program) + + def set_bool(self, name, value): + glUniform1i(glGetUniformLocation(self.program, name), int(value)) + + def set_int(self, name, value): + glUniform1i(glGetUniformLocation(self.program, name), value) + + def set_float(self, name, value): + glUniform1f(glGetUniformLocation(self.program, name), value) + + def set_vec2(self, name, value): + glUniform2fv(glGetUniformLocation(self.program, name), 1, value) + + def set_vec3(self, name, value): + glUniform3fv(glGetUniformLocation(self.program, name), 1, value) + + def set_vec4(self, name, value): + glUniform4fv(glGetUniformLocation(self.program, name), 1, value) + + def set_mat2(self, name, value): + glUniformMatrix2fv(glGetUniformLocation(self.program, name), 1, GL_FALSE, value) + + def set_mat3(self, name, value): + glUniformMatrix3fv(glGetUniformLocation(self.program, name), 1, GL_FALSE, value) + + def set_mat4(self, name, value): + glUniformMatrix4fv(glGetUniformLocation(self.program, name), 1, GL_FALSE, value) \ No newline at end of file diff --git a/engine/texture.py b/engine/texture.py new file mode 100644 index 0000000..9a28bfd --- /dev/null +++ b/engine/texture.py @@ -0,0 +1,30 @@ +from OpenGL.GL import * +from PIL import Image +import numpy as np + + +class Texture: + def __init__(self, path, type_name="texture_diffuse"): + self.id = glGenTextures(1) + self.type = type_name + self.path = path + image = Image.open(path) + image = image.transpose(Image.FLIP_TOP_BOTTOM) + img_data = np.array(list(image.getdata()), np.uint8) + if image.mode == "RGB": + img_format = GL_RGB + elif image.mode == "RGBA": + img_format = GL_RGBA + else: + raise ValueError(f"Unsupported image format: {image.mode}") + glBindTexture(GL_TEXTURE_2D, self.id) + 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) + glTexImage2D( + GL_TEXTURE_2D, 0, img_format, image.width, image.height, 0, + img_format, GL_UNSIGNED_BYTE, img_data + ) + glGenerateMipmap(GL_TEXTURE_2D) + image.close() \ No newline at end of file diff --git a/engine/window.py b/engine/window.py new file mode 100644 index 0000000..7fbd09c --- /dev/null +++ b/engine/window.py @@ -0,0 +1,119 @@ +import glfw +from OpenGL.GL import * + + +class Window: + def __init__(self, width, height, title): + if not glfw.init(): + raise Exception("Failed to initialize GLFW") + + glfw.window_hint(glfw.CONTEXT_VERSION_MAJOR, 3) + glfw.window_hint(glfw.CONTEXT_VERSION_MINOR, 3) + glfw.window_hint(glfw.OPENGL_PROFILE, glfw.OPENGL_CORE_PROFILE) + self.window = glfw.create_window(width, height, title, None, None) + if not self.window: + glfw.terminate() + raise Exception("Failed to create GLFW window") + glfw.make_context_current(self.window) + glfw.set_framebuffer_size_callback(self.window, self._framebuffer_size_callback) + glEnable(GL_DEPTH_TEST) + + glEnable(GL_CULL_FACE) + glCullFace(GL_BACK) + + self.width = width + self.height = height + + self.last_x = width / 2 + self.last_y = height / 2 + self.first_mouse = True + self.keys = {} + + self.x_offset = 0 + self.y_offset = 0 + + glfw.set_cursor_pos_callback(self.window, self._mouse_callback) + glfw.set_key_callback(self.window, self._key_callback) + glfw.set_scroll_callback(self.window, self._scroll_callback) + + self.mouse_buttons = {} + glfw.set_mouse_button_callback(self.window, self._mouse_button_callback) + + self.scroll_offset = 0 + + def _framebuffer_size_callback(self, window, width, height): + glViewport(0, 0, width, height) + self.width = width + self.height = height + + def _mouse_callback(self, window, xpos, ypos): + if self.first_mouse: + self.last_x = xpos + self.last_y = ypos + self.first_mouse = False + + self.x_offset = xpos - self.last_x + self.y_offset = self.last_y - ypos + + self.last_x = xpos + self.last_y = ypos + + def _key_callback(self, window, key, scancode, action, mods): + if action == glfw.PRESS: + self.keys[key] = True + elif action == glfw.RELEASE: + self.keys[key] = False + + def _mouse_button_callback(self, window, button, action, mods): + if action == glfw.PRESS: + self.mouse_buttons[button] = True + elif action == glfw.RELEASE: + self.mouse_buttons[button] = False + + def _scroll_callback(self, window, xoffset, yoffset): + self.scroll_offset = yoffset + + def is_key_pressed(self, key): + return key in self.keys and self.keys[key] + + def is_mouse_button_pressed(self, button): + return button in self.mouse_buttons and self.mouse_buttons[button] + + def get_mouse_position(self): + return glfw.get_cursor_pos(self.window) + + def get_mouse_offset(self): + offset = (self.x_offset, self.y_offset) + self.x_offset = 0 + self.y_offset = 0 + return offset + + def get_scroll_offset(self): + offset = self.scroll_offset + self.scroll_offset = 0 + return offset + + def poll_events(self): + glfw.poll_events() + + def should_close(self): + return glfw.window_should_close(self.window) + + def set_should_close(self, value): + glfw.set_window_should_close(self.window, value) + + def clear(self, r, g, b, a): + glClearColor(r, g, b, a) + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) + + def swap_buffers(self): + glfw.swap_buffers(self.window) + + def terminate(self): + glfw.terminate() + + def get_aspect_ratio(self): + return self.width / self.height + + def set_cursor_mode(self, mode): + glfw.set_input_mode(self.window, glfw.CURSOR, mode) \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..eb9eeae --- /dev/null +++ b/main.py @@ -0,0 +1,129 @@ +import numpy as np +import os +import glfw +from engine.engine import Engine +from engine.camera import Camera +from engine.model import Model +from engine.light import DirectionalLight, PointLight + + +class GameApp(Engine): + def __init__(self, width=800, height=600, title="3D Game Demo"): + super().__init__(width, height, title) + + self.camera = Camera(position=np.array([0, 2, 5]), target=np.array([0, 0, 0])) + self.scene.set_camera(self.camera) + + self.camera_speed = 6 + self.mouse_sensitivity = 0.2 + + self.window.set_cursor_mode(glfw.CURSOR_DISABLED) + dir_light = DirectionalLight( + direction=np.array([-0.2, -1.0, -0.3]), + ambient=np.array([0.2, 0.2, 0.2]), + diffuse=np.array([0.5, 0.5, 0.5]), + specular=np.array([1.0, 1.0, 1.0]) + ) + self.scene.add_light(dir_light) + + point_light = PointLight( + position=np.array([1.0, 1.0, 1.0]), + ambient=np.array([0.1, 0.1, 0.1]), + diffuse=np.array([0.8, 0.8, 0.8]), + specular=np.array([1.0, 1.0, 1.0]), + constant=1.0, + linear=0.09, + quadratic=0.032 + ) + self.scene.add_light(point_light) + + self.create_models() + + def create_models(self): + cube_model = Model.create_box(1.0, 1.0, 1.0) + cube_model.set_position(np.array([-1.5, 0, 0])) + cube_model.set_color([0.8, 0.2, 0.2]) # Red + cube_model.set_shininess(64.0) + self.scene.add_model(cube_model) + self.cube_model = cube_model + + sphere_model = Model.create_sphere(0.5, 32) + sphere_model.set_position(np.array([0, 0, 0])) + sphere_model.set_color([0.2, 0.8, 0.2]) # Green + sphere_model.set_shininess(128.0) + self.scene.add_model(sphere_model) + self.sphere_model = sphere_model + + cylinder_model = Model.create_cylinder(0.5, 1.0, 32) + cylinder_model.set_position(np.array([1.5, 0, 0])) + cylinder_model.set_color([0.2, 0.2, 0.8]) # Blue + cylinder_model.set_shininess(32.0) + self.scene.add_model(cylinder_model) + self.cylinder_model = cylinder_model + + assets_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets") + obj_path = os.path.join(assets_dir, "cube.obj") + + if os.path.exists(obj_path): + try: + obj_model = Model(obj_path) + obj_model.set_position(np.array([0, 1.5, 0])) + obj_model.set_scale(np.array([0.5, 0.5, 0.5])) + self.scene.add_model(obj_model) + self.obj_model = obj_model + except Exception as e: + print(f"Could not load model: {e}") + else: + print(f"Model file not found: {obj_path}") + torus_model = Model.create_torus(0.5, 0.2, 30, 20) + torus_model.set_position(np.array([0, 1.5, 0])) + self.scene.add_model(torus_model) + self.torus_model = torus_model + torus_model.set_color([0.8, 0.8, 0.2]) + torus_model.set_shininess(16.0) + + def update(self, delta_time): + self.process_keyboard(delta_time) + self.process_mouse() + self.process_scroll() + self.cube_model.rotate(np.array([0, 1, 0]), 0.5 * delta_time) + self.sphere_model.rotate(np.array([1, 1, 0]), 0.3 * delta_time) + self.cylinder_model.rotate(np.array([0, 0, 1]), 0.7 * delta_time) + + if self.window.is_key_pressed(glfw.KEY_ESCAPE): + self.window.set_should_close(True) + def process_keyboard(self, delta_time): + velocity = self.camera_speed * delta_time + + if self.window.is_key_pressed(glfw.KEY_W): + self.camera.move("forward", velocity) + if self.window.is_key_pressed(glfw.KEY_S): + self.camera.move("backward", velocity) + if self.window.is_key_pressed(glfw.KEY_A): + self.camera.move("left", velocity) + if self.window.is_key_pressed(glfw.KEY_D): + self.camera.move("right", velocity) + if self.window.is_key_pressed(glfw.KEY_SPACE): + self.camera.move("up", velocity) + if self.window.is_key_pressed(glfw.KEY_LEFT_SHIFT): + self.camera.move("down", velocity) + + def process_mouse(self): + x_offset, y_offset = self.window.get_mouse_offset() + if x_offset != 0 or y_offset != 0: + x_offset *= self.mouse_sensitivity + y_offset *= self.mouse_sensitivity + self.camera.rotate(x_offset, y_offset) + + def process_scroll(self): + y_offset = self.window.get_scroll_offset() + if y_offset != 0: + current_zoom = self.camera.zoom + self.camera.set_zoom(current_zoom - y_offset) + +def main(): + game = GameApp(800, 600, "3D Game Engine Demo") + game.start() + +if __name__ == "__main__": + main() diff --git a/shaders/phong.frag b/shaders/phong.frag new file mode 100644 index 0000000..1edb77d --- /dev/null +++ b/shaders/phong.frag @@ -0,0 +1,111 @@ +#version 330 core +out vec4 FragColor; + +struct Material { + sampler2D texture_diffuse1; + sampler2D texture_specular1; + float shininess; +}; + +struct DirLight { + vec3 direction; + + vec3 ambient; + vec3 diffuse; + vec3 specular; +}; + +struct PointLight { + vec3 position; + + float constant; + float linear; + float quadratic; + + vec3 ambient; + vec3 diffuse; + vec3 specular; +}; + +#define MAX_POINT_LIGHTS 4 + +in vec3 FragPos; +in vec3 Normal; +in vec2 TexCoords; + +uniform vec3 viewPos; +uniform Material material; +uniform DirLight dirLight; +uniform PointLight pointLights[MAX_POINT_LIGHTS]; +uniform int numPointLights; + +// Function prototypes +vec3 CalcDirLight(DirLight light, vec3 normal, vec3 viewDir); +vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir); + +void main() +{ + // Properties + vec3 norm = normalize(Normal); + vec3 viewDir = normalize(viewPos - FragPos); + + // Phase 1: Directional lighting + vec3 result = CalcDirLight(dirLight, norm, viewDir); + + // Phase 2: Point lights + for(int i = 0; i < 1; i++) + result += CalcPointLight(pointLights[i], norm, FragPos, viewDir); + + // If no texture is bound, use a default color + vec4 texColor = vec4(0.8, 0.8, 0.8, 1.0); + + FragColor = vec4(result, 1.0) * texColor; +} + +// Calculates the color when using a directional light +vec3 CalcDirLight(DirLight light, vec3 normal, vec3 viewDir) +{ + vec3 lightDir = normalize(-light.direction); + + // Diffuse shading + float diff = max(dot(normal, lightDir), 0.0); + + // Specular shading + vec3 reflectDir = reflect(-lightDir, normal); + float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0); + + // Combine results + vec3 ambient = light.ambient; + vec3 diffuse = light.diffuse * diff; + vec3 specular = light.specular * spec; + + return (ambient + diffuse + specular); +} + +// Calculates the color when using a point light +vec3 CalcPointLight(PointLight light, vec3 normal, vec3 fragPos, vec3 viewDir) +{ + vec3 lightDir = normalize(light.position - fragPos); + + // Diffuse shading + float diff = max(dot(normal, lightDir), 0.0); + + // Specular shading + vec3 reflectDir = reflect(-lightDir, normal); + float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0); + + // Attenuation + float distance = length(light.position - fragPos); + float attenuation = 1.0 / (light.constant + light.linear * distance + light.quadratic * (distance * distance)); + + // Combine results + vec3 ambient = light.ambient; + vec3 diffuse = light.diffuse * diff; + vec3 specular = light.specular * spec; + + ambient *= attenuation; + diffuse *= attenuation; + specular *= attenuation; + + return (ambient + diffuse + specular); +} \ No newline at end of file diff --git a/shaders/phong.vert b/shaders/phong.vert new file mode 100644 index 0000000..ac6f70a --- /dev/null +++ b/shaders/phong.vert @@ -0,0 +1,21 @@ +#version 330 core +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec3 aNormal; +layout (location = 2) in vec2 aTexCoords; + +out vec3 FragPos; +out vec3 Normal; +out vec2 TexCoords; + +uniform mat4 model; +uniform mat4 view; +uniform mat4 projection; + +void main() +{ + FragPos = vec3(model * vec4(aPos, 1.0)); + Normal = mat3(transpose(inverse(model))) * aNormal; + TexCoords = aTexCoords; + + gl_Position = projection * view * vec4(FragPos, 1.0); +} \ No newline at end of file