Enhancements to breadcrumb trail

This commit is contained in:
2026-05-19 17:36:42 -04:00
parent 7235d756dc
commit 186d9f673a
16 changed files with 514 additions and 117 deletions

View File

@@ -14,6 +14,7 @@
#include <iostream>
#include <filesystem>
#include <vector>
#include <deque>
#include <math.h>
#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<Ghost *> &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<TrailPoint> &trail_points, const std::vector<Ghost *> &ghosts);
bool update_trail(std::deque<TrailPoint> &trail_points, const glm::vec3 &player_pos, float current_time);
bool end_trail_segment(std::deque<TrailPoint> &trail_points, const glm::vec3 &player_pos, float current_time);
bool begin_trail_segment(std::deque<TrailPoint> &trail_points, const glm::vec3 &player_pos, float current_time);
void add_trail_reset_marker(std::deque<TrailResetMarker> &reset_markers, const glm::vec3 &player_pos, float player_yaw, float current_time);
bool prune_trail_reset_markers(std::deque<TrailResetMarker> &reset_markers, float current_time);
void render_trail_reset_markers(int ghost_program, unsigned int ghostVAO, const std::deque<TrailResetMarker> &reset_markers);
void rebuild_trail_mesh(const std::deque<TrailPoint> &trail_points, std::vector<TrailVertex> &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<TrailPoint> &trail_points, size_t from_ix, size_t to_ix);
glm::vec3 trail_join_offset(const std::deque<TrailPoint> &trail_points, size_t point_ix, const glm::vec2 &fallback_normal, float half_width);
MinimapConfig minimap_config;
TrailConfig trail_config;
std::vector<MinimapSegment> 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<TrailPoint> trail_points;
std::vector<TrailVertex> trail_vertices;
std::deque<TrailResetMarker> 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<size_t>(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<size_t>(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<Ghost *> &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<TrailPoint> &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<TrailPoint> &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<TrailPoint> &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<TrailResetMarker> &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<TrailResetMarker> &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<TrailResetMarker> &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<TrailPoint> &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<TrailPoint> &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<TrailPoint> &trail_points, std::vector<TrailVertex> &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<TrailPoint> &trail_points, const std::vector<Ghost *> &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<float>() / static_cast<float>(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<float> 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<float> 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);
};