From 186d9f673a9edc6dce1c8cac2f85d153eb7dfa3d Mon Sep 17 00:00:00 2001 From: cowley Date: Tue, 19 May 2026 17:36:42 -0400 Subject: [PATCH] Enhancements to breadcrumb trail --- AGENTS.md | 27 ++- README.md | 2 + fragmentshader.glsl | 2 +- ghostfragshader.glsl | 2 +- ghostland.cpp | 536 ++++++++++++++++++++++++++++++++++------- ghostland.json | 15 +- ghostshader.glsl | 2 +- minimapfragshader.glsl | 2 +- minimapshader.glsl | 2 +- player.cpp | 2 +- shader.cpp | 4 +- textfragshader.glsl | 2 +- textshader.glsl | 4 +- trailfragshader.glsl | 15 +- trailshader.glsl | 12 +- vertexshader.glsl | 2 +- 16 files changed, 514 insertions(+), 117 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 85eb71a..88073c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # 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. +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`; keep defaults mirrored in code so omitted keys never cause null or invalid 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. @@ -12,6 +12,15 @@ Core gameplay lives in root-level C++ translation units: `ghostland.cpp` orchest ## 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`. +## Configuration Guidelines +`ghostland.json` is intentionally a flat runtime tuning file. The parser in `config.cpp` is a minimal line-oriented JSON subset reader: it expects one `"key": value` pair per line, ignores braces and comment-like lines, strips one trailing comma, and stores raw strings in a process-wide map. Do not rely on nested objects, arrays, inline comments after values, duplicate-key semantics beyond "last loaded wins", or full JSON validation unless you first replace the parser deliberately. + +Load config values once during startup after `Config::loadFromFile("ghostland.json")`, then copy them into typed globals or small config structs. Always use `Config::getFloat`, `Config::getInt`, or `Config::getString` with a meaningful in-code default; missing or malformed values should keep the game playable. Keep booleans as `0` / `1` integer keys for consistency with existing options. + +When adding or renaming a setting, update all three surfaces together: the struct or default value in code, the sample value in `ghostland.json`, and the README configuration table/example. Prefer descriptive key names grouped by system prefix (`minimap_*`, `trail_*`, `ghost_*`). If compatibility matters, read the legacy key first and let the new key override it, as with `trail_y_offset` falling back to `trail_y_position`. + +Validate numeric config values at the use site when bad ranges can break rendering or gameplay. Clamp or guard zero/negative values where appropriate (`std::max` for lifetimes, spacing, sizes, and shader divisors), and keep color channels documented as 0-1 floats. Avoid adding config knobs for internal implementation details unless they are useful for tuning gameplay, visuals, or reproduction. + ## 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. @@ -20,6 +29,16 @@ Existing history favors short, descriptive subjects ("Player's position is reset ## Minimap Implementation Plan - Add minimap config keys in `ghostland.json` (enable flag, width/height, margin, colors). After loading `maze.txt`, cache 2D wall segments plus world bounds for quick reuse. -- Create a lightweight 2D orthographic shader/VAO for overlay drawing; use GL_LINES for walls, triangle-fan circles for breadcrumbs/ghosts, and a small triangle for the player arrow. Disable depth test while rendering the overlay near the end of the frame. -- Each frame convert wall endpoints and breadcrumb/ghost positions relative to the player’s X/Z so the minimap scrolls with the player centered. Clip or skip segments outside the visible minimap extents. -- Draw order: white background quad, wall lines, breadcrumb dots, ghost dots (border then fill), red arrow rotated by player yaw. Re-enable depth test and continue with HUD text. +- Create a lightweight 2D orthographic shader/VAO for overlay drawing; use GL_LINES for walls, triangle quads for the trail stroke, triangle-fan circles for ghosts, and a small triangle for the player arrow. Disable depth test while rendering the overlay near the end of the frame. +- Each frame convert wall endpoints, trail points, and ghost positions relative to the player’s X/Z so the minimap scrolls with the player centered. Clip or skip segments outside the visible minimap extents. +- Draw order: white background quad, wall lines, trail stroke, ghost dots (border then fill), red arrow rotated by player yaw. Re-enable depth test and continue with HUD text. + +## Trail Implementation Overview +The old breadcrumb arrows have been replaced by a continuous time-limited trail rendered as a flat ribbon mesh in world space. `ghostland.cpp` owns the trail data structures: `TrailPoint` stores sampled X/Z player positions, timestamps, and a `starts_segment` flag that prevents respawns from drawing teleport lines. `TrailVertex` stores generated ribbon vertices plus the source timestamp. `update_trail()` prunes samples older than `trail_lifetime_minutes`, adds a new sample only after the player moves at least `trail_sample_spacing`, and removes nearly straight intermediate samples using `trail_simplify_epsilon`. + +`rebuild_trail_mesh()` turns adjacent trail samples into quad segments made from two triangles, avoiding driver-dependent OpenGL line width behavior. The VBO is updated only when samples are added, pruned, simplified, or a reset splits the trail. `trailshader.glsl` passes each vertex timestamp to `trailfragshader.glsl`, which blends from `trail_recent_*` color values toward `trail_old_*` based on age. Keep the shader GLSL version aligned with the requested OpenGL 3.3 context. + +Trail tuning belongs in `ghostland.json`: `trail_enabled`, `trail_lifetime_minutes`, `trail_sample_spacing`, `trail_width`, `trail_y_position`, `trail_simplify_epsilon`, `trail_reset_marker_size`, and RGB triplets for recent/old colors. The legacy `trail_y_offset` key is still accepted as a fallback. The minimap consumes the same trail point deque and renders a screen-space stroke whose half-width is controlled by `minimap_breadcrumb_radius`, so changes to sampling behavior affect both the world ribbon and overlay path. + +## Danger Tint Notes +The red danger tint is driven by the nearest ghost in X/Z space rather than 3D distance, so ghost bobbing does not change the warning. Keep the tint calculation independent of ghost render ordering: the partial sort is only for drawing. The tint also keeps persistent intensity with a 30-unit enter / 35-unit exit hysteresis band and frame-rate-independent smoothing; this prevents threshold jitter from flashing the camera between normal and red. diff --git a/README.md b/README.md index 7a92d65..09f754a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ While the previous project was implemented in go, for this project I was not abl | `minimap_breadcrumb_[rgb]` | float | `1.0 / 0.3 / 0.3` | Breadcrumb dot color per channel (0–1). | | `minimap_breadcrumb_radius` | float | `3.0` | Breadcrumb radius in pixels. | | `breadcrumb_max_distance` | float | `200.0` | Bread crumbs beyond this world-space distance from the player are not drawn (both in-world and on the minimap). | +| `trail_y_position` | float | `0.06` | World-space Y coordinate used to draw the trail ribbon and reset marker. `trail_y_offset` is still accepted as a legacy fallback. | | `minimap_ghost_fill_[rgb]` | float | `0.9 / 0.9 / 1.0` | Ghost dot interior color on the minimap. | | `minimap_ghost_border_[rgb]` | float | `0.2 / 0.2 / 0.4` | Ghost dot border color. | | `minimap_ghost_radius` | float | `4.0` | Ghost indicator radius in pixels. | @@ -54,6 +55,7 @@ Example: "minimap_breadcrumb_b": 0.3, "minimap_breadcrumb_radius": 3.0, "breadcrumb_max_distance": 200.0, + "trail_y_position": 0.06, "minimap_ghost_fill_r": 0.9, "minimap_ghost_fill_g": 0.9, "minimap_ghost_fill_b": 1.0, diff --git a/fragmentshader.glsl b/fragmentshader.glsl index 14acd00..365712d 100644 --- a/fragmentshader.glsl +++ b/fragmentshader.glsl @@ -1,4 +1,4 @@ -#version 460 core +#version 330 core // Phong lighting fragment shader driven by a single spotlight described in ghostland.cpp. out vec4 FragColor; diff --git a/ghostfragshader.glsl b/ghostfragshader.glsl index 5ad67c2..46d0090 100644 --- a/ghostfragshader.glsl +++ b/ghostfragshader.glsl @@ -1,4 +1,4 @@ -#version 460 core +#version 330 core // Ghost sprite fragment shader: samples alpha texture and fades opacity to 30%. out vec4 FragColor; diff --git a/ghostland.cpp b/ghostland.cpp index f14837f..0110d06 100644 --- a/ghostland.cpp +++ b/ghostland.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include "collisions.h" @@ -28,7 +29,6 @@ void framebuffer_size_callback(GLFWwindow* window, int width, int height); void mouse_callback(GLFWwindow* window, double xpos, double ypos); void scroll_callback(GLFWwindow* window, double xoffset, double yoffset); void set_light_front(int xoffset, int yoffset); -void render_minimap(const glm::vec3 &player_pos, float player_yaw, const glm::vec3 *trail_positions, int trail_sz, const std::vector &ghosts); // player Player *player; @@ -76,7 +76,52 @@ struct MinimapBounds { float zmax = 0.0f; }; +struct TrailConfig { + bool enabled = true; + float lifetime_minutes = 3.0f; + float sample_spacing = 1.0f; + float width = 0.25f; + float y_position = 0.06f; + float simplify_epsilon = 0.15f; + float reset_marker_size = 2.0f; + glm::vec3 recent_color = glm::vec3(1.0f, 0.0f, 0.0f); + glm::vec3 old_color = glm::vec3(0.35f, 0.18f, 0.05f); +}; + +struct TrailPoint { + glm::vec3 position; + float timestamp; + // True for the first point after spawn/reset; prevents drawing teleport segments. + bool starts_segment; +}; + +struct TrailVertex { + glm::vec3 position; + float timestamp; +}; + +struct TrailResetMarker { + glm::vec3 position; + // Player yaw at reset time, used to orient the flattened marker sprite. + float yaw; + float timestamp; +}; + +void render_minimap(const glm::vec3 &player_pos, float player_yaw, const std::deque &trail_points, const std::vector &ghosts); +bool update_trail(std::deque &trail_points, const glm::vec3 &player_pos, float current_time); +bool end_trail_segment(std::deque &trail_points, const glm::vec3 &player_pos, float current_time); +bool begin_trail_segment(std::deque &trail_points, const glm::vec3 &player_pos, float current_time); +void add_trail_reset_marker(std::deque &reset_markers, const glm::vec3 &player_pos, float player_yaw, float current_time); +bool prune_trail_reset_markers(std::deque &reset_markers, float current_time); +void render_trail_reset_markers(int ghost_program, unsigned int ghostVAO, const std::deque &reset_markers); +void rebuild_trail_mesh(const std::deque &trail_points, std::vector &trail_vertices); +float distance_xz(const glm::vec3 &a, const glm::vec3 &b); +bool is_redundant_trail_point(const TrailPoint &a, const TrailPoint &b, const TrailPoint &c); +glm::vec2 trail_segment_direction(const std::deque &trail_points, size_t from_ix, size_t to_ix); +glm::vec3 trail_join_offset(const std::deque &trail_points, size_t point_ix, const glm::vec2 &fallback_normal, float half_width); + MinimapConfig minimap_config; +TrailConfig trail_config; std::vector minimap_segments; MinimapBounds minimap_bounds; unsigned int minimapVAO = 0; @@ -87,7 +132,6 @@ const char *projectionC = "projection"; const char *viewC = "view"; const char *modelC = "model"; -const char *objectcolorC = "objectcolor"; const char *viewposC = "viewPos"; const char *shininessC = "material.shininess"; const char *colorC = "material.color"; @@ -102,8 +146,6 @@ const char *specularC = "light.specular"; int num_walls; float *wall_vertices; -int trailmax = 500; - int main(int argc, char *argv[]) { int success; @@ -134,6 +176,20 @@ int main(int argc, char *argv[]) { minimap_config.breadcrumb_color.b = Config::getFloat("minimap_breadcrumb_b", minimap_config.breadcrumb_color.b); minimap_config.breadcrumb_radius = Config::getFloat("minimap_breadcrumb_radius", minimap_config.breadcrumb_radius); minimap_config.breadcrumb_max_distance = Config::getFloat("breadcrumb_max_distance", minimap_config.breadcrumb_max_distance); + trail_config.enabled = Config::getInt("trail_enabled", trail_config.enabled ? 1 : 0) != 0; + trail_config.lifetime_minutes = Config::getFloat("trail_lifetime_minutes", trail_config.lifetime_minutes); + trail_config.sample_spacing = Config::getFloat("trail_sample_spacing", trail_config.sample_spacing); + trail_config.width = Config::getFloat("trail_width", trail_config.width); + trail_config.y_position = Config::getFloat("trail_y_offset", trail_config.y_position); + trail_config.y_position = Config::getFloat("trail_y_position", trail_config.y_position); + trail_config.simplify_epsilon = Config::getFloat("trail_simplify_epsilon", trail_config.simplify_epsilon); + trail_config.reset_marker_size = Config::getFloat("trail_reset_marker_size", trail_config.reset_marker_size); + trail_config.recent_color.r = Config::getFloat("trail_recent_r", trail_config.recent_color.r); + trail_config.recent_color.g = Config::getFloat("trail_recent_g", trail_config.recent_color.g); + trail_config.recent_color.b = Config::getFloat("trail_recent_b", trail_config.recent_color.b); + trail_config.old_color.r = Config::getFloat("trail_old_r", trail_config.old_color.r); + trail_config.old_color.g = Config::getFloat("trail_old_g", trail_config.old_color.g); + trail_config.old_color.b = Config::getFloat("trail_old_b", trail_config.old_color.b); minimap_config.ghost_fill_color.r = Config::getFloat("minimap_ghost_fill_r", minimap_config.ghost_fill_color.r); minimap_config.ghost_fill_color.g = Config::getFloat("minimap_ghost_fill_g", minimap_config.ghost_fill_color.g); minimap_config.ghost_fill_color.b = Config::getFloat("minimap_ghost_fill_b", minimap_config.ghost_fill_color.b); @@ -269,6 +325,9 @@ int main(int argc, char *argv[]) { return -1; } + printf("OpenGL version: %s\n", glGetString(GL_VERSION)); + printf("GLSL version: %s\n", glGetString(GL_SHADING_LANGUAGE_VERSION)); + glEnable(GL_DEPTH_TEST); //glEnable(GL_CULL_FACE); glEnable(GL_BLEND); @@ -282,7 +341,7 @@ int main(int argc, char *argv[]) { // Perhaps there's a better way of doing this, rather than re-compiling the same files? int floor_program = create_shader_program("vertexshader.glsl", "fragmentshader.glsl"); - if (wall_program < 0) { + if (floor_program < 0) { glfwTerminate(); return -1; } @@ -323,21 +382,8 @@ int main(int argc, char *argv[]) { glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float))); glEnableVertexAttribArray(1); - // do stuff for trail - float trail_vertices[] = { - 0.25f, 0.0f, 0.0f, - -0.25f, 0.0f, 0.5f, - -0.25f, 0.0f, -0.5f, - - -0.25f, 0.0f, 0.1f, - -0.25f, 0.0f, -0.1f, - -0.75f, 0.0f, 0.1f, - - -0.75f, 0.0f, 0.1f, - -0.75f, 0.0f, -0.1f, - -0.25f, 0.0f, -0.1f, - }; - + // The trail is a dynamic ribbon mesh. Each vertex carries the timestamp of + // its source path sample so the shader can fade red-to-brown by age. unsigned int trailVBO, trailVAO; glGenVertexArrays(1, &trailVAO); glGenBuffers(1, &trailVBO); @@ -345,10 +391,12 @@ int main(int argc, char *argv[]) { glBindVertexArray(trailVAO); glBindBuffer(GL_ARRAY_BUFFER, trailVBO); - glBufferData(GL_ARRAY_BUFFER, sizeof(float) * 3 * 3 * 3, trail_vertices, GL_STATIC_DRAW); + glBufferData(GL_ARRAY_BUFFER, sizeof(TrailVertex), NULL, GL_DYNAMIC_DRAW); - glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void *)0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(TrailVertex), (void *)0); glEnableVertexAttribArray(0); + glVertexAttribPointer(1, 1, GL_FLOAT, GL_FALSE, sizeof(TrailVertex), (void *)(3 * sizeof(float))); + glEnableVertexAttribArray(1); int trail_program = create_shader_program("trailshader.glsl", "trailfragshader.glsl"); if (trail_program < 0) { @@ -356,10 +404,10 @@ int main(int argc, char *argv[]) { return -1; } - glm::vec3 *trail_positions = (glm::vec3 *)calloc(sizeof(glm::vec3), trailmax); - float *trail_angles = (float *)calloc(sizeof(float), trailmax); - int trail_ix = 0; - int trail_sz = 0; + std::deque trail_points; + std::vector trail_vertices; + std::deque trail_reset_markers; + bool trail_dirty = update_trail(trail_points, player->get_pos(), 0.0f); // do stuff for ghosts // create program @@ -448,6 +496,10 @@ int main(int argc, char *argv[]) { glm::vec3 ambient = glm::vec3(0.007f, 0.006f, 0.005f); glm::vec3 diffuse = glm::vec3(0.61f, 0.60f, 0.59f); glm::vec3 specular = glm::vec3(0.11f, 0.10f, 0.09f); + // Persistent danger tint state. The target can change abruptly as ghosts move, + // so the displayed intensity is smoothed below to avoid camera flicker. + float danger_tint_intensity = 0.0f; + bool danger_tint_active = false; float largecutoff = glm::cos(glm::radians(30.0f)); float smallcutoff = glm::cos(glm::radians(7.5f)); @@ -484,15 +536,6 @@ int main(int argc, char *argv[]) { time_sec = current_frame; time_secI = current_frameI; num_frames = 1; - trail_positions[trail_ix].x = player_pos.x; - trail_positions[trail_ix].y = 6.5; - trail_positions[trail_ix].z = player_pos.z; - trail_angles[trail_ix] = yaw; - //printf("Recorded trail %d: (%f, %f, %f) %f\n", trail_ix, trail_positions[trail_ix].x, trail_positions[trail_ix].y, trail_positions[trail_ix].z, yaw); - trail_ix = (trail_ix + 1) % trailmax; - if (trail_sz < trailmax) { - trail_sz++; - } } else { // otherwise increase number of frames num_frames++; } @@ -531,8 +574,25 @@ int main(int argc, char *argv[]) { glm::vec3 light_front = player->get_light_front(); //printf("light_pos: (%f, %f, %f)\n", light_front.x, light_front.y, light_front.z); - // Sort ghosts by distance to player for optimization. glm::vec3 player_pos = player->get_pos(); + if (trail_config.enabled) { + trail_dirty = update_trail(trail_points, player_pos, current_frame) || trail_dirty; + prune_trail_reset_markers(trail_reset_markers, current_frame); + // Upload after sample/prune changes so the world trail VBO is ready + // when rendered later in this frame. The minimap reads the deque. + if (trail_dirty) { + rebuild_trail_mesh(trail_points, trail_vertices); + glBindBuffer(GL_ARRAY_BUFFER, trailVBO); + glBufferData( + GL_ARRAY_BUFFER, + std::max(trail_vertices.size(), 1) * sizeof(TrailVertex), + trail_vertices.empty() ? NULL : trail_vertices.data(), + GL_DYNAMIC_DRAW + ); + trail_dirty = false; + } + } + // Sort ghosts by distance to player for render-order optimization. struct { bool operator()(const Ghost *a, const Ghost *b) const { glm::vec3 a_diff, b_diff; @@ -547,21 +607,44 @@ int main(int argc, char *argv[]) { // 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); - // Calculate nearest ghost distance (first ghost is now closest) - float nearest_ghost_distance = glm::length(ghosts[0]->get_pos() - player_pos); + // Drive the danger tint from the actual nearest ghost. Use X/Z distance + // so vertical ghost bobbing cannot push the warning in and out. + float nearest_ghost_distance = distance_xz(ghosts[0]->get_pos(), player_pos); + for (size_t i = 1; i < ghosts.size(); i++) { + float ghost_distance = distance_xz(ghosts[i]->get_pos(), player_pos); + if (ghost_distance < nearest_ghost_distance) { + nearest_ghost_distance = ghost_distance; + } + } - // Interpolate flashlight color based on nearest ghost distance + // Interpolate flashlight color based on nearest ghost distance. The + // active band has hysteresis so tiny distance changes at the edge do + // not flicker the tint on/off. // At 30.0+ units: (0.61, 0.60, 0.59) - normal color // At 10.0 units: (0.99, 0.60, 0.59) - red shift + if (nearest_ghost_distance <= 30.0f) { + danger_tint_active = true; + } else if (nearest_ghost_distance >= 35.0f) { + danger_tint_active = false; + } + float color_factor = 1.0f; - if (nearest_ghost_distance < 30.0f) { + if (danger_tint_active) { color_factor = (nearest_ghost_distance - 10.0f) / (30.0f - 10.0f); color_factor = glm::clamp(color_factor, 0.0f, 1.0f); } + float target_danger_intensity = 1.0f - color_factor; + if (danger_tint_active) { + target_danger_intensity = std::max(target_danger_intensity, 0.35f); + } + // Attack quickly when danger rises, release more slowly when it falls. + float tint_response = target_danger_intensity > danger_tint_intensity ? 16.0f : 4.0f; + float tint_blend = 1.0f - exp(-tint_response * timed); + danger_tint_intensity = glm::mix(danger_tint_intensity, target_danger_intensity, tint_blend); glm::vec3 normal_color = glm::vec3(0.61f, 0.60f, 0.59f); glm::vec3 danger_color = glm::vec3(0.99f, 0.60f, 0.59f); - diffuse = glm::mix(danger_color, normal_color, color_factor); + diffuse = glm::mix(normal_color, danger_color, danger_tint_intensity); // fragment requirements set_uniform(wall_program, viewposC, camera_pos); @@ -599,31 +682,6 @@ int main(int argc, char *argv[]) { glBindVertexArray(floorVAO); glDrawArrays(GL_TRIANGLES, 0, 6 * 6 * 1); - glUseProgram(trail_program); - - set_uniform(trail_program, projectionC, projection); - set_uniform(trail_program, viewC, view); - glm::vec3 trail_color = glm::vec3(1.0f, 0.0f, 0.0f); - set_uniform(trail_program, objectcolorC, trail_color); - - float breadcrumb_draw_limit_sq = minimap_config.breadcrumb_max_distance > 0.0f - ? minimap_config.breadcrumb_max_distance * minimap_config.breadcrumb_max_distance - : -1.0f; - for (int i = 0; i < trail_sz; i++) { - if (breadcrumb_draw_limit_sq > 0.0f) { - glm::vec3 delta = trail_positions[i] - player_pos; - if (glm::dot(delta, delta) > breadcrumb_draw_limit_sq) { - continue; - } - } - glm::mat4 model = glm::mat4(1.0f); - model = glm::translate(model, trail_positions[i]); - model = glm::rotate(model, glm::radians(trail_angles[i]), glm::vec3(0.0f, -1.0f, 0.0f)); - set_uniform(trail_program, modelC, model); - glBindVertexArray(trailVAO); - glDrawArrays(GL_TRIANGLES, 0, 3 * 3 * 3); - } - glUseProgram(ghost_program); glActiveTexture(GL_TEXTURE0); @@ -631,6 +689,7 @@ int main(int argc, char *argv[]) { set_uniform(ghost_program, projectionC, projection); set_uniform(ghost_program, viewC, view); + bool player_reset_this_frame = false; for (int i = ghosts.size()/5; i >= 0; i--) { glm::mat4 ghost_model = ghosts[i]->get_model(camera_pos); ghosts[i]->apply_movement(current_frame, timed); @@ -639,8 +698,18 @@ int main(int argc, char *argv[]) { glm::vec3 ghost_pos = ghosts[i]->get_pos(); glm::vec3 player_pos = player->get_pos(); float distance = glm::length(ghost_pos - player_pos); - if (distance <= ghost_reset_distance) { + if (!player_reset_this_frame && distance <= ghost_reset_distance) { + glm::vec3 reset_pos = player->get_pos(); + float reset_yaw = player->get_yaw(); + if (trail_config.enabled) { + trail_dirty = end_trail_segment(trail_points, reset_pos, current_frame) || trail_dirty; + add_trail_reset_marker(trail_reset_markers, reset_pos, reset_yaw, current_frame); + } player->reset_position(start_position, start_yaw); + if (trail_config.enabled) { + trail_dirty = begin_trail_segment(trail_points, start_position, current_frame) || trail_dirty; + } + player_reset_this_frame = true; } // Nudge ghosts away from the spawn area so the player gets a breather. @@ -654,8 +723,48 @@ int main(int argc, char *argv[]) { glDrawArrays(GL_TRIANGLES, 0, (3 + 3 + 2) * 6); } + if (trail_config.enabled) { + // Ghost resets can split the trail after the initial upload above. + // Rebuild here before drawing so reset markers, world trail, and + // minimap all reflect the same reset on the current frame. + if (trail_dirty) { + rebuild_trail_mesh(trail_points, trail_vertices); + glBindBuffer(GL_ARRAY_BUFFER, trailVBO); + glBufferData( + GL_ARRAY_BUFFER, + std::max(trail_vertices.size(), 1) * sizeof(TrailVertex), + trail_vertices.empty() ? NULL : trail_vertices.data(), + GL_DYNAMIC_DRAW + ); + trail_dirty = false; + } + + if (!trail_vertices.empty()) { + glUseProgram(trail_program); + + set_uniform(trail_program, projectionC, projection); + set_uniform(trail_program, viewC, view); + set_uniform(trail_program, modelC, glm::mat4(1.0f)); + set_uniform(trail_program, "currentTime", current_frame); + set_uniform(trail_program, "maxAge", std::max(0.001f, trail_config.lifetime_minutes * 60.0f)); + set_uniform(trail_program, "recentColor", trail_config.recent_color); + set_uniform(trail_program, "oldColor", trail_config.old_color); + glBindVertexArray(trailVAO); + glDrawArrays(GL_TRIANGLES, 0, trail_vertices.size()); + } + + if (!trail_reset_markers.empty()) { + glUseProgram(ghost_program); + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, ghost_texture); + set_uniform(ghost_program, projectionC, projection); + set_uniform(ghost_program, viewC, view); + render_trail_reset_markers(ghost_program, ghostVAO, trail_reset_markers); + } + } + if (minimap_config.enabled) { - render_minimap(player->get_pos(), player->get_yaw(), trail_positions, trail_sz, ghosts); + render_minimap(player->get_pos(), player->get_yaw(), trail_points, ghosts); } if (FPS != -1) { @@ -680,6 +789,10 @@ int main(int argc, char *argv[]) { glDeleteVertexArrays(1, &floorVAO); glDeleteBuffers(1, &floorVBO); + glDeleteVertexArrays(1, &trailVAO); + glDeleteBuffers(1, &trailVBO); + glDeleteProgram(trail_program); + if (minimap_config.enabled) { glDeleteVertexArrays(1, &minimapVAO); glDeleteBuffers(1, &minimapVBO); @@ -702,7 +815,223 @@ void scroll_callback(GLFWwindow* window, double xoffset, double yoffset) { player->scroll_callback(window, xoffset, yoffset); } -void render_minimap(const glm::vec3 &player_pos, float player_yaw, const glm::vec3 *trail_positions, int trail_sz, const std::vector &ghosts) { +float distance_xz(const glm::vec3 &a, const glm::vec3 &b) { + float dx = a.x - b.x; + float dz = a.z - b.z; + return sqrt(dx * dx + dz * dz); +} + +bool is_redundant_trail_point(const TrailPoint &a, const TrailPoint &b, const TrailPoint &c) { + if (trail_config.simplify_epsilon <= 0.0f) { + return false; + } + + glm::vec2 ab(b.position.x - a.position.x, b.position.z - a.position.z); + glm::vec2 ac(c.position.x - a.position.x, c.position.z - a.position.z); + float ac_len_sq = glm::dot(ac, ac); + if (ac_len_sq <= 0.0001f) { + return false; + } + + float t = glm::dot(ab, ac) / ac_len_sq; + if (t <= 0.0f || t >= 1.0f) { + return false; + } + + glm::vec2 closest = ac * t; + return glm::length(ab - closest) <= trail_config.simplify_epsilon; +} + +bool update_trail(std::deque &trail_points, const glm::vec3 &player_pos, float current_time) { + if (!trail_config.enabled) { + bool had_points = !trail_points.empty(); + trail_points.clear(); + return had_points; + } + + bool dirty = false; + // Keep a time window instead of a fixed sample count so trail length is + // stable across different movement speeds and frame rates. + float max_age = std::max(0.001f, trail_config.lifetime_minutes * 60.0f); + while (!trail_points.empty() && current_time - trail_points.front().timestamp > max_age) { + trail_points.pop_front(); + dirty = true; + } + + glm::vec3 sample_pos(player_pos.x, trail_config.y_position, player_pos.z); + float min_spacing = std::max(0.001f, trail_config.sample_spacing); + if (trail_points.empty() || distance_xz(sample_pos, trail_points.back().position) >= min_spacing) { + TrailPoint new_point = {sample_pos, current_time, trail_points.empty()}; + if (trail_points.size() >= 2 && !trail_points.back().starts_segment) { + TrailPoint previous = trail_points.back(); + TrailPoint before_previous = trail_points[trail_points.size() - 2]; + if (!previous.starts_segment && is_redundant_trail_point(before_previous, previous, new_point)) { + trail_points.pop_back(); + } + } + trail_points.push_back(new_point); + dirty = true; + } + + return dirty; +} + +bool end_trail_segment(std::deque &trail_points, const glm::vec3 &player_pos, float current_time) { + glm::vec3 sample_pos(player_pos.x, trail_config.y_position, player_pos.z); + if (trail_points.empty()) { + trail_points.push_back({sample_pos, current_time, true}); + return true; + } + + if (distance_xz(sample_pos, trail_points.back().position) <= 0.0001f) { + // If the last regular sample is already at the reset location, refresh + // its age rather than adding a zero-length segment. + trail_points.back().position = sample_pos; + trail_points.back().timestamp = current_time; + return true; + } + + trail_points.push_back({sample_pos, current_time, false}); + return true; +} + +bool begin_trail_segment(std::deque &trail_points, const glm::vec3 &player_pos, float current_time) { + glm::vec3 sample_pos(player_pos.x, trail_config.y_position, player_pos.z); + if (!trail_points.empty() + && trail_points.back().starts_segment + && distance_xz(sample_pos, trail_points.back().position) <= 0.0001f) { + trail_points.back().timestamp = current_time; + return true; + } + + TrailPoint new_point = {sample_pos, current_time, true}; + trail_points.push_back(new_point); + return true; +} + +void add_trail_reset_marker(std::deque &reset_markers, const glm::vec3 &player_pos, float player_yaw, float current_time) { + glm::vec3 marker_pos(player_pos.x, trail_config.y_position + 0.002f, player_pos.z); + reset_markers.push_back({marker_pos, player_yaw, current_time}); +} + +bool prune_trail_reset_markers(std::deque &reset_markers, float current_time) { + bool dirty = false; + float max_age = std::max(0.001f, trail_config.lifetime_minutes * 60.0f); + while (!reset_markers.empty() && current_time - reset_markers.front().timestamp > max_age) { + reset_markers.pop_front(); + dirty = true; + } + return dirty; +} + +void render_trail_reset_markers(int ghost_program, unsigned int ghostVAO, const std::deque &reset_markers) { + if (trail_config.reset_marker_size <= 0.0f) { + return; + } + + glBindVertexArray(ghostVAO); + for (const TrailResetMarker &marker : reset_markers) { + glm::mat4 marker_model = glm::mat4(1.0f); + marker_model = glm::translate(marker_model, marker.position); + marker_model = glm::rotate(marker_model, glm::radians(90.0f - marker.yaw), glm::vec3(0.0f, 1.0f, 0.0f)); + marker_model = glm::rotate(marker_model, glm::radians(90.0f), glm::vec3(1.0f, 0.0f, 0.0f)); + marker_model = glm::scale(marker_model, glm::vec3(trail_config.reset_marker_size, trail_config.reset_marker_size, 1.0f)); + set_uniform(ghost_program, modelC, marker_model); + glDrawArrays(GL_TRIANGLES, 0, 6); + } +} + +glm::vec2 trail_segment_direction(const std::deque &trail_points, size_t from_ix, size_t to_ix) { + glm::vec3 delta = trail_points[to_ix].position - trail_points[from_ix].position; + glm::vec2 delta_xz(delta.x, delta.z); + float len = glm::length(delta_xz); + if (len <= 0.0001f) { + return glm::vec2(0.0f, 0.0f); + } + return delta_xz / len; +} + +glm::vec3 trail_join_offset(const std::deque &trail_points, size_t point_ix, const glm::vec2 &fallback_normal, float half_width) { + bool has_prev = point_ix > 0 && !trail_points[point_ix].starts_segment; + bool has_next = point_ix + 1 < trail_points.size() && !trail_points[point_ix + 1].starts_segment; + + if (!has_prev && !has_next) { + return glm::vec3(fallback_normal.x * half_width, 0.0f, fallback_normal.y * half_width); + } + + if (!has_prev || !has_next) { + glm::vec2 dir = has_prev + ? trail_segment_direction(trail_points, point_ix - 1, point_ix) + : trail_segment_direction(trail_points, point_ix, point_ix + 1); + if (glm::length(dir) <= 0.0001f) { + return glm::vec3(fallback_normal.x * half_width, 0.0f, fallback_normal.y * half_width); + } + glm::vec2 normal(-dir.y, dir.x); + return glm::vec3(normal.x * half_width, 0.0f, normal.y * half_width); + } + + glm::vec2 prev_dir = trail_segment_direction(trail_points, point_ix - 1, point_ix); + glm::vec2 next_dir = trail_segment_direction(trail_points, point_ix, point_ix + 1); + if (glm::length(prev_dir) <= 0.0001f || glm::length(next_dir) <= 0.0001f) { + return glm::vec3(fallback_normal.x * half_width, 0.0f, fallback_normal.y * half_width); + } + + glm::vec2 prev_normal(-prev_dir.y, prev_dir.x); + glm::vec2 next_normal(-next_dir.y, next_dir.x); + glm::vec2 miter = prev_normal + next_normal; + float miter_len = glm::length(miter); + if (miter_len <= 0.0001f) { + return glm::vec3(fallback_normal.x * half_width, 0.0f, fallback_normal.y * half_width); + } + + miter /= miter_len; + float denom = glm::dot(miter, fallback_normal); + if (denom <= 0.1f) { + return glm::vec3(fallback_normal.x * half_width, 0.0f, fallback_normal.y * half_width); + } + + // Clamp miters so tight turns do not produce long spikes in the ribbon. + float miter_scale = std::min(half_width / denom, half_width * 2.5f); + return glm::vec3(miter.x * miter_scale, 0.0f, miter.y * miter_scale); +} + +void rebuild_trail_mesh(const std::deque &trail_points, std::vector &trail_vertices) { + trail_vertices.clear(); + if (trail_points.size() < 2 || trail_config.width <= 0.0f) { + return; + } + + trail_vertices.reserve((trail_points.size() - 1) * 6); + float half_width = trail_config.width * 0.5f; + for (size_t i = 1; i < trail_points.size(); i++) { + const TrailPoint &p0 = trail_points[i - 1]; + const TrailPoint &p1 = trail_points[i]; + if (p1.starts_segment) { + continue; + } + glm::vec2 dir = trail_segment_direction(trail_points, i - 1, i); + if (glm::length(dir) <= 0.0001f) { + continue; + } + + glm::vec2 normal(-dir.y, dir.x); + glm::vec3 start_offset = trail_join_offset(trail_points, i - 1, normal, half_width); + glm::vec3 end_offset = trail_join_offset(trail_points, i, normal, half_width); + TrailVertex v0 = {p0.position + start_offset, p0.timestamp}; + TrailVertex v1 = {p0.position - start_offset, p0.timestamp}; + TrailVertex v2 = {p1.position + end_offset, p1.timestamp}; + TrailVertex v3 = {p1.position - end_offset, p1.timestamp}; + + trail_vertices.push_back(v0); + trail_vertices.push_back(v1); + trail_vertices.push_back(v2); + trail_vertices.push_back(v2); + trail_vertices.push_back(v1); + trail_vertices.push_back(v3); + } +} + +void render_minimap(const glm::vec3 &player_pos, float player_yaw, const std::deque &trail_points, const std::vector &ghosts) { // 2D overlay renders last so sorting/blending for 3D content is unaffected. if (!minimap_config.enabled || minimap_program < 0) { return; @@ -761,35 +1090,62 @@ void render_minimap(const glm::vec3 &player_pos, float player_yaw, const glm::ve } if (!line_vertices.empty()) { glBindBuffer(GL_ARRAY_BUFFER, minimapVBO); - glBufferSubData(GL_ARRAY_BUFFER, 0, line_vertices.size() * sizeof(float), line_vertices.data()); + glBufferData(GL_ARRAY_BUFFER, line_vertices.size() * sizeof(float), line_vertices.data(), GL_DYNAMIC_DRAW); set_uniform(minimap_program, "inColor", minimap_config.wall_color); glDrawArrays(GL_LINES, 0, line_vertices.size() / 2); } - // Breadcrumbs mark recently recorded player positions. - if (trail_positions != nullptr && trail_sz > 0 && minimap_config.breadcrumb_radius > 0.0f) { - const int circle_segments = 12; - const float angle_step = glm::two_pi() / static_cast(circle_segments); - float radius = minimap_config.breadcrumb_radius; - float max_dist_sq = minimap_config.breadcrumb_max_distance > 0.0f ? minimap_config.breadcrumb_max_distance * minimap_config.breadcrumb_max_distance : -1.0f; - std::vector circle_vertices((circle_segments + 2) * 2); - for (int i = 0; i < trail_sz; i++) { - glm::vec2 rel = glm::vec2(trail_positions[i].x, trail_positions[i].z) - player_flat; - if (max_dist_sq > 0.0f && glm::dot(rel, rel) > max_dist_sq) { + // The minimap mirrors the world trail as a real screen-space stroke. The + // old breadcrumb radius now acts as stroke half-width in pixels. + if (trail_points.size() >= 2 && minimap_config.breadcrumb_radius > 0.0f) { + static std::vector trail_line_vertices; + trail_line_vertices.clear(); + trail_line_vertices.reserve((trail_points.size() - 1) * 12); + float max_dist_sq = minimap_config.breadcrumb_max_distance > 0.0f + ? minimap_config.breadcrumb_max_distance * minimap_config.breadcrumb_max_distance + : -1.0f; + float half_width = minimap_config.breadcrumb_radius; + for (size_t i = 1; i < trail_points.size(); i++) { + if (trail_points[i].starts_segment) { continue; } - glm::vec2 center = map_center + rel * scale; - circle_vertices[0] = center.x; - circle_vertices[1] = center.y; - for (int seg = 0; seg <= circle_segments; seg++) { - float angle = seg * angle_step; - circle_vertices[(seg + 1) * 2 + 0] = center.x + cos(angle) * radius; - circle_vertices[(seg + 1) * 2 + 1] = center.y + sin(angle) * radius; + glm::vec2 rel0 = glm::vec2(trail_points[i - 1].position.x, trail_points[i - 1].position.z) - player_flat; + glm::vec2 rel1 = glm::vec2(trail_points[i].position.x, trail_points[i].position.z) - player_flat; + if (max_dist_sq > 0.0f && glm::dot(rel0, rel0) > max_dist_sq && glm::dot(rel1, rel1) > max_dist_sq) { + continue; } + glm::vec2 p0 = map_center + rel0 * scale; + glm::vec2 p1 = map_center + rel1 * scale; + glm::vec2 segment = p1 - p0; + float segment_len = glm::length(segment); + if (segment_len <= 0.0001f) { + continue; + } + + glm::vec2 normal = glm::vec2(-segment.y, segment.x) / segment_len * half_width; + glm::vec2 p0_left = p0 + normal; + glm::vec2 p0_right = p0 - normal; + glm::vec2 p1_left = p1 + normal; + glm::vec2 p1_right = p1 - normal; + + trail_line_vertices.push_back(p0_left.x); + trail_line_vertices.push_back(p0_left.y); + trail_line_vertices.push_back(p0_right.x); + trail_line_vertices.push_back(p0_right.y); + trail_line_vertices.push_back(p1_left.x); + trail_line_vertices.push_back(p1_left.y); + trail_line_vertices.push_back(p1_left.x); + trail_line_vertices.push_back(p1_left.y); + trail_line_vertices.push_back(p0_right.x); + trail_line_vertices.push_back(p0_right.y); + trail_line_vertices.push_back(p1_right.x); + trail_line_vertices.push_back(p1_right.y); + } + if (!trail_line_vertices.empty()) { glBindBuffer(GL_ARRAY_BUFFER, minimapVBO); - glBufferSubData(GL_ARRAY_BUFFER, 0, circle_vertices.size() * sizeof(float), circle_vertices.data()); + glBufferData(GL_ARRAY_BUFFER, trail_line_vertices.size() * sizeof(float), trail_line_vertices.data(), GL_DYNAMIC_DRAW); set_uniform(minimap_program, "inColor", minimap_config.breadcrumb_color); - glDrawArrays(GL_TRIANGLE_FAN, 0, circle_vertices.size() / 2); + glDrawArrays(GL_TRIANGLES, 0, trail_line_vertices.size() / 2); } } @@ -808,7 +1164,7 @@ void render_minimap(const glm::vec3 &player_pos, float player_yaw, const glm::ve ghost_vertices[(seg + 1) * 2 + 1] = center.y + sin(angle) * radius; } glBindBuffer(GL_ARRAY_BUFFER, minimapVBO); - glBufferSubData(GL_ARRAY_BUFFER, 0, ghost_vertices.size() * sizeof(float), ghost_vertices.data()); + glBufferData(GL_ARRAY_BUFFER, ghost_vertices.size() * sizeof(float), ghost_vertices.data(), GL_DYNAMIC_DRAW); set_uniform(minimap_program, "inColor", color); glDrawArrays(GL_TRIANGLE_FAN, 0, ghost_vertices.size() / 2); }; diff --git a/ghostland.json b/ghostland.json index 0fa82f9..c02b811 100644 --- a/ghostland.json +++ b/ghostland.json @@ -27,5 +27,18 @@ "minimap_ghost_border_g": 0.2, "minimap_ghost_border_b": 0.4, "minimap_ghost_radius": 4.0, - "breadcrumb_max_distance": 200.0 + "breadcrumb_max_distance": 200.0, + "trail_enabled": 1, + "trail_lifetime_minutes": 3.0, + "trail_sample_spacing": 1.0, + "trail_width": 0.25, + "trail_y_position": 10.0, + "trail_simplify_epsilon": 0.15, + "trail_reset_marker_size": 2.0, + "trail_recent_r": 1.0, + "trail_recent_g": 0.0, + "trail_recent_b": 0.0, + "trail_old_r": 0.35, + "trail_old_g": 0.18, + "trail_old_b": 0.05 } diff --git a/ghostshader.glsl b/ghostshader.glsl index 7bce6d0..fb194d0 100644 --- a/ghostshader.glsl +++ b/ghostshader.glsl @@ -1,4 +1,4 @@ -#version 460 core +#version 330 core // Billboarded ghost vertex shader: passes world pos/normal and flips UV.y for texture atlases. layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal; diff --git a/minimapfragshader.glsl b/minimapfragshader.glsl index ad646f1..241fddf 100644 --- a/minimapfragshader.glsl +++ b/minimapfragshader.glsl @@ -1,4 +1,4 @@ -#version 460 core +#version 330 core // Solid-color fragment shader for minimap overlay primitives. out vec4 FragColor; diff --git a/minimapshader.glsl b/minimapshader.glsl index fdcd896..8de9144 100644 --- a/minimapshader.glsl +++ b/minimapshader.glsl @@ -1,4 +1,4 @@ -#version 460 core +#version 330 core // Vertex shader for minimap overlay geometry (lines/quads) in screen space. layout (location = 0) in vec2 aPos; diff --git a/player.cpp b/player.cpp index 9462395..303f1ad 100644 --- a/player.cpp +++ b/player.cpp @@ -14,7 +14,7 @@ Player::Player(glm::vec3 startpos, float startyaw) { first_frame = false; light_xpersist = 0.0f; light_ypersist = 0.0f; - armlength = 0.5f; + armlength = 0.0f; camera_offset = glm::vec3(0.0f, 2.5f, 0.0f); camera_up = glm::vec3(0.0f, 1.0f, 0.0f); fov = 60.0f; diff --git a/shader.cpp b/shader.cpp index e3ab986..9ff693c 100644 --- a/shader.cpp +++ b/shader.cpp @@ -9,7 +9,7 @@ int create_shader(const char *filename, int shadertype) { FILE* fp = fopen(filename, "r"); if (!fp) { - printf("failed to open vertex shader!\n"); + printf("failed to open shader file %s!\n", filename); return -1; } fseek(fp, 0L, SEEK_END); @@ -18,7 +18,7 @@ int create_shader(const char *filename, int shadertype) { char *buffer = (char *)calloc(sizeof(char), buffer_sz + 1); if (fread(buffer, buffer_sz, 1, fp) != 1) { - printf("failed to read vertex shader!\n"); + printf("failed to read shader file %s!\n", filename); return -1; } fclose(fp); diff --git a/textfragshader.glsl b/textfragshader.glsl index e04d0bd..d8a7432 100644 --- a/textfragshader.glsl +++ b/textfragshader.glsl @@ -1,4 +1,4 @@ -#version 460 core +#version 330 core // Samples single-channel glyph texture and applies RGB tint. in vec2 TexCoords; out vec4 color; diff --git a/textshader.glsl b/textshader.glsl index bfe59cd..ac0bfe0 100644 --- a/textshader.glsl +++ b/textshader.glsl @@ -1,4 +1,4 @@ -#version 460 core +#version 330 core // HUD text vertex shader: projects screen-space quad verts and forwards glyph UVs. layout (location = 0) in vec4 vertex; // out vec2 TexCoords; @@ -7,6 +7,6 @@ uniform mat4 projection; void main() { - gl_Position = projection * vec4(vertex.xy, 0.0f, 1.0); + gl_Position = projection * vec4(vertex.xy, 0.0, 1.0); TexCoords = vertex.zw; } diff --git a/trailfragshader.glsl b/trailfragshader.glsl index 47f68ca..f2f3d25 100644 --- a/trailfragshader.glsl +++ b/trailfragshader.glsl @@ -1,12 +1,17 @@ -#version 460 core -// Colors the trail geometry with a solid uniform tint. +#version 330 core +// Colors the trail geometry by age: newest samples are red and old samples +// fade toward brown before they expire from the CPU-side trail window. out vec4 FragColor; -in vec3 FragPos; +in float TrailTimestamp; -uniform vec3 objectcolor; +uniform float currentTime; +uniform float maxAge; +uniform vec3 recentColor; +uniform vec3 oldColor; void main() { - FragColor = vec4(objectcolor, 1.0); + float ageRatio = clamp((currentTime - TrailTimestamp) / maxAge, 0.0, 1.0); + FragColor = vec4(mix(recentColor, oldColor, ageRatio), 1.0); } diff --git a/trailshader.glsl b/trailshader.glsl index ad61cf3..70d639f 100644 --- a/trailshader.glsl +++ b/trailshader.glsl @@ -1,8 +1,10 @@ -#version 460 core -// Simple vertex shader for the player's breadcrumb trail triangles. +#version 330 core +// Dynamic ribbon trail vertex shader. Timestamp is used by the fragment shader +// to fade recent path segments from red toward brown as they age. layout (location = 0) in vec3 aPos; +layout (location = 1) in float aTimestamp; -out vec3 FragPos; +out float TrailTimestamp; uniform mat4 model; uniform mat4 view; @@ -10,7 +12,7 @@ uniform mat4 projection; void main() { - FragPos = vec3(model * vec4(aPos, 1.0)); + TrailTimestamp = aTimestamp; - gl_Position = projection * view * vec4(FragPos, 1.0); + gl_Position = projection * view * model * vec4(aPos, 1.0); } diff --git a/vertexshader.glsl b/vertexshader.glsl index 4233943..7db3ca0 100644 --- a/vertexshader.glsl +++ b/vertexshader.glsl @@ -1,4 +1,4 @@ -#version 460 core +#version 330 core // Basic vertex shader for world geometry (walls/floor). Transforms positions/normals into view space. layout (location = 0) in vec3 aPos; layout (location = 1) in vec3 aNormal;