Add configuration option for ghost reset distance. Improve documentation.
This commit is contained in:
19
AGENTS.md
Normal file
19
AGENTS.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Repository Guidelines
|
||||||
|
|
||||||
|
## Project Structure & Module Organization
|
||||||
|
Core gameplay lives in root-level C++ translation units: `ghostland.cpp` orchestrates the loop, `player.cpp`, `ghost.cpp`, `collisions.cpp`, `shader.cpp`, `text.cpp`, and `config.cpp` own their respective systems. Runtime assets stay alongside code: GLSL shaders (`*shader.glsl`), UI textures (PNG in the repo root), and bitmap fonts under `fonts/`. Level data is serialized in `maze.txt`, typically regenerated via `mazeparser.py` from `maze.png`. Adjustable settings belong in `ghostland.json`; document experimental JSON entries before relying on them in code to avoid null lookups.
|
||||||
|
|
||||||
|
## Build, Test, and Development Commands
|
||||||
|
- `make` – builds all objects with C++17 + O3 and links the `ghostland` binary with GLFW, GLAD, stb_image, and Freetype.
|
||||||
|
- `./ghostland` – runs the game from the repo root; pass config overrides via `ghostland.json`.
|
||||||
|
- `make clean` – removes all `.o` files and the executable; run before release builds to ensure a full relink.
|
||||||
|
- `python3 mazeparser.py` – regenerates `maze.txt` from the source maze image; adjust the image path in the script if you keep art assets elsewhere.
|
||||||
|
|
||||||
|
## Coding Style & Naming Conventions
|
||||||
|
Use 4-space indentation, braces on the same line as control statements, and guard new modules with include guards. Keep standard-library/GLM includes before project headers as in `ghostland.cpp`. Classes use PascalCase (`Player`), local variables use `snake_case`, compile-time constants stay uppercase. Prefer STL containers over manual arrays unless performance-critical, and keep shader uniform names centralized near the top of `ghostland.cpp`.
|
||||||
|
|
||||||
|
## Testing Guidelines
|
||||||
|
There is no automated framework yet, so rely on focused manual passes. Before opening a PR, build fresh (`make clean && make`), run `./ghostland`, and verify spawn position, collision bounds, ghost ordering, and text rendering. When touching geometry, regenerate `maze.txt` and inspect extremes logged at the bottom of the file. Mention any GPU or driver quirks encountered so others can reproduce.
|
||||||
|
|
||||||
|
## Commit & Pull Request Guidelines
|
||||||
|
Existing history favors short, descriptive subjects ("Player's position is reset when close to ghosts"). Follow that tone, keep subjects under 72 characters, and add body lines for rationale or performance numbers. Each PR should include: summary of gameplay-visible changes, configs or assets touched, reproduction steps or screenshots when visuals change, and confirmation that `make` succeeds on a clean tree. Link related issues and call out any follow-up tasks explicitly.
|
||||||
19
README.md
19
README.md
@@ -3,4 +3,23 @@ This is a continuation of my [goland-game](https://github.com/five-hundred-eleve
|
|||||||
|
|
||||||
While the previous project was implemented in go, for this project I was not able to get the go bindings for OpenGL working, and so I went with C++.
|
While the previous project was implemented in go, for this project I was not able to get the go bindings for OpenGL working, and so I went with C++.
|
||||||
|
|
||||||
|
[Repository Guidelines](AGENTS.md) outlines code structure, build steps, and contribution conventions.
|
||||||
|
|
||||||
|
## Configuration (`ghostland.json`)
|
||||||
|
`ghostland.json` is parsed at startup with a minimal line-by-line reader, so keep it a flat list of key-value pairs (`"key": value`). Omitted keys fall back to in-code defaults.
|
||||||
|
|
||||||
|
| Key | Type | Default | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `camera_speed` | float | `20.0` | Scales how fast the free camera pans when you move the mouse to the screen edge or hold movement keys. Raise for faster fly-through, lower for precise adjustments. |
|
||||||
|
| `ghost_reset_distance` | float | `10.0` | Minimum distance (units) between the player and the nearest ghost before the player is snapped back to the maze start. Increase for a safer buffer, decrease for higher tension. |
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"camera_speed": 30.0,
|
||||||
|
"ghost_reset_distance": 8.5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||

|

|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include <glm/glm.hpp>
|
#include <glm/glm.hpp>
|
||||||
|
|
||||||
|
// Minimal triangle intersection helpers; use local versions to avoid glibc dependencies.
|
||||||
float abs(float x) {
|
float abs(float x) {
|
||||||
if (x < 0) {
|
if (x < 0) {
|
||||||
return -x;
|
return -x;
|
||||||
@@ -14,6 +15,7 @@ int signbit(float x) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Möller–Trumbore style segment-triangle intersection routine.
|
||||||
int get_intersection(glm::vec3 vec_start, glm::vec3 vec_stop, glm::vec3 P1, glm::vec3 P2, glm::vec3 P3) {
|
int get_intersection(glm::vec3 vec_start, glm::vec3 vec_stop, glm::vec3 P1, glm::vec3 P2, glm::vec3 P3) {
|
||||||
|
|
||||||
glm::vec3 lba = vec_start - vec_stop;
|
glm::vec3 lba = vec_start - vec_stop;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <glm/glm.hpp>
|
#include <glm/glm.hpp>
|
||||||
|
|
||||||
|
// Returns 1 if the segment (vec_start -> vec_stop) intersects triangle (P1,P2,P3).
|
||||||
int get_intersection(glm::vec3 vec_start, glm::vec3 vec_stop, glm::vec3 P1, glm::vec3 P2, glm::vec3 P3);
|
int get_intersection(glm::vec3 vec_start, glm::vec3 vec_stop, glm::vec3 P1, glm::vec3 P2, glm::vec3 P3);
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
std::map<std::string, std::string> Config::values;
|
std::map<std::string, std::string> Config::values;
|
||||||
|
|
||||||
bool Config::loadFromFile(const std::string& filename) {
|
bool Config::loadFromFile(const std::string& filename) {
|
||||||
|
// Extremely small JSON subset reader: expects `"key": value` pairs per line.
|
||||||
std::ifstream file(filename);
|
std::ifstream file(filename);
|
||||||
if (!file.is_open()) {
|
if (!file.is_open()) {
|
||||||
std::cout << "Warning: Could not open config file: " << filename << std::endl;
|
std::cout << "Warning: Could not open config file: " << filename << std::endl;
|
||||||
@@ -26,6 +27,7 @@ bool Config::loadFromFile(const std::string& filename) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
float Config::getFloat(const std::string& key, float defaultValue) {
|
float Config::getFloat(const std::string& key, float defaultValue) {
|
||||||
|
// Values are stored as strings; convert on demand and fall back if invalid.
|
||||||
auto it = values.find(key);
|
auto it = values.find(key);
|
||||||
if (it != values.end()) {
|
if (it != values.end()) {
|
||||||
try {
|
try {
|
||||||
@@ -38,6 +40,7 @@ float Config::getFloat(const std::string& key, float defaultValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int Config::getInt(const std::string& key, int defaultValue) {
|
int Config::getInt(const std::string& key, int defaultValue) {
|
||||||
|
// Values are stored as strings; convert on demand and fall back if invalid.
|
||||||
auto it = values.find(key);
|
auto it = values.find(key);
|
||||||
if (it != values.end()) {
|
if (it != values.end()) {
|
||||||
try {
|
try {
|
||||||
@@ -50,6 +53,7 @@ int Config::getInt(const std::string& key, int defaultValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string Config::getString(const std::string& key, const std::string& defaultValue) {
|
std::string Config::getString(const std::string& key, const std::string& defaultValue) {
|
||||||
|
// Direct passthrough for callers that want raw string values.
|
||||||
auto it = values.find(key);
|
auto it = values.find(key);
|
||||||
if (it != values.end()) {
|
if (it != values.end()) {
|
||||||
return it->second;
|
return it->second;
|
||||||
@@ -58,6 +62,7 @@ std::string Config::getString(const std::string& key, const std::string& default
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::string Config::trim(const std::string& str) {
|
std::string Config::trim(const std::string& str) {
|
||||||
|
// Helper for stripping whitespace around keys/values.
|
||||||
size_t start = str.find_first_not_of(" \t\r\n");
|
size_t start = str.find_first_not_of(" \t\r\n");
|
||||||
if (start == std::string::npos) return "";
|
if (start == std::string::npos) return "";
|
||||||
size_t end = str.find_last_not_of(" \t\r\n");
|
size_t end = str.find_last_not_of(" \t\r\n");
|
||||||
|
|||||||
2
config.h
2
config.h
@@ -4,8 +4,10 @@
|
|||||||
#include <string>
|
#include <string>
|
||||||
#include <map>
|
#include <map>
|
||||||
|
|
||||||
|
// Config is a minimal line-oriented JSON reader used to expose tuning knobs at runtime.
|
||||||
class Config {
|
class Config {
|
||||||
public:
|
public:
|
||||||
|
// Reads the file into a flat string map; best-effort, tolerates missing files.
|
||||||
static bool loadFromFile(const std::string& filename);
|
static bool loadFromFile(const std::string& filename);
|
||||||
static float getFloat(const std::string& key, float defaultValue);
|
static float getFloat(const std::string& key, float defaultValue);
|
||||||
static int getInt(const std::string& key, int defaultValue);
|
static int getInt(const std::string& key, int defaultValue);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#version 460 core
|
#version 460 core
|
||||||
|
// Phong lighting fragment shader driven by a single spotlight described in ghostland.cpp.
|
||||||
out vec4 FragColor;
|
out vec4 FragColor;
|
||||||
|
|
||||||
struct Material {
|
struct Material {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ Ghost::Ghost(float xmin, float xmax, float zmin, float zmax) {
|
|||||||
|
|
||||||
void Ghost::apply_movement(float curr_time, float timed) {
|
void Ghost::apply_movement(float curr_time, float timed) {
|
||||||
|
|
||||||
// vertical movement
|
// Bob vertically to make ghosts feel more alive.
|
||||||
pos.y = 2.5 + sin(curr_time + y_offset);
|
pos.y = 2.5 + sin(curr_time + y_offset);
|
||||||
|
|
||||||
moved.x = -sin(yawr) + cos(yawr);
|
moved.x = -sin(yawr) + cos(yawr);
|
||||||
@@ -77,7 +77,7 @@ glm::mat4 Ghost::get_model(glm::vec3 &camera_pos) {
|
|||||||
// apply direction
|
// apply direction
|
||||||
// the cross product helps us determine which direction it's going relative to player
|
// the cross product helps us determine which direction it's going relative to player
|
||||||
glm::vec3 ghost_to_player = pos - camera_pos;
|
glm::vec3 ghost_to_player = pos - camera_pos;
|
||||||
glm::vec3 crossed = glm::cross(ghost_to_player, moved);
|
glm::vec3 crossed = glm::cross(ghost_to_player, moved); // determines whether the sprite should face left/right
|
||||||
//float dist2 = ghost_to_player.x * ghost_to_player.x + ghost_to_player.z * ghost_to_player.z;
|
//float dist2 = ghost_to_player.x * ghost_to_player.x + ghost_to_player.z * ghost_to_player.z;
|
||||||
float theta = atan2f(ghost_to_player.x, ghost_to_player.z);
|
float theta = atan2f(ghost_to_player.x, ghost_to_player.z);
|
||||||
if (crossed.y < 0.0) {
|
if (crossed.y < 0.0) {
|
||||||
@@ -113,9 +113,8 @@ glm::vec3 Ghost::get_pos() const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Ghost::reverse_direction_from_position(glm::vec3 target_pos) {
|
void Ghost::reverse_direction_from_position(glm::vec3 target_pos) {
|
||||||
// Calculate direction from target_pos to ghost position
|
// Push the ghost to flee from target_pos (usually the starting area).
|
||||||
glm::vec3 direction = pos - target_pos;
|
glm::vec3 direction = pos - target_pos;
|
||||||
// Set yawr to point directly away from target_pos
|
|
||||||
yawr = atan2(direction.x, direction.z);
|
yawr = atan2(direction.x, direction.z);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
6
ghost.h
6
ghost.h
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
#include <glm/glm.hpp>
|
#include <glm/glm.hpp>
|
||||||
|
|
||||||
|
// Ghost encapsulates AI state for a single floating enemy constrained to an
|
||||||
|
// axis-aligned bounding box inside the maze.
|
||||||
class Ghost {
|
class Ghost {
|
||||||
public:
|
public:
|
||||||
Ghost(float xmin, float xmax, float zmin, float zmax);
|
Ghost(float xmin, float xmax, float zmin, float zmax);
|
||||||
@@ -17,14 +19,14 @@ class Ghost {
|
|||||||
bool first_frame;
|
bool first_frame;
|
||||||
glm::vec3 moved;
|
glm::vec3 moved;
|
||||||
glm::vec3 pos;
|
glm::vec3 pos;
|
||||||
float yawr;
|
float yawr; // facing direction used while wandering
|
||||||
float xmin;
|
float xmin;
|
||||||
float xmax;
|
float xmax;
|
||||||
float zmin;
|
float zmin;
|
||||||
float zmax;
|
float zmax;
|
||||||
float y_offset;
|
float y_offset;
|
||||||
float prev_move_time;
|
float prev_move_time;
|
||||||
float direction_persist;
|
float direction_persist; // blends rotation over time so ghosts don't jitter
|
||||||
};
|
};
|
||||||
|
|
||||||
float rand_float(float rmin, float rmax);
|
float rand_float(float rmin, float rmax);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#version 460 core
|
#version 460 core
|
||||||
|
// Ghost sprite fragment shader: samples alpha texture and fades opacity to 30%.
|
||||||
|
|
||||||
out vec4 FragColor;
|
out vec4 FragColor;
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
#include "text.h"
|
#include "text.h"
|
||||||
#include "config.h"
|
#include "config.h"
|
||||||
|
|
||||||
|
// Forward declarations for GLFW callbacks configured at start-up.
|
||||||
void framebuffer_size_callback(GLFWwindow* window, int width, int height);
|
void framebuffer_size_callback(GLFWwindow* window, int width, int height);
|
||||||
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
|
void mouse_callback(GLFWwindow* window, double xpos, double ypos);
|
||||||
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset);
|
void scroll_callback(GLFWwindow* window, double xoffset, double yoffset);
|
||||||
@@ -40,6 +41,7 @@ float timed = 0.0f;
|
|||||||
float last_frame = 0.0f;
|
float last_frame = 0.0f;
|
||||||
|
|
||||||
float camera_speed = 20.0f;
|
float camera_speed = 20.0f;
|
||||||
|
float ghost_reset_distance = 10.0f;
|
||||||
|
|
||||||
const char *projectionC = "projection";
|
const char *projectionC = "projection";
|
||||||
const char *viewC = "view";
|
const char *viewC = "view";
|
||||||
@@ -69,10 +71,12 @@ int main(int argc, char *argv[]) {
|
|||||||
glm::vec3 start_position;
|
glm::vec3 start_position;
|
||||||
float start_yaw;
|
float start_yaw;
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration knobs before spinning up the world.
|
||||||
Config::loadFromFile("ghostland.json");
|
Config::loadFromFile("ghostland.json");
|
||||||
camera_speed = Config::getFloat("camera_speed", camera_speed);
|
camera_speed = Config::getFloat("camera_speed", camera_speed);
|
||||||
|
ghost_reset_distance = Config::getFloat("ghost_reset_distance", ghost_reset_distance);
|
||||||
|
|
||||||
|
// Maze file contains serialized spawn and wall mesh data generated by mazeparser.py.
|
||||||
FILE *fp = fopen("maze.txt", "r");
|
FILE *fp = fopen("maze.txt", "r");
|
||||||
float yaw;
|
float yaw;
|
||||||
if (fscanf(fp, "%f %f %f %f\n", &position.x, &position.y, &position.z, &yaw) == EOF) {
|
if (fscanf(fp, "%f %f %f %f\n", &position.x, &position.y, &position.z, &yaw) == EOF) {
|
||||||
@@ -150,6 +154,7 @@ int main(int argc, char *argv[]) {
|
|||||||
}
|
}
|
||||||
fclose(fp);
|
fclose(fp);
|
||||||
|
|
||||||
|
// Initialize GLFW/GLAD and windowing.
|
||||||
glfwInit();
|
glfwInit();
|
||||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
|
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
|
||||||
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
|
||||||
@@ -366,6 +371,7 @@ int main(int argc, char *argv[]) {
|
|||||||
|
|
||||||
int FPS = -1;
|
int FPS = -1;
|
||||||
|
|
||||||
|
// Main render/game loop.
|
||||||
while (!glfwWindowShouldClose(window))
|
while (!glfwWindowShouldClose(window))
|
||||||
{
|
{
|
||||||
float current_frame = static_cast<float>(glfwGetTime());
|
float current_frame = static_cast<float>(glfwGetTime());
|
||||||
@@ -428,7 +434,7 @@ int main(int argc, char *argv[]) {
|
|||||||
glm::vec3 light_front = player->get_light_front();
|
glm::vec3 light_front = player->get_light_front();
|
||||||
//printf("light_pos: (%f, %f, %f)\n", light_front.x, light_front.y, light_front.z);
|
//printf("light_pos: (%f, %f, %f)\n", light_front.x, light_front.y, light_front.z);
|
||||||
|
|
||||||
// Sort ghosts by distance to player for optimization
|
// Sort ghosts by distance to player for optimization.
|
||||||
glm::vec3 player_pos = player->get_pos();
|
glm::vec3 player_pos = player->get_pos();
|
||||||
struct {
|
struct {
|
||||||
bool operator()(const Ghost *a, const Ghost *b) const {
|
bool operator()(const Ghost *a, const Ghost *b) const {
|
||||||
@@ -441,6 +447,7 @@ int main(int argc, char *argv[]) {
|
|||||||
return a_dist < b_dist;
|
return a_dist < b_dist;
|
||||||
}
|
}
|
||||||
} ghost_compare;
|
} ghost_compare;
|
||||||
|
// Only the closest 20% need perfect ordering for blending, so partial_sort is cheaper than full sort.
|
||||||
std::partial_sort(ghosts.begin(), ghosts.begin() + ghosts.size() / 5, ghosts.end(), ghost_compare);
|
std::partial_sort(ghosts.begin(), ghosts.begin() + ghosts.size() / 5, ghosts.end(), ghost_compare);
|
||||||
|
|
||||||
// Calculate nearest ghost distance (first ghost is now closest)
|
// Calculate nearest ghost distance (first ghost is now closest)
|
||||||
@@ -522,15 +529,15 @@ int main(int argc, char *argv[]) {
|
|||||||
glm::mat4 ghost_model = ghosts[i]->get_model(camera_pos);
|
glm::mat4 ghost_model = ghosts[i]->get_model(camera_pos);
|
||||||
ghosts[i]->apply_movement(current_frame, timed);
|
ghosts[i]->apply_movement(current_frame, timed);
|
||||||
|
|
||||||
// Check if ghost is within 10.0 units of player
|
// When ghosts close in, reset player back to the maze entrance.
|
||||||
glm::vec3 ghost_pos = ghosts[i]->get_pos();
|
glm::vec3 ghost_pos = ghosts[i]->get_pos();
|
||||||
glm::vec3 player_pos = player->get_pos();
|
glm::vec3 player_pos = player->get_pos();
|
||||||
float distance = glm::length(ghost_pos - player_pos);
|
float distance = glm::length(ghost_pos - player_pos);
|
||||||
if (distance <= 10.0f) {
|
if (distance <= ghost_reset_distance) {
|
||||||
player->reset_position(start_position, start_yaw);
|
player->reset_position(start_position, start_yaw);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if ghost is within 30.0 units of start_position
|
// Nudge ghosts away from the spawn area so the player gets a breather.
|
||||||
float start_distance = glm::length(ghost_pos - start_position);
|
float start_distance = glm::length(ghost_pos - start_position);
|
||||||
if (start_distance <= 30.0f) {
|
if (start_distance <= 30.0f) {
|
||||||
ghosts[i]->reverse_direction_from_position(start_position);
|
ghosts[i]->reverse_direction_from_position(start_position);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
{
|
{
|
||||||
"camera_speed": 30.0
|
"camera_speed": 35.0,
|
||||||
|
"ghost_reset_distance": 5.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#version 460 core
|
#version 460 core
|
||||||
|
// Billboarded ghost vertex shader: passes world pos/normal and flips UV.y for texture atlases.
|
||||||
layout (location = 0) in vec3 aPos;
|
layout (location = 0) in vec3 aPos;
|
||||||
layout (location = 1) in vec3 aNormal;
|
layout (location = 1) in vec3 aNormal;
|
||||||
layout (location = 2) in vec2 aTexCoord;
|
layout (location = 2) in vec2 aTexCoord;
|
||||||
|
|||||||
@@ -1,17 +1,24 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
|
|
||||||
|
# Generate maze.txt geometry by tracing the outline of a binary maze image.
|
||||||
|
# The script expects the maze PNG to be white-on-black and writes walls/floor
|
||||||
|
# segments in the format ghostland.cpp consumes at runtime.
|
||||||
|
|
||||||
import matplotlib.image as mpimage
|
import matplotlib.image as mpimage
|
||||||
|
|
||||||
|
# TODO: parameterize this path via CLI flags or config.
|
||||||
img = mpimage.imread("/home/cowley/Pictures/maze.png")
|
img = mpimage.imread("/home/cowley/Pictures/maze.png")
|
||||||
rows = len(img)
|
rows = len(img)
|
||||||
cols = len(img[0])
|
cols = len(img[0])
|
||||||
|
|
||||||
|
|
||||||
def isfilled(val):
|
def isfilled(val):
|
||||||
|
# Pixels brighter than ~20% are treated as walls.
|
||||||
return all(x > 0.2 for x in val)
|
return all(x > 0.2 for x in val)
|
||||||
|
|
||||||
|
|
||||||
def getval(img, x, y):
|
def getval(img, x, y):
|
||||||
|
# Bounds-check helpers so we can safely probe virtual border pixels.
|
||||||
if y < 0 or y >= len(img):
|
if y < 0 or y >= len(img):
|
||||||
return False
|
return False
|
||||||
if x < 0 or x >= len(img[y]):
|
if x < 0 or x >= len(img[y]):
|
||||||
@@ -20,6 +27,8 @@ def getval(img, x, y):
|
|||||||
|
|
||||||
|
|
||||||
segments = []
|
segments = []
|
||||||
|
# Sweep the image in four directions to emit axis-aligned wall segments.
|
||||||
|
# Horizontal sweep detecting transitions from empty -> filled above (top edges).
|
||||||
for iy in range(-1, rows + 1):
|
for iy in range(-1, rows + 1):
|
||||||
seg_start = None
|
seg_start = None
|
||||||
for ix in range(-1, cols + 1):
|
for ix in range(-1, cols + 1):
|
||||||
@@ -34,6 +43,7 @@ for iy in range(-1, rows + 1):
|
|||||||
segments.append((seg_start, (cols - 0.5, iy - 0.5), (0.0, 0.0, 1.0)))
|
segments.append((seg_start, (cols - 0.5, iy - 0.5), (0.0, 0.0, 1.0)))
|
||||||
|
|
||||||
|
|
||||||
|
# Horizontal sweep detecting transitions from empty -> filled below (bottom edges).
|
||||||
for iy in range(-1, rows + 1):
|
for iy in range(-1, rows + 1):
|
||||||
seg_start = None
|
seg_start = None
|
||||||
for ix in range(-1, cols + 1):
|
for ix in range(-1, cols + 1):
|
||||||
@@ -47,6 +57,7 @@ for iy in range(-1, rows + 1):
|
|||||||
if seg_start is not None:
|
if seg_start is not None:
|
||||||
segments.append((seg_start, (cols - 0.5, iy + 0.5), (0.0, 0.0, -1.0)))
|
segments.append((seg_start, (cols - 0.5, iy + 0.5), (0.0, 0.0, -1.0)))
|
||||||
|
|
||||||
|
# Vertical sweep detecting transitions from empty -> filled on the left.
|
||||||
for ix in range(-1, cols + 1):
|
for ix in range(-1, cols + 1):
|
||||||
seg_start = None
|
seg_start = None
|
||||||
for iy in range(-1, rows + 1):
|
for iy in range(-1, rows + 1):
|
||||||
@@ -60,6 +71,7 @@ for ix in range(-1, cols + 1):
|
|||||||
if seg_start is not None:
|
if seg_start is not None:
|
||||||
segments.append((seg_start, (ix - 0.5, rows - 0.5), (-1.0, 0.0, 0.0)))
|
segments.append((seg_start, (ix - 0.5, rows - 0.5), (-1.0, 0.0, 0.0)))
|
||||||
|
|
||||||
|
# Vertical sweep detecting transitions from empty -> filled on the right.
|
||||||
for ix in range(-1, cols + 1):
|
for ix in range(-1, cols + 1):
|
||||||
seg_start = None
|
seg_start = None
|
||||||
for iy in range(-1, rows + 1):
|
for iy in range(-1, rows + 1):
|
||||||
@@ -73,10 +85,12 @@ for ix in range(-1, cols + 1):
|
|||||||
if seg_start is not None:
|
if seg_start is not None:
|
||||||
segments.append((seg_start, (ix + 0.5, rows - 0.5), (1.0, 0.0, 0.0)))
|
segments.append((seg_start, (ix + 0.5, rows - 0.5), (1.0, 0.0, 0.0)))
|
||||||
|
|
||||||
print(segments[:10])
|
print(segments[:10]) # Quick sanity check; remove or redirect for batch runs.
|
||||||
|
|
||||||
|
# Serialize into the legacy maze.txt format ghostland.cpp expects.
|
||||||
res = {}
|
res = {}
|
||||||
res["players"] = []
|
res["players"] = []
|
||||||
|
# Hard-coded spawn until a better level editor exists.
|
||||||
player = {
|
player = {
|
||||||
"y": 2.5,
|
"y": 2.5,
|
||||||
"x": 845.0,
|
"x": 845.0,
|
||||||
@@ -86,6 +100,7 @@ player = {
|
|||||||
res["players"].append(player)
|
res["players"].append(player)
|
||||||
res["surfaces"] = []
|
res["surfaces"] = []
|
||||||
for segment in segments:
|
for segment in segments:
|
||||||
|
# Build quads with outward normals stored as the fifth element.
|
||||||
p1 = {"x": segment[0][0], "z": -segment[0][1], "y": 0.0}
|
p1 = {"x": segment[0][0], "z": -segment[0][1], "y": 0.0}
|
||||||
p2 = {
|
p2 = {
|
||||||
"x": segment[1][0],
|
"x": segment[1][0],
|
||||||
@@ -110,10 +125,12 @@ xmax = 0.0
|
|||||||
zmin = 0.0
|
zmin = 0.0
|
||||||
zmax = 0.0
|
zmax = 0.0
|
||||||
|
|
||||||
|
# Write the flattened surfaces followed by a floor bounding box so the game can detect footsteps.
|
||||||
with open("maze.txt", "w") as f:
|
with open("maze.txt", "w") as f:
|
||||||
f.write(f"{player['x']} {player['y']} {player['z']} {player['yaw']}\n")
|
f.write(f"{player['x']} {player['y']} {player['z']} {player['yaw']}\n")
|
||||||
f.write(f"{len(res['surfaces'])}\n")
|
f.write(f"{len(res['surfaces'])}\n")
|
||||||
for segment in res["surfaces"]:
|
for segment in res["surfaces"]:
|
||||||
|
# Track extrema to write a single floor plane at the end.
|
||||||
for point in segment[:4]:
|
for point in segment[:4]:
|
||||||
if point["x"] < xmin:
|
if point["x"] < xmin:
|
||||||
xmin = point["x"]
|
xmin = point["x"]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Player::Player(glm::vec3 startpos, float startyaw) {
|
|||||||
position = startpos;
|
position = startpos;
|
||||||
yaw = startyaw;
|
yaw = startyaw;
|
||||||
|
|
||||||
|
// Initialize neutral view/camera defaults; yaw follows spawn while pitch starts level.
|
||||||
pitch = 0.0f;
|
pitch = 0.0f;
|
||||||
first_mouse = false;
|
first_mouse = false;
|
||||||
first_frame = false;
|
first_frame = false;
|
||||||
@@ -66,6 +67,7 @@ float Player::get_fov() {
|
|||||||
|
|
||||||
void Player::mouse_callback(GLFWwindow* window, double xposI, double yposI) {
|
void Player::mouse_callback(GLFWwindow* window, double xposI, double yposI) {
|
||||||
|
|
||||||
|
// Track relative mouse deltas to drive FPS-style look controls.
|
||||||
float xpos = static_cast<float>(xposI);
|
float xpos = static_cast<float>(xposI);
|
||||||
float ypos = static_cast<float>(yposI);
|
float ypos = static_cast<float>(yposI);
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ void Player::mouse_callback(GLFWwindow* window, double xposI, double yposI) {
|
|||||||
|
|
||||||
void Player::process_input(GLFWwindow *window)
|
void Player::process_input(GLFWwindow *window)
|
||||||
{
|
{
|
||||||
|
// Poll keyboard state and convert to world-space velocity.
|
||||||
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) {
|
if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS) {
|
||||||
glfwSetWindowShouldClose(window, true);
|
glfwSetWindowShouldClose(window, true);
|
||||||
}
|
}
|
||||||
@@ -185,6 +188,7 @@ bool Player::is_in_air() {
|
|||||||
|
|
||||||
void Player::set_light_offset(float xoffset, float yoffset) {
|
void Player::set_light_offset(float xoffset, float yoffset) {
|
||||||
|
|
||||||
|
// Ease in flashlight offsets so the beam trails behind head motion.
|
||||||
xoffset *= light_movement_multiplier;
|
xoffset *= light_movement_multiplier;
|
||||||
yoffset *= light_movement_multiplier;
|
yoffset *= light_movement_multiplier;
|
||||||
|
|
||||||
@@ -205,6 +209,7 @@ void Player::set_light_offset(float xoffset, float yoffset) {
|
|||||||
|
|
||||||
void Player::apply_movement(float timed) {
|
void Player::apply_movement(float timed) {
|
||||||
|
|
||||||
|
// Apply accumulated mouse/keyboard input, then integrate velocity with collision resolution.
|
||||||
// mouse movement
|
// mouse movement
|
||||||
glm::vec3 front;
|
glm::vec3 front;
|
||||||
front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
|
front.x = cos(glm::radians(yaw)) * cos(glm::radians(pitch));
|
||||||
@@ -274,6 +279,7 @@ glm::vec3 Player::get_vec3_from_indices(int ix, int jx) {
|
|||||||
|
|
||||||
glm::vec3 Player::check_intersection(glm::vec3 movement) {
|
glm::vec3 Player::check_intersection(glm::vec3 movement) {
|
||||||
|
|
||||||
|
// Raycast against wall quads to zero out components that would clip into geometry.
|
||||||
glm::vec3 vec_start, vec_stop, p1, p2, p3, res;
|
glm::vec3 vec_start, vec_stop, p1, p2, p3, res;
|
||||||
bool gotX = false, gotZ = false;
|
bool gotX = false, gotZ = false;
|
||||||
vec_start = get_camera_pos();
|
vec_start = get_camera_pos();
|
||||||
@@ -286,6 +292,7 @@ glm::vec3 Player::check_intersection(glm::vec3 movement) {
|
|||||||
if (gotX && gotZ) {
|
if (gotX && gotZ) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// Only test faces whose normals oppose our intended direction.
|
||||||
glm::vec3 norm = get_normal_from_index(ix);
|
glm::vec3 norm = get_normal_from_index(ix);
|
||||||
if (!gotX) {
|
if (!gotX) {
|
||||||
if ((movement.x > 0 && norm.x < 0) || (movement.x < 0 && norm.x > 0)) {
|
if ((movement.x > 0 && norm.x < 0) || (movement.x < 0 && norm.x > 0)) {
|
||||||
@@ -324,4 +331,3 @@ void Player::scroll_callback(GLFWwindow* window, double xoffset, double yoffset)
|
|||||||
fov = 90.0f;
|
fov = 90.0f;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
13
player.h
13
player.h
@@ -8,6 +8,7 @@
|
|||||||
#include "collisions.h"
|
#include "collisions.h"
|
||||||
#include "ghost.h"
|
#include "ghost.h"
|
||||||
|
|
||||||
|
// Flashlight smoothing and locomotion constants tuned for the maze scale.
|
||||||
const float lightoffsetmax = 15.0;
|
const float lightoffsetmax = 15.0;
|
||||||
const float light_persist_factor = 0.99;
|
const float light_persist_factor = 0.99;
|
||||||
const float light_offset_factor = 0.01;
|
const float light_offset_factor = 0.01;
|
||||||
@@ -16,6 +17,8 @@ const float jumpvelocity = 8.104849;
|
|||||||
const float vacceleration = -13.4058;
|
const float vacceleration = -13.4058;
|
||||||
const float hvelocity = 10.0;
|
const float hvelocity = 10.0;
|
||||||
|
|
||||||
|
// Player glues together camera orientation, flashlight pose, and collision-aware
|
||||||
|
// movement inside the maze geometry.
|
||||||
class Player {
|
class Player {
|
||||||
|
|
||||||
public:
|
public:
|
||||||
@@ -52,8 +55,8 @@ class Player {
|
|||||||
float prevy;
|
float prevy;
|
||||||
bool first_mouse;
|
bool first_mouse;
|
||||||
bool first_frame;
|
bool first_frame;
|
||||||
float light_xpersist;
|
float light_xpersist; // smoothed horizontal flashlight offset
|
||||||
float light_ypersist;
|
float light_ypersist; // smoothed vertical flashlight offset
|
||||||
float armlength;
|
float armlength;
|
||||||
float prev_move_time;
|
float prev_move_time;
|
||||||
float mouse_xoffset;
|
float mouse_xoffset;
|
||||||
@@ -64,14 +67,14 @@ class Player {
|
|||||||
glm::vec3 camera_up;
|
glm::vec3 camera_up;
|
||||||
glm::vec3 light_pos;
|
glm::vec3 light_pos;
|
||||||
glm::vec3 light_front;
|
glm::vec3 light_front;
|
||||||
glm::vec3 velocity;
|
glm::vec3 velocity; // accumulated velocity, including gravity
|
||||||
glm::vec3 camera_offset;
|
glm::vec3 camera_offset; // camera lift above feet
|
||||||
|
|
||||||
void set_light_offset(float xoffset, float yoffset);
|
void set_light_offset(float xoffset, float yoffset);
|
||||||
|
|
||||||
glm::vec3 get_normal_from_index(int ix);
|
glm::vec3 get_normal_from_index(int ix);
|
||||||
glm::vec3 get_vec3_from_indices(int ix, int jx);
|
glm::vec3 get_vec3_from_indices(int ix, int jx);
|
||||||
glm::vec3 check_intersection(glm::vec3 movement);
|
glm::vec3 check_intersection(glm::vec3 movement); // clamp movement vector by wall hits
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include "shader.h"
|
#include "shader.h"
|
||||||
|
|
||||||
|
// Load a GLSL file from disk and compile it as vertex/fragment/etc.
|
||||||
int create_shader(const char *filename, int shadertype) {
|
int create_shader(const char *filename, int shadertype) {
|
||||||
FILE* fp = fopen(filename, "r");
|
FILE* fp = fopen(filename, "r");
|
||||||
if (!fp) {
|
if (!fp) {
|
||||||
@@ -38,6 +39,7 @@ int create_shader(const char *filename, int shadertype) {
|
|||||||
return shader;
|
return shader;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compile and link a shader program from the given vertex/fragment GLSL files.
|
||||||
int create_shader_program(const char *vertex_filename, const char *fragment_filename) {
|
int create_shader_program(const char *vertex_filename, const char *fragment_filename) {
|
||||||
|
|
||||||
int vertex_shader = create_shader(vertex_filename, GL_VERTEX_SHADER);
|
int vertex_shader = create_shader(vertex_filename, GL_VERTEX_SHADER);
|
||||||
@@ -70,6 +72,7 @@ int create_shader_program(const char *vertex_filename, const char *fragment_file
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Simple wrappers to make uniform updates less verbose at call sites.
|
||||||
void set_uniform(int program, const char *key, float value) {
|
void set_uniform(int program, const char *key, float value) {
|
||||||
glUniform1f(glGetUniformLocation(program, key), value);
|
glUniform1f(glGetUniformLocation(program, key), value);
|
||||||
}
|
}
|
||||||
|
|||||||
1
shader.h
1
shader.h
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
#include <glm/glm.hpp>
|
#include <glm/glm.hpp>
|
||||||
|
|
||||||
|
// Utility helpers for compiling GLSL source files and setting uniforms.
|
||||||
int create_shader(const char *filename, int shadertype);
|
int create_shader(const char *filename, int shadertype);
|
||||||
int create_shader_program(const char *vertex_filename, const char *fragment_filename);
|
int create_shader_program(const char *vertex_filename, const char *fragment_filename);
|
||||||
|
|
||||||
|
|||||||
22
text.cpp
22
text.cpp
@@ -5,13 +5,16 @@
|
|||||||
#include "text.h"
|
#include "text.h"
|
||||||
#include "shader.h"
|
#include "shader.h"
|
||||||
|
|
||||||
|
// Global freetype context shared across text rendering.
|
||||||
FT_Library ft;
|
FT_Library ft;
|
||||||
FT_Face face;
|
FT_Face face;
|
||||||
|
|
||||||
|
// Uniform key for tinting glyphs.
|
||||||
const char *textcolorC = "textColor";
|
const char *textcolorC = "textColor";
|
||||||
|
|
||||||
unsigned int textVAO, textVBO;
|
unsigned int textVAO, textVBO; // shared quad mesh for all HUD strings
|
||||||
|
|
||||||
|
// Glyph metadata + GL texture for a single character.
|
||||||
struct character_t {
|
struct character_t {
|
||||||
unsigned int texture_id;
|
unsigned int texture_id;
|
||||||
glm::ivec2 size;
|
glm::ivec2 size;
|
||||||
@@ -19,8 +22,10 @@ struct character_t {
|
|||||||
unsigned int advance;
|
unsigned int advance;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ASCII glyph cache keyed by character codepoint.
|
||||||
std::map<char, character_t> glyph_to_character;
|
std::map<char, character_t> glyph_to_character;
|
||||||
|
|
||||||
|
// Initialize freetype and bake ASCII glyphs into GL textures for reuse.
|
||||||
int init_text(int *shader) {
|
int init_text(int *shader) {
|
||||||
|
|
||||||
if (FT_Init_FreeType(&ft)) {
|
if (FT_Init_FreeType(&ft)) {
|
||||||
@@ -33,8 +38,9 @@ int init_text(int *shader) {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bake glyph bitmaps at a fixed pixel height; width is dynamic.
|
||||||
FT_Set_Pixel_Sizes(face, 0, 48);
|
FT_Set_Pixel_Sizes(face, 0, 48);
|
||||||
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
|
glPixelStorei(GL_UNPACK_ALIGNMENT, 1); // allow byte-aligned glyph rows
|
||||||
|
|
||||||
for (unsigned char c = 0; c < 128; c++) {
|
for (unsigned char c = 0; c < 128; c++) {
|
||||||
|
|
||||||
@@ -44,6 +50,7 @@ int init_text(int *shader) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unsigned int texture;
|
unsigned int texture;
|
||||||
|
// Upload the glyph bitmap into a single-channel texture.
|
||||||
glGenTextures(1, &texture);
|
glGenTextures(1, &texture);
|
||||||
glBindTexture(GL_TEXTURE_2D, texture);
|
glBindTexture(GL_TEXTURE_2D, texture);
|
||||||
glTexImage2D(
|
glTexImage2D(
|
||||||
@@ -78,8 +85,7 @@ int init_text(int *shader) {
|
|||||||
FT_Done_Face(face);
|
FT_Done_Face(face);
|
||||||
FT_Done_FreeType(ft);
|
FT_Done_FreeType(ft);
|
||||||
|
|
||||||
// configure textVAO/textVBO for texture quads
|
// Configure buffers for a dynamic quad (two triangles per glyph).
|
||||||
// -----------------------------------
|
|
||||||
glGenVertexArrays(1, &textVAO);
|
glGenVertexArrays(1, &textVAO);
|
||||||
glGenBuffers(1, &textVBO);
|
glGenBuffers(1, &textVBO);
|
||||||
glBindVertexArray(textVAO);
|
glBindVertexArray(textVAO);
|
||||||
@@ -100,9 +106,10 @@ int init_text(int *shader) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw a string using pre-baked glyph textures at the supplied screen position.
|
||||||
void render_text(int shader, std::string text, float x, float y, float scale, glm::vec3 color) {
|
void render_text(int shader, std::string text, float x, float y, float scale, glm::vec3 color) {
|
||||||
|
|
||||||
set_uniform(shader, textcolorC, color);
|
set_uniform(shader, textcolorC, color); // apply tint per draw call
|
||||||
glActiveTexture(GL_TEXTURE0);
|
glActiveTexture(GL_TEXTURE0);
|
||||||
glBindVertexArray(textVAO);
|
glBindVertexArray(textVAO);
|
||||||
|
|
||||||
@@ -116,6 +123,7 @@ void render_text(int shader, std::string text, float x, float y, float scale, gl
|
|||||||
float w = ch.size.x * scale;
|
float w = ch.size.x * scale;
|
||||||
float h = ch.size.y * scale;
|
float h = ch.size.y * scale;
|
||||||
|
|
||||||
|
// Each glyph is rendered as a textured quad.
|
||||||
float vertices[24] = {
|
float vertices[24] = {
|
||||||
xpos, ypos + h, 0.0f, 0.0f,
|
xpos, ypos + h, 0.0f, 0.0f,
|
||||||
xpos, ypos, 0.0f, 1.0f,
|
xpos, ypos, 0.0f, 1.0f,
|
||||||
@@ -126,12 +134,12 @@ void render_text(int shader, std::string text, float x, float y, float scale, gl
|
|||||||
};
|
};
|
||||||
glBindTexture(GL_TEXTURE_2D, ch.texture_id);
|
glBindTexture(GL_TEXTURE_2D, ch.texture_id);
|
||||||
glBindBuffer(GL_ARRAY_BUFFER, textVBO);
|
glBindBuffer(GL_ARRAY_BUFFER, textVBO);
|
||||||
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(float) * (2 + 2) * 6, vertices);
|
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(float) * (2 + 2) * 6, vertices); // stream quad verts
|
||||||
|
|
||||||
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
glBindBuffer(GL_ARRAY_BUFFER, 0);
|
||||||
glDrawArrays(GL_TRIANGLES, 0, (2 + 2) * 6);
|
glDrawArrays(GL_TRIANGLES, 0, (2 + 2) * 6);
|
||||||
|
|
||||||
x += (ch.advance >> 6) * scale;
|
x += (ch.advance >> 6) * scale; // advance is stored in 1/64th pixels
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
text.h
1
text.h
@@ -7,6 +7,7 @@
|
|||||||
#include <ft2build.h>
|
#include <ft2build.h>
|
||||||
#include FT_FREETYPE_H
|
#include FT_FREETYPE_H
|
||||||
|
|
||||||
|
// Loads glyphs and configures OpenGL state to render HUD text via Freetype.
|
||||||
int init_text(int *shader);
|
int init_text(int *shader);
|
||||||
void render_text(int shader, std::string text, float x, float y, float scale, glm::vec3 color);
|
void render_text(int shader, std::string text, float x, float y, float scale, glm::vec3 color);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#version 460 core
|
#version 460 core
|
||||||
|
// Samples single-channel glyph texture and applies RGB tint.
|
||||||
in vec2 TexCoords;
|
in vec2 TexCoords;
|
||||||
out vec4 color;
|
out vec4 color;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#version 460 core
|
#version 460 core
|
||||||
|
// HUD text vertex shader: projects screen-space quad verts and forwards glyph UVs.
|
||||||
layout (location = 0) in vec4 vertex; // <vec2 pos, vec2 tex>
|
layout (location = 0) in vec4 vertex; // <vec2 pos, vec2 tex>
|
||||||
out vec2 TexCoords;
|
out vec2 TexCoords;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#version 460 core
|
#version 460 core
|
||||||
|
// Colors the trail geometry with a solid uniform tint.
|
||||||
out vec4 FragColor;
|
out vec4 FragColor;
|
||||||
|
|
||||||
in vec3 FragPos;
|
in vec3 FragPos;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#version 460 core
|
#version 460 core
|
||||||
|
// Simple vertex shader for the player's breadcrumb trail triangles.
|
||||||
layout (location = 0) in vec3 aPos;
|
layout (location = 0) in vec3 aPos;
|
||||||
|
|
||||||
out vec3 FragPos;
|
out vec3 FragPos;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#version 460 core
|
#version 460 core
|
||||||
|
// Basic vertex shader for world geometry (walls/floor). Transforms positions/normals into view space.
|
||||||
layout (location = 0) in vec3 aPos;
|
layout (location = 0) in vec3 aPos;
|
||||||
layout (location = 1) in vec3 aNormal;
|
layout (location = 1) in vec3 aNormal;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user