Files
radar-simulation/src/main.cpp
2026-04-04 22:26:38 -07:00

964 lines
36 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Radar Simulation — Feature Test: 1, 2 & 3
// Feature 1: Initialize display, draw scope boundaries (PPI circle, A scope box)
// Feature 2: PPI bearing ring with tick marks and degree labels
// Feature 3: Replaceable A scope graticule — cycles through 2/5/10/15 mi ranges,
// 5 s hold per range, 0.5 s slide animation between ranges.
// Press ESC to exit.
#include <glad/glad.h>
#include <GLFW/glfw3.h>
#include <ft2build.h>
#include FT_FREETYPE_H
#include <cmath>
#include <vector>
#include <string>
#include <fstream>
#include <sstream>
#include <iostream>
#include <algorithm>
#include <cstring>
// ─── Constants ───────────────────────────────────────────────────────────────
static constexpr float PI = 3.14159265358979323846f;
static constexpr int CIRCLE_SEGS = 360;
// Incandescent (warm lamp) — bearing graticule and A scope graticule
static constexpr float INCAN_R = 1.00f;
static constexpr float INCAN_G = 0.78f;
static constexpr float INCAN_B = 0.35f;
// P1 phosphor (green) — A scope boundary
static constexpr float P1_R = 0.00f;
static constexpr float P1_G = 0.90f;
static constexpr float P1_B = 0.20f;
// Digits rendered into the font atlas: '0''9'
static constexpr int GLYPH_FIRST = '0';
static constexpr int GLYPH_COUNT = 10;
// Feature 3 timing
static constexpr float HOLD_SEC = 5.0f;
static constexpr float SLIDE_SEC = 0.5f;
// P7 phosphor — active (blueish white) and persistence (greenish yellow)
static constexpr float P7A_R = 0.85f, P7A_G = 0.92f, P7A_B = 1.00f;
static constexpr float P7P_R = 0.35f, P7P_G = 0.88f, P7P_B = 0.18f;
// Sweep / persistence
static constexpr float SWEEP_DEG_PS = 20.0f * 6.0f; // 20 RPM → 120 °/s
static constexpr float TRAIL_DEG = 50.0f; // lit arc behind sweep (°)
static constexpr float TARG_PERSIST = 5.0f; // target glow lifetime (s)
static constexpr int RING_SEGS = 300;
static constexpr int TRAIL_SEGS = 50;
// ─── NDC helpers ─────────────────────────────────────────────────────────────
static inline float ndcX(float px, float W) { return px / W * 2.0f - 1.0f; }
static inline float ndcY(float py, float H) { return 1.0f - py / H * 2.0f; }
// ─── Shader utilities ────────────────────────────────────────────────────────
static std::string readFile(const std::string& path)
{
std::ifstream f(path);
if (!f) { std::cerr << "Cannot read shader: " << path << "\n"; return ""; }
return { std::istreambuf_iterator<char>(f), std::istreambuf_iterator<char>() };
}
static GLuint compileShader(GLenum type, const std::string& src)
{
GLuint sh = glCreateShader(type);
const char* s = src.c_str();
glShaderSource(sh, 1, &s, nullptr);
glCompileShader(sh);
GLint ok; glGetShaderiv(sh, GL_COMPILE_STATUS, &ok);
if (!ok) {
char log[512]; glGetShaderInfoLog(sh, 512, nullptr, log);
std::cerr << "Shader compile: " << log << "\n";
}
return sh;
}
static GLuint makeProgram(const std::string& vp, const std::string& fp)
{
GLuint vs = compileShader(GL_VERTEX_SHADER, readFile(vp));
GLuint fs = compileShader(GL_FRAGMENT_SHADER, readFile(fp));
GLuint prog = glCreateProgram();
glAttachShader(prog, vs); glAttachShader(prog, fs);
glLinkProgram(prog);
GLint ok; glGetProgramiv(prog, GL_LINK_STATUS, &ok);
if (!ok) {
char log[512]; glGetProgramInfoLog(prog, 512, nullptr, log);
std::cerr << "Program link: " << log << "\n";
}
glDeleteShader(vs); glDeleteShader(fs);
return prog;
}
// ─── VAO / VBO helpers ───────────────────────────────────────────────────────
// 2-float-per-vertex (NDC positions only)
static void makeLineVAO(GLuint& vao, GLuint& vbo, const std::vector<float>& v)
{
glGenVertexArrays(1, &vao); glGenBuffers(1, &vbo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, v.size() * sizeof(float), v.data(), GL_STATIC_DRAW);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), nullptr);
glEnableVertexAttribArray(0);
glBindVertexArray(0);
}
// 4-float-per-vertex (NDC x,y + UV u,v)
static void makeTextVAO(GLuint& vao, GLuint& vbo, const std::vector<float>& v)
{
glGenVertexArrays(1, &vao); glGenBuffers(1, &vbo);
glBindVertexArray(vao);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, v.size() * sizeof(float), v.data(), GL_STATIC_DRAW);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), nullptr);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float),
reinterpret_cast<void*>(2 * sizeof(float)));
glEnableVertexAttribArray(1);
glBindVertexArray(0);
}
// ─── FreeType font atlas ─────────────────────────────────────────────────────
struct GlyphInfo {
int atlasX;
int bitmapW;
int bitmapH;
int bearingX;
int bearingY;
int advance;
};
struct FontAtlas {
GLuint texture = 0;
int atlasW = 0;
int atlasH = 0;
GlyphInfo glyphs[GLYPH_COUNT]{};
};
static bool buildFontAtlas(FontAtlas& fa, const std::string& fontPath, int sizePx)
{
FT_Library ft;
if (FT_Init_FreeType(&ft)) {
std::cerr << "FreeType init failed\n"; return false;
}
FT_Face face;
if (FT_New_Face(ft, fontPath.c_str(), 0, &face)) {
std::cerr << "FT_New_Face failed: " << fontPath << "\n";
FT_Done_FreeType(ft); return false;
}
FT_Set_Pixel_Sizes(face, 0, sizePx);
int totalW = 0, maxH = 0;
for (int i = 0; i < GLYPH_COUNT; ++i) {
if (FT_Load_Char(face, GLYPH_FIRST + i, FT_LOAD_RENDER)) continue;
FT_GlyphSlot g = face->glyph;
totalW += (int)g->bitmap.width + 2;
maxH = std::max(maxH, (int)g->bitmap.rows);
}
fa.atlasW = totalW;
fa.atlasH = maxH;
std::vector<uint8_t> atlas(fa.atlasW * fa.atlasH, 0);
int xOff = 0;
for (int i = 0; i < GLYPH_COUNT; ++i) {
if (FT_Load_Char(face, GLYPH_FIRST + i, FT_LOAD_RENDER)) continue;
FT_GlyphSlot g = face->glyph;
GlyphInfo& gi = fa.glyphs[i];
gi.atlasX = xOff;
gi.bitmapW = (int)g->bitmap.width;
gi.bitmapH = (int)g->bitmap.rows;
gi.bearingX = g->bitmap_left;
gi.bearingY = g->bitmap_top;
gi.advance = (int)(g->advance.x >> 6);
for (int row = 0; row < gi.bitmapH; ++row) {
const uint8_t* src = g->bitmap.buffer + row * std::abs(g->bitmap.pitch);
uint8_t* dst = atlas.data() + row * fa.atlasW + xOff;
std::memcpy(dst, src, gi.bitmapW);
}
xOff += gi.bitmapW + 2;
}
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glGenTextures(1, &fa.texture);
glBindTexture(GL_TEXTURE_2D, fa.texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RED,
fa.atlasW, fa.atlasH, 0,
GL_RED, GL_UNSIGNED_BYTE, atlas.data());
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);
glPixelStorei(GL_UNPACK_ALIGNMENT, 4);
FT_Done_Face(face);
FT_Done_FreeType(ft);
return true;
}
// Append NDC quad vertices for string s, visually centered at screen pixel (cx, cy).
// Only digits '0''9' are supported.
static void appendTextQuads(std::vector<float>& verts, const FontAtlas& fa,
const std::string& s, float cx, float cy,
float W, float H)
{
if (s.empty()) return;
float totalAdv = 0.0f;
int maxBY = 0;
int minBY = 0;
for (char c : s) {
int i = (int)c - GLYPH_FIRST;
if (i < 0 || i >= GLYPH_COUNT) continue;
const GlyphInfo& g = fa.glyphs[i];
totalAdv += g.advance;
maxBY = std::max(maxBY, g.bearingY);
minBY = std::min(minBY, g.bearingY - g.bitmapH);
}
float visualCenterAboveBaseline = (maxBY + minBY) * 0.5f;
float baselineY = cy + visualCenterAboveBaseline;
float cursorX = cx - totalAdv * 0.5f;
for (char c : s) {
int i = (int)c - GLYPH_FIRST;
if (i < 0 || i >= GLYPH_COUNT) continue;
const GlyphInfo& g = fa.glyphs[i];
float gx0 = cursorX + g.bearingX;
float gy0 = baselineY - g.bearingY;
float gx1 = gx0 + g.bitmapW;
float gy1 = gy0 + g.bitmapH;
float u0 = (float) g.atlasX / fa.atlasW;
float u1 = (float)(g.atlasX + g.bitmapW)/ fa.atlasW;
float v0 = 0.0f;
float v1 = (float) g.bitmapH / fa.atlasH;
verts.push_back(ndcX(gx0,W)); verts.push_back(ndcY(gy0,H)); verts.push_back(u0); verts.push_back(v0);
verts.push_back(ndcX(gx1,W)); verts.push_back(ndcY(gy0,H)); verts.push_back(u1); verts.push_back(v0);
verts.push_back(ndcX(gx1,W)); verts.push_back(ndcY(gy1,H)); verts.push_back(u1); verts.push_back(v1);
verts.push_back(ndcX(gx0,W)); verts.push_back(ndcY(gy0,H)); verts.push_back(u0); verts.push_back(v0);
verts.push_back(ndcX(gx1,W)); verts.push_back(ndcY(gy1,H)); verts.push_back(u1); verts.push_back(v1);
verts.push_back(ndcX(gx0,W)); verts.push_back(ndcY(gy1,H)); verts.push_back(u0); verts.push_back(v1);
cursorX += g.advance;
}
}
// ─── Layout ──────────────────────────────────────────────────────────────────
struct Layout {
float ppiCX, ppiCY, ppiR;
float asLeft, asTop, asRight, asBot;
};
static Layout computeLayout(float W, float H, float marginPx)
{
Layout L{};
float availW = (W - marginPx) - W * 0.5f;
float availH = (H - marginPx) - marginPx;
L.ppiR = std::min(availW, availH) * 0.5f;
L.ppiCX = W * 0.5f + availW * 0.5f;
L.ppiCY = marginPx + availH * 0.5f;
float asW = (W * 0.5f - 2.0f * marginPx) * 0.65f;
float asH = H * 0.22f;
L.asLeft = marginPx;
L.asRight = marginPx + asW;
L.asTop = (H - asH) * 0.5f;
L.asBot = L.asTop + asH;
return L;
}
// ─── Feature 1: Scope boundaries ─────────────────────────────────────────────
struct ScopeBounds {
GLuint prog = 0, vao = 0, vbo = 0;
int ppiStart = 0, ppiCount = 0;
int asStart = 0, asCount = 0;
};
static ScopeBounds buildScopeBounds(const Layout& L, float W, float H)
{
ScopeBounds sb{};
std::vector<float> v;
sb.ppiStart = 0;
sb.ppiCount = CIRCLE_SEGS + 1;
for (int i = 0; i <= CIRCLE_SEGS; ++i) {
float a = 2.0f * PI * i / CIRCLE_SEGS;
v.push_back(ndcX(L.ppiCX + L.ppiR * std::cos(a), W));
v.push_back(ndcY(L.ppiCY + L.ppiR * std::sin(a), H));
}
sb.asStart = sb.ppiStart + sb.ppiCount;
sb.asCount = 8;
auto ln = [&](float x1, float y1, float x2, float y2) {
v.push_back(ndcX(x1,W)); v.push_back(ndcY(y1,H));
v.push_back(ndcX(x2,W)); v.push_back(ndcY(y2,H));
};
ln(L.asLeft, L.asTop, L.asRight, L.asTop);
ln(L.asRight, L.asTop, L.asRight, L.asBot);
ln(L.asRight, L.asBot, L.asLeft, L.asBot);
ln(L.asLeft, L.asBot, L.asLeft, L.asTop);
makeLineVAO(sb.vao, sb.vbo, v);
sb.prog = makeProgram("shaders/scope_bounds.vert", "shaders/scope_bounds.frag");
return sb;
}
// ─── Feature 2: PPI bearing graticule ────────────────────────────────────────
struct BearingGraticule {
GLuint lineProg = 0, textProg = 0;
GLuint lineVAO = 0, lineVBO = 0;
GLuint textVAO = 0, textVBO = 0;
int ringStart = 0, ringCount = 0;
int tickStart = 0, tickCount = 0;
int textVerts = 0;
GLuint fontTex = 0;
};
static BearingGraticule buildBearingGraticule(const Layout& L, const FontAtlas& fa,
float W, float H)
{
BearingGraticule bg{};
const float cx = L.ppiCX, cy = L.ppiCY, R = L.ppiR;
std::vector<float> lineV;
bg.ringStart = 0;
bg.ringCount = CIRCLE_SEGS + 1;
for (int i = 0; i <= CIRCLE_SEGS; ++i) {
float a = 2.0f * PI * i / CIRCLE_SEGS;
lineV.push_back(ndcX(cx + R * std::cos(a), W));
lineV.push_back(ndcY(cy + R * std::sin(a), H));
}
const float majorLen = 0.055f * R;
const float minorLen = 0.025f * R;
bg.tickStart = bg.ringStart + bg.ringCount;
bg.tickCount = 360 * 2;
for (int b = 0; b < 360; ++b) {
float brad = b * PI / 180.0f;
float sb = std::sin(brad);
float cb = std::cos(brad);
float len = (b % 10 == 0) ? majorLen : minorLen;
lineV.push_back(ndcX(cx + R * sb, W));
lineV.push_back(ndcY(cy - R * cb, H));
lineV.push_back(ndcX(cx + (R-len) * sb, W));
lineV.push_back(ndcY(cy - (R-len) * cb, H));
}
makeLineVAO(bg.lineVAO, bg.lineVBO, lineV);
std::vector<float> textV;
const float textR = R * 1.07f;
for (int b = 0; b < 360; b += 10) {
float brad = b * PI / 180.0f;
float tx = cx + textR * std::sin(brad);
float ty = cy - textR * std::cos(brad);
appendTextQuads(textV, fa, std::to_string(b), tx, ty, W, H);
}
bg.textVerts = (int)textV.size() / 4;
bg.fontTex = fa.texture;
makeTextVAO(bg.textVAO, bg.textVBO, textV);
bg.lineProg = makeProgram("shaders/ppi_bearing.vert", "shaders/ppi_bearing.frag");
bg.textProg = makeProgram("shaders/text.vert", "shaders/text.frag");
return bg;
}
// ─── Feature 3: A scope replaceable graticule ────────────────────────────────
// One entry per selectable range. The graticule has numMajor labeled ticks
// (full-height vertical lines) and numMinorPerMajor minor ticks between each
// pair of major ticks. Label values are computed as (m * maxMiles / numMajor).
struct RangeConfig {
float maxMiles;
int numMajor;
int numMinorPerMajor;
};
static const RangeConfig RANGE_CONFIGS[4] = {
{ 2.0f, 2, 4 }, // labels: 1, 2 mi
{ 5.0f, 5, 1 }, // labels: 1,2,3,4,5 mi
{ 10.0f, 5, 1 }, // labels: 2,4,6,8,10 mi
{ 15.0f, 5, 2 }, // labels: 3,6,9,12,15 mi
};
static constexpr int RANGE_COUNT = 4;
struct AScopeGraticule {
GLuint lineVAO = 0, lineVBO = 0;
GLuint textVAO = 0, textVBO = 0;
int lineCount = 0;
int textVerts = 0;
};
// Shared shader programs for all A scope graticules (uYOffset drives animation)
struct AScopeGratProg {
GLuint line = 0;
GLuint text = 0;
};
static AScopeGratProg buildAScopeGratPrograms()
{
return {
makeProgram("shaders/ascope_graticule.vert",
"shaders/ascope_graticule.frag"),
makeProgram("shaders/ascope_graticule_text.vert",
"shaders/ascope_graticule_text.frag")
};
}
static AScopeGraticule buildAScopeGraticule(
const Layout& L, const FontAtlas& fa, const RangeConfig& rc,
float W, float H)
{
AScopeGraticule ag{};
const float asH = L.asBot - L.asTop;
const float pad = 4.0f;
const float gx0 = L.asLeft + pad; // signal area left
const float gx1 = L.asRight - pad; // signal area right
const float gy0 = L.asTop + pad; // top of signal area
const float gy1 = L.asTop + asH * 0.80f; // baseline (signal y = 0)
const float sigW = gx1 - gx0;
const float sigH = gy1 - gy0;
std::vector<float> lineV;
auto ln = [&](float x1, float y1, float x2, float y2) {
lineV.push_back(ndcX(x1,W)); lineV.push_back(ndcY(y1,H));
lineV.push_back(ndcX(x2,W)); lineV.push_back(ndcY(y2,H));
};
// Outer frame (the physical glass plate border)
ln(L.asLeft, L.asTop, L.asRight, L.asTop);
ln(L.asRight, L.asTop, L.asRight, L.asBot);
ln(L.asRight, L.asBot, L.asLeft, L.asBot);
ln(L.asLeft, L.asBot, L.asLeft, L.asTop);
// Baseline and top amplitude reference
ln(gx0, gy1, gx1, gy1);
ln(gx0, gy0, gx1, gy0);
// Horizontal amplitude guide lines at 25 %, 50 %, 75 %
for (int i = 1; i <= 3; ++i)
ln(gx0, gy0 + sigH * i * 0.25f, gx1, gy0 + sigH * i * 0.25f);
// Vertical tick marks
const float majorSpan = sigW / rc.numMajor;
const float minorSpan = majorSpan / (rc.numMinorPerMajor + 1);
const float minorTickH = sigH * 0.35f;
for (int m = 0; m < rc.numMajor; ++m) {
// Major tick (full signal height) at right edge of each interval
float xMaj = gx0 + (m + 1) * majorSpan;
ln(xMaj, gy1, xMaj, gy0);
// Minor ticks between this major and the next
float xBase = gx0 + m * majorSpan;
for (int n = 1; n <= rc.numMinorPerMajor; ++n) {
float xMin = xBase + n * minorSpan;
ln(xMin, gy1, xMin, gy1 - minorTickH);
}
}
ag.lineCount = (int)lineV.size() / 2;
makeLineVAO(ag.lineVAO, ag.lineVBO, lineV);
// Range labels at each major tick (whole-number miles, centred in label area)
std::vector<float> textV;
const float labelY = L.asTop + asH * 0.90f;
const float milesPerMajor = rc.maxMiles / rc.numMajor;
for (int m = 1; m <= rc.numMajor; ++m) {
float x = gx0 + m * majorSpan;
int labelMi = (int)std::round(m * milesPerMajor);
appendTextQuads(textV, fa, std::to_string(labelMi), x, labelY, W, H);
}
ag.textVerts = (int)textV.size() / 4;
makeTextVAO(ag.textVAO, ag.textVBO, textV);
return ag;
}
// Draw one graticule with a vertical NDC offset (positive = up on screen).
// glScissor clips to the A scope box so the slide animation looks like the
// graticule is being pulled out / pushed in through a slot at the top.
static void drawAScopeGraticule(
const AScopeGratProg& prog, const AScopeGraticule& ag,
float yOffNDC, const FontAtlas& fa, float W, float H, const Layout& L)
{
glEnable(GL_SCISSOR_TEST);
// OpenGL scissor uses window coords (y=0 at bottom of window)
glScissor((GLint) L.asLeft,
(GLint)(H - L.asBot),
(GLint)(L.asRight - L.asLeft),
(GLint)(L.asBot - L.asTop));
glUseProgram(prog.line);
glUniform3f(glGetUniformLocation(prog.line, "uColor"), INCAN_R, INCAN_G, INCAN_B);
glUniform1f(glGetUniformLocation(prog.line, "uYOffset"), yOffNDC);
glBindVertexArray(ag.lineVAO);
glDrawArrays(GL_LINES, 0, ag.lineCount);
if (ag.textVerts > 0) {
glUseProgram(prog.text);
glUniform3f(glGetUniformLocation(prog.text, "uColor"), INCAN_R, INCAN_G, INCAN_B);
glUniform1f(glGetUniformLocation(prog.text, "uYOffset"), yOffNDC);
glUniform1i(glGetUniformLocation(prog.text, "uTexture"), 0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, fa.texture);
glBindVertexArray(ag.textVAO);
glDrawArrays(GL_TRIANGLES, 0, ag.textVerts);
glBindTexture(GL_TEXTURE_2D, 0);
}
glBindVertexArray(0);
glDisable(GL_SCISSOR_TEST);
}
// ─── Polar coordinate helpers (bearing ° CW from N, range fraction) ─────────
static inline float polarPx(const Layout& L, float bearDeg, float frac)
{ return L.ppiCX + frac * L.ppiR * std::sin(bearDeg * (PI/180.0f)); }
static inline float polarPy(const Layout& L, float bearDeg, float frac)
{ return L.ppiCY - frac * L.ppiR * std::cos(bearDeg * (PI/180.0f)); }
// ─── Feature 4: PPI range rings ──────────────────────────────────────────────
// Build vertex data (x,y,r,g,b = 5 floats/vertex):
// 1. Full persistence rings (dim P7 green-yellow)
// 2. Active sweep-trail arc per ring (active → persistence colour gradient)
// 3. Sweep line from centre to edge
static void buildRingVerts(std::vector<float>& v,
const Layout& L, int rangeIdx,
float sweepAngle, float W, float H)
{
const RangeConfig& rc = RANGE_CONFIGS[rangeIdx];
const int nr = rc.numMajor;
auto push = [&](float px, float py, float r, float g, float b) {
v.push_back(ndcX(px,W)); v.push_back(ndcY(py,H));
v.push_back(r); v.push_back(g); v.push_back(b);
};
// Full persistence rings (dim)
for (int ri = 1; ri <= nr; ++ri) {
float frac = (float)ri / nr;
for (int i = 0; i <= RING_SEGS; ++i) {
float a = 2.0f * PI * i / RING_SEGS;
push(L.ppiCX + frac*L.ppiR*std::cos(a),
L.ppiCY + frac*L.ppiR*std::sin(a),
P7P_R*0.45f, P7P_G*0.45f, P7P_B*0.45f);
}
}
// Sweep trail arcs (active at head → persistence at tail)
for (int ri = 1; ri <= nr; ++ri) {
float frac = (float)ri / nr;
for (int i = 0; i <= TRAIL_SEGS; ++i) {
float t = 1.0f - (float)i / TRAIL_SEGS;
float ang = sweepAngle - (float)i * TRAIL_DEG / TRAIL_SEGS;
float r = P7A_R * t + P7P_R * 0.45f * (1.0f - t);
float g2 = P7A_G * t + P7P_G * 0.45f * (1.0f - t);
float b = P7A_B * t + P7P_B * 0.45f * (1.0f - t);
push(polarPx(L,ang,frac), polarPy(L,ang,frac), r, g2, b);
}
}
// Sweep line (centre → edge)
push(L.ppiCX, L.ppiCY, P7A_R, P7A_G, P7A_B);
push(polarPx(L,sweepAngle,1.0f), polarPy(L,sweepAngle,1.0f),
P7A_R, P7A_G, P7A_B);
}
struct RingLayer {
GLuint prog = 0, vao = 0, vbo = 0;
};
static RingLayer buildRingLayer()
{
RingLayer rl{};
glGenVertexArrays(1, &rl.vao); glGenBuffers(1, &rl.vbo);
glBindVertexArray(rl.vao);
glBindBuffer(GL_ARRAY_BUFFER, rl.vbo);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 5*sizeof(float), nullptr);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 5*sizeof(float),
reinterpret_cast<void*>(2*sizeof(float)));
glEnableVertexAttribArray(1);
glBindVertexArray(0);
rl.prog = makeProgram("shaders/ppi_range_rings.vert",
"shaders/ppi_range_rings.frag");
return rl;
}
static void renderRingLayer(RingLayer& rl, const Layout& L,
int rangeIdx, float sweepAngle, float W, float H)
{
std::vector<float> v;
buildRingVerts(v, L, rangeIdx, sweepAngle, W, H);
glBindBuffer(GL_ARRAY_BUFFER, rl.vbo);
glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)(v.size()*sizeof(float)),
v.data(), GL_DYNAMIC_DRAW);
glUseProgram(rl.prog);
glBindVertexArray(rl.vao);
const int nr = RANGE_CONFIGS[rangeIdx].numMajor;
int off = 0;
for (int ri = 0; ri < nr; ++ri) {
glDrawArrays(GL_LINE_STRIP, off, RING_SEGS+1);
off += RING_SEGS+1;
}
for (int ri = 0; ri < nr; ++ri) {
glDrawArrays(GL_LINE_STRIP, off, TRAIL_SEGS+1);
off += TRAIL_SEGS+1;
}
glDrawArrays(GL_LINES, off, 2);
glBindVertexArray(0);
}
// ─── Feature 5: Fake targets ──────────────────────────────────────────────────
struct FakeTarget {
float bearingDeg;
float rangeMiles;
float coreRadPx; // core blob radius (pixels)
float bloomRadPx; // bloom glow radius (pixels); 0 = no bloom
mutable float lastActT; // glfwGetTime() when sweep last lit this target
};
static float angDiff(float a, float b) // unsigned angular separation (°)
{
float d = std::fmod(std::fabs(a - b), 360.0f);
return d > 180.0f ? 360.0f - d : d;
}
// Build one textured quad (x,y,u,v = 4 floats/vertex) centred at screen pixel (px,py).
static void genBlob(std::vector<float>& v,
float px, float py, float radiusPx, float W, float H)
{
float x0=ndcX(px-radiusPx,W), y0=ndcY(py-radiusPx,H);
float x1=ndcX(px+radiusPx,W), y1=ndcY(py+radiusPx,H);
v.push_back(x0);v.push_back(y0);v.push_back(-1.f);v.push_back(-1.f);
v.push_back(x1);v.push_back(y0);v.push_back( 1.f);v.push_back(-1.f);
v.push_back(x1);v.push_back(y1);v.push_back( 1.f);v.push_back( 1.f);
v.push_back(x0);v.push_back(y0);v.push_back(-1.f);v.push_back(-1.f);
v.push_back(x1);v.push_back(y1);v.push_back( 1.f);v.push_back( 1.f);
v.push_back(x0);v.push_back(y1);v.push_back(-1.f);v.push_back( 1.f);
}
struct TargetLayer {
GLuint prog = 0, vao = 0, vbo = 0;
};
static TargetLayer buildTargetLayer()
{
TargetLayer tl{};
glGenVertexArrays(1, &tl.vao); glGenBuffers(1, &tl.vbo);
glBindVertexArray(tl.vao);
glBindBuffer(GL_ARRAY_BUFFER, tl.vbo);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4*sizeof(float), nullptr);
glEnableVertexAttribArray(0);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4*sizeof(float),
reinterpret_cast<void*>(2*sizeof(float)));
glEnableVertexAttribArray(1);
glBindVertexArray(0);
tl.prog = makeProgram("shaders/ppi_targets.vert","shaders/ppi_targets.frag");
return tl;
}
static void renderTargets(TargetLayer& tl,
FakeTarget* tgts, int nTgts,
const Layout& L, float maxRangeMi,
float sweepAngle, float curTime, float W, float H)
{
static const float THRESH = 3.5f; // sweep activation threshold (°)
glUseProgram(tl.prog);
glBindVertexArray(tl.vao);
const GLint locCol = glGetUniformLocation(tl.prog, "uColor");
const GLint locFall = glGetUniformLocation(tl.prog, "uFalloff");
for (int i = 0; i < nTgts; ++i) {
FakeTarget& t = tgts[i];
if (angDiff(sweepAngle, t.bearingDeg) < THRESH)
t.lastActT = curTime;
float rangeFrac = t.rangeMiles / maxRangeMi;
if (rangeFrac > 1.0f) continue;
float px = polarPx(L, t.bearingDeg, rangeFrac);
float py = polarPy(L, t.bearingDeg, rangeFrac);
float dt = curTime - t.lastActT;
float fade = (dt < 0.0f) ? 0.0f : std::max(0.0f, 1.0f - dt/TARG_PERSIST);
bool active = angDiff(sweepAngle, t.bearingDeg) < THRESH;
float bright = active ? 1.0f : fade;
if (bright < 0.01f) continue;
float cr, cg, cb;
if (active) { cr=P7A_R; cg=P7A_G; cb=P7A_B; }
else { cr=P7P_R; cg=P7P_G; cb=P7P_B; }
// Core blob
{
std::vector<float> bv;
genBlob(bv, px, py, t.coreRadPx, W, H);
glBindBuffer(GL_ARRAY_BUFFER, tl.vbo);
glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)(bv.size()*sizeof(float)),
bv.data(), GL_DYNAMIC_DRAW);
glUniform3f(locCol, cr*bright, cg*bright, cb*bright);
glUniform1f(locFall, 4.5f);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
// Bloom glow (large targets only)
if (t.bloomRadPx > 0.0f && bright > 0.05f) {
std::vector<float> bv;
genBlob(bv, px, py, t.bloomRadPx, W, H);
glBindBuffer(GL_ARRAY_BUFFER, tl.vbo);
glBufferData(GL_ARRAY_BUFFER, (GLsizeiptr)(bv.size()*sizeof(float)),
bv.data(), GL_DYNAMIC_DRAW);
glUniform3f(locCol, cr*bright*0.35f, cg*bright*0.35f, cb*bright*0.35f);
glUniform1f(locFall, 0.8f);
glDrawArrays(GL_TRIANGLES, 0, 6);
}
}
glBindVertexArray(0);
}
// ─── Key callback ────────────────────────────────────────────────────────────
static void onKey(GLFWwindow* win, int key, int /*scan*/, int action, int /*mods*/)
{
if (key == GLFW_KEY_ESCAPE && action == GLFW_PRESS)
glfwSetWindowShouldClose(win, GLFW_TRUE);
}
// ─── main ────────────────────────────────────────────────────────────────────
int main()
{
if (!glfwInit()) { std::cerr << "GLFW init failed\n"; return 1; }
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWmonitor* mon = glfwGetPrimaryMonitor();
const GLFWvidmode* mode = glfwGetVideoMode(mon);
GLFWwindow* win = glfwCreateWindow(
mode->width, mode->height,
"Radar Test — Features 15",
nullptr, nullptr);
if (!win) { std::cerr << "Window create failed\n"; glfwTerminate(); return 1; }
glfwMakeContextCurrent(win);
glfwSetKeyCallback(win, onKey);
if (!gladLoadGLLoader(reinterpret_cast<GLADloadproc>(glfwGetProcAddress))) {
std::cerr << "GLAD init failed\n"; return 1;
}
int fbW, fbH;
glfwGetFramebufferSize(win, &fbW, &fbH);
glViewport(0, 0, fbW, fbH);
const float W = static_cast<float>(fbW);
const float H = static_cast<float>(fbH);
int mmW, mmH;
glfwGetMonitorPhysicalSize(mon, &mmW, &mmH);
const float dpiX = static_cast<float>(mode->width) / (static_cast<float>(mmW) / 25.4f);
const float margin = 0.5f * dpiX;
const Layout layout = computeLayout(W, H, margin);
// Font atlas (digits only, ~1.8 % of screen height)
FontAtlas fa{};
const std::string fontPath =
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf";
const int fontSizePx = std::max(12, static_cast<int>(H * 0.018f));
if (!buildFontAtlas(fa, fontPath, fontSizePx)) {
glfwTerminate(); return 1;
}
// Feature 1 — scope boundaries
ScopeBounds sb = buildScopeBounds(layout, W, H);
// Feature 2 — PPI bearing graticule
BearingGraticule bg = buildBearingGraticule(layout, fa, W, H);
// Feature 3 — A scope replaceable graticules
AScopeGratProg agProg = buildAScopeGratPrograms();
AScopeGraticule graticules[RANGE_COUNT];
for (int i = 0; i < RANGE_COUNT; ++i)
graticules[i] = buildAScopeGraticule(layout, fa, RANGE_CONFIGS[i], W, H);
// Feature 4 — PPI range rings
RingLayer rl = buildRingLayer();
// Feature 5 — Fake targets (one per quadrant: NE small, SE large, SW/NW very large)
const float pR = layout.ppiR;
FakeTarget targets[4] = {
{ 55.0f, 1.3f, pR*0.010f, 0.0f, -999.0f }, // NE small (kayak)
{ 135.0f, 3.8f, pR*0.022f, pR*0.048f, -999.0f }, // SE large, blooming
{ 215.0f, 2.2f, pR*0.032f, pR*0.075f, -999.0f }, // SW very large
{ 310.0f, 9.0f, pR*0.032f, pR*0.075f, -999.0f }, // NW very large
};
TargetLayer tl = buildTargetLayer();
float sweepAngle = 0.0f; // degrees, 0 = north, clockwise
// NDC height of the A scope box — used to compute slide distance
const float scopeNDCH = (layout.asBot - layout.asTop) * 2.0f / H;
// Animation state
int curRange = 0;
int nextRange = 1;
bool sliding = false;
float holdTimer = 0.0f;
float slideTimer = 0.0f;
float prevTime = static_cast<float>(glfwGetTime());
glEnable(GL_BLEND);
glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
while (!glfwWindowShouldClose(win)) {
const float now = static_cast<float>(glfwGetTime());
const float dt = now - prevTime;
prevTime = now;
// ── Advance sweep (Features 4 & 5) ───────────────────────────────────
sweepAngle = std::fmod(sweepAngle + SWEEP_DEG_PS * dt, 360.0f);
// ── Advance Feature 3 animation ───────────────────────────────────────
if (!sliding) {
holdTimer += dt;
if (holdTimer >= HOLD_SEC) {
holdTimer = 0.0f;
slideTimer = 0.0f;
nextRange = (curRange + 1) % RANGE_COUNT;
sliding = true;
}
} else {
slideTimer += dt;
if (slideTimer >= SLIDE_SEC) {
curRange = nextRange;
sliding = false;
}
}
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// ── Feature 1: scope outlines ─────────────────────────────────────────
glUseProgram(sb.prog);
const GLint sbCol = glGetUniformLocation(sb.prog, "uColor");
glBindVertexArray(sb.vao);
glUniform3f(sbCol, 0.25f, 0.35f, 0.55f);
glDrawArrays(GL_LINE_STRIP, sb.ppiStart, sb.ppiCount);
glUniform3f(sbCol, P1_R, P1_G, P1_B);
glDrawArrays(GL_LINES, sb.asStart, sb.asCount);
glBindVertexArray(0);
// ── Feature 2: PPI bearing ring + ticks ──────────────────────────────
glUseProgram(bg.lineProg);
glUniform3f(glGetUniformLocation(bg.lineProg, "uColor"),
INCAN_R, INCAN_G, INCAN_B);
glBindVertexArray(bg.lineVAO);
glDrawArrays(GL_LINE_STRIP, bg.ringStart, bg.ringCount);
glDrawArrays(GL_LINES, bg.tickStart, bg.tickCount);
glBindVertexArray(0);
glUseProgram(bg.textProg);
glUniform3f(glGetUniformLocation(bg.textProg, "uColor"),
INCAN_R, INCAN_G, INCAN_B);
glUniform1i(glGetUniformLocation(bg.textProg, "uTexture"), 0);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, bg.fontTex);
glBindVertexArray(bg.textVAO);
glDrawArrays(GL_TRIANGLES, 0, bg.textVerts);
glBindVertexArray(0);
glBindTexture(GL_TEXTURE_2D, 0);
// ── Feature 3: A scope graticule (with slide animation) ───────────────
if (!sliding) {
drawAScopeGraticule(agProg, graticules[curRange],
0.0f, fa, W, H, layout);
} else {
float t = std::min(slideTimer / SLIDE_SEC, 1.0f);
// Old graticule slides UP (+NDC Y = up) and out through the top slot
drawAScopeGraticule(agProg, graticules[curRange],
t * scopeNDCH, fa, W, H, layout);
// New graticule descends from above into position
drawAScopeGraticule(agProg, graticules[nextRange],
(1.0f - t) * scopeNDCH, fa, W, H, layout);
}
// ── Feature 4: PPI range rings ────────────────────────────────────────
renderRingLayer(rl, layout, curRange, sweepAngle, W, H);
// ── Feature 5: Active targets + persistence ───────────────────────────
renderTargets(tl, targets, 4, layout,
RANGE_CONFIGS[curRange].maxMiles,
sweepAngle, now, W, H);
glfwSwapBuffers(win);
glfwPollEvents();
}
// ── Cleanup ───────────────────────────────────────────────────────────────
glDeleteVertexArrays(1, &sb.vao); glDeleteBuffers(1, &sb.vbo);
glDeleteVertexArrays(1, &bg.lineVAO); glDeleteBuffers(1, &bg.lineVBO);
glDeleteVertexArrays(1, &bg.textVAO); glDeleteBuffers(1, &bg.textVBO);
glDeleteTextures(1, &fa.texture);
glDeleteProgram(sb.prog);
glDeleteProgram(bg.lineProg);
glDeleteProgram(bg.textProg);
for (int i = 0; i < RANGE_COUNT; ++i) {
glDeleteVertexArrays(1, &graticules[i].lineVAO);
glDeleteBuffers(1, &graticules[i].lineVBO);
glDeleteVertexArrays(1, &graticules[i].textVAO);
glDeleteBuffers(1, &graticules[i].textVBO);
}
glDeleteProgram(agProg.line);
glDeleteProgram(agProg.text);
glDeleteVertexArrays(1, &rl.vao); glDeleteBuffers(1, &rl.vbo);
glDeleteProgram(rl.prog);
glDeleteVertexArrays(1, &tl.vao); glDeleteBuffers(1, &tl.vbo);
glDeleteProgram(tl.prog);
glfwDestroyWindow(win);
glfwTerminate();
return 0;
}