diff --git a/.gitignore b/.gitignore index 4a6127b..e0b8aa8 100644 --- a/.gitignore +++ b/.gitignore @@ -145,3 +145,4 @@ _deps # Vcpkg vcpkg_installed +/black_hole diff --git a/README.md b/README.md index b96a210..7a47677 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,23 @@ sudo apt install build-essential cmake \ This provides the GLEW, GLFW, GLM and OpenGL development files so `find_package(...)` calls in `CMakeLists.txt` can locate the libraries. After installing, run the `cmake -B build -S .` and `cmake --build build` commands as shown in the Build Instructions. +## **Running on macOS (Apple Silicon):** + +macOS only supports OpenGL up to 4.1, which has no compute shaders — so `black_hole.cpp` detects this at startup and runs the same geodesic raytracer as a fragment shader (`geodesic.frag`) instead. No configuration needed; Windows and Linux keep using `geodesic.comp` exactly as before. + +Setup with [Homebrew](https://brew.sh), from the repository root: + +```bash +brew install cmake glfw glew glm +cmake -B build -S . -DCMAKE_PREFIX_PATH=/opt/homebrew +cmake --build build +cd build && ./BlackHole3D +``` + +Tested on a MacBook Pro (Apple M4 Pro, 24 GB RAM) running macOS 26.5: about 90 ms per frame (~11 FPS) at the default 200x150 render resolution. The frame is drawn in tiles with adaptive integration steps to stay under the macOS GPU watchdog, which would otherwise terminate long-running GPU work. + +![BlackHole3D running on macOS (M4 Pro)](screenshots/macos-m4.png) + ## **How the code works:** for 2D: simple, just run 2D_lensing.cpp with the nessesary dependencies installed. diff --git a/black_hole.cpp b/black_hole.cpp index 926ac7c..8275226 100644 --- a/black_hole.cpp +++ b/black_hole.cpp @@ -35,7 +35,10 @@ struct Camera { float minRadius = 1e10f, maxRadius = 1e12f; float azimuth = 0.0f; - float elevation = M_PI / 2.0f; + // Start slightly above the equatorial plane: at exactly pi/2 the camera + // sits level with (and inside the outer edge of) the accretion disk, so + // the first frame is a wall of disk instead of the classic lensed view. + float elevation = 1.3f; float orbitSpeed = 0.01f; float panSpeed = 0.01f; @@ -156,6 +159,10 @@ struct Engine { GLuint texture; GLuint shaderProgram; GLuint computeProgram = 0; + // -- Fragment-shader fallback for platforms without GL 4.3 compute (macOS) -- // + bool useCompute = false; + GLuint geodesicProgram = 0; + GLuint geodesicFBO = 0; // -- UBOs -- // GLuint cameraUBO = 0; GLuint diskUBO = 0; @@ -179,8 +186,9 @@ struct Engine { exit(EXIT_FAILURE); } glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 4); - glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 1); glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); window = glfwCreateWindow(WIDTH, HEIGHT, "Black Hole", nullptr, nullptr); if (!window) { cerr << "Failed to create GLFW window\n"; @@ -199,9 +207,29 @@ struct Engine { } cout << "OpenGL " << glGetString(GL_VERSION) << "\n"; this->shaderProgram = CreateShaderProgram(); + + // NOTE: grid.vert and grid.frag files must be present gridShaderProgram = CreateShaderProgram("grid.vert", "grid.frag"); - computeProgram = CreateComputeProgram("geodesic.comp"); + // Compute shaders require OpenGL 4.3, which macOS never shipped (it is + // capped at 4.1). Detect support at runtime: use the compute shader + // where available, otherwise fall back to an equivalent fragment-shader + // pass rendered into an offscreen framebuffer. + useCompute = (GLEW_VERSION_4_3 != 0); + if (useCompute) { + // NOTE: geodesic.comp file must be present and contain #version 430 + computeProgram = CreateComputeProgram("geodesic.comp"); + } else { + cout << "[INFO] OpenGL 4.3 compute shaders unavailable; using fragment-shader raytracer (geodesic.frag)\n"; + geodesicProgram = CreateShaderProgram("fullscreen.vert", "geodesic.frag"); + // GLSL 4.10 can't declare layout(binding=N) on uniform blocks, so + // bind them here to match the compute shader's binding points. + glUniformBlockBinding(geodesicProgram, glGetUniformBlockIndex(geodesicProgram, "Camera"), 1); + glUniformBlockBinding(geodesicProgram, glGetUniformBlockIndex(geodesicProgram, "Disk"), 2); + glUniformBlockBinding(geodesicProgram, glGetUniformBlockIndex(geodesicProgram, "Objects"), 3); + glGenFramebuffers(1, &geodesicFBO); + } + glGenBuffers(1, &cameraUBO); glBindBuffer(GL_UNIFORM_BUFFER, cameraUBO); glBufferData(GL_UNIFORM_BUFFER, 128, nullptr, GL_DYNAMIC_DRAW); // alloc ~128 bytes @@ -494,6 +522,57 @@ struct Engine { // 5) sync glMemoryBarrier(GL_SHADER_IMAGE_ACCESS_BARRIER_BIT); } + // Fragment-shader equivalent of dispatchCompute for GL < 4.3 (macOS): + // renders the geodesic raytracer into the same low-res texture via an FBO. + void renderGeodesicFragment(const Camera& cam) { + int cw = cam.moving ? COMPUTE_WIDTH : 200; + int ch = cam.moving ? COMPUTE_HEIGHT : 150; + + // 1) reallocate the texture if needed (same as compute path) + glBindTexture(GL_TEXTURE_2D, texture); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA8, cw, ch, 0, + GL_RGBA, GL_UNSIGNED_BYTE, nullptr); + + // 2) render into the texture through an FBO + glBindFramebuffer(GL_FRAMEBUFFER, geodesicFBO); + glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, texture, 0); + glViewport(0, 0, cw, ch); + + glUseProgram(geodesicProgram); + uploadCameraUBO(cam); + uploadDiskUBO(); + uploadObjectsUBO(objects); + + // The compute path overwrites every texel; disable blending so the + // fragment path does too, then restore it for the overlay draw. + GLboolean blendWasEnabled = glIsEnabled(GL_BLEND); + glDisable(GL_BLEND); + glDisable(GL_DEPTH_TEST); + + // Split the pass into horizontal tiles and flush between them so no + // single GPU command buffer runs long enough to trip the OS watchdog + // (macOS kills GL work that hangs the GPU for roughly 2 seconds). + const int tiles = 10; + glEnable(GL_SCISSOR_TEST); + glBindVertexArray(quadVAO); + for (int t = 0; t < tiles; ++t) { + int y0 = ch * t / tiles; + int y1 = ch * (t + 1) / tiles; + glScissor(0, y0, cw, y1 - y0); + glDrawArrays(GL_TRIANGLES, 0, 6); + glFlush(); + } + glBindVertexArray(0); + glDisable(GL_SCISSOR_TEST); + + if (blendWasEnabled) glEnable(GL_BLEND); + glEnable(GL_DEPTH_TEST); + glBindFramebuffer(GL_FRAMEBUFFER, 0); + int fbWidth, fbHeight; + glfwGetFramebufferSize(window, &fbWidth, &fbHeight); + glViewport(0, 0, fbWidth, fbHeight); + } void uploadCameraUBO(const Camera& cam) { struct UBOData { vec3 pos; float _pad0; @@ -502,7 +581,7 @@ struct Engine { vec3 forward; float _pad3; float tanHalfFov; float aspect; - bool moving; + int moving; // std140 bool is 4 bytes; C++ bool is 1, so use int int _pad4; } data; vec3 fwd = normalize(cam.target - cam.position()); @@ -516,7 +595,7 @@ struct Engine { data.forward = fwd; data.tanHalfFov = tan(radians(60.0f * 0.5f)); data.aspect = float(WIDTH) / float(HEIGHT); - data.moving = cam.dragging || cam.panning; + data.moving = (cam.dragging || cam.panning) ? 1 : 0; glBindBuffer(GL_UNIFORM_BUFFER, cameraUBO); glBufferSubData(GL_UNIFORM_BUFFER, 0, sizeof(UBOData), &data); @@ -668,7 +747,7 @@ int main() { double Gforce = (G * obj.mass * obj2.mass) / (distance * distance); double acc1 = Gforce / obj.mass; - std::vector acc = {direction[0] * acc1, direction[1] * acc1, direction[2] * acc1}; + std::vector acc = {direction[0] * acc1, direction[1] * acc1, direction[2] * acc1}; if (Gravity) { obj.velocity.x += acc[0]; obj.velocity.y += acc[1]; @@ -684,7 +763,6 @@ int main() { } - // ---------- GRID ------------- // // 2) rebuild grid mesh on CPU engine.generateGrid(objects); @@ -695,8 +773,17 @@ int main() { engine.drawGrid(viewProj); // ---------- RUN RAYTRACER ------------- // - glViewport(0, 0, engine.WIDTH, engine.HEIGHT); - engine.dispatchCompute(camera); + // Use the framebuffer size, not the window size: on Retina/HiDPI + // displays the framebuffer is larger, and a WIDTHxHEIGHT viewport + // would only cover the bottom-left quarter of the window. + int fbWidth, fbHeight; + glfwGetFramebufferSize(engine.window, &fbWidth, &fbHeight); + glViewport(0, 0, fbWidth, fbHeight); + if (engine.useCompute) { + engine.dispatchCompute(camera); + } else { + engine.renderGeodesicFragment(camera); + } engine.drawFullScreenQuad(); // 6) present to screen @@ -707,4 +794,4 @@ int main() { glfwDestroyWindow(engine.window); glfwTerminate(); return 0; -} +} \ No newline at end of file diff --git a/fullscreen.vert b/fullscreen.vert new file mode 100644 index 0000000..0bd9d8f --- /dev/null +++ b/fullscreen.vert @@ -0,0 +1,8 @@ +#version 410 core +layout(location = 0) in vec2 aPos; +layout(location = 1) in vec2 aTexCoord; +out vec2 TexCoord; +void main() { + gl_Position = vec4(aPos, 0.0, 1.0); + TexCoord = aTexCoord; +} diff --git a/geodesic.frag b/geodesic.frag new file mode 100644 index 0000000..113c2b5 --- /dev/null +++ b/geodesic.frag @@ -0,0 +1,194 @@ +// Fragment-shader port of geodesic.comp for platforms without OpenGL 4.3 +// compute shaders (macOS is capped at OpenGL 4.1). The physics is identical: +// per-pixel Schwarzschild geodesic integration via RK4. Instead of imageStore +// into an image unit, we render a fullscreen quad into an offscreen FBO and +// write the ray color as the fragment output. +#version 410 core + +in vec2 TexCoord; +out vec4 FragColor; + +// Note: GLSL 4.10 does not allow layout(binding=N) on uniform blocks; +// block bindings are assigned from the C++ side with glUniformBlockBinding. +layout(std140) uniform Camera { + vec3 camPos; float _pad0; + vec3 camRight; float _pad1; + vec3 camUp; float _pad2; + vec3 camForward; float _pad3; + float tanHalfFov; + float aspect; + bool moving; + int _pad4; +} cam; + +layout(std140) uniform Disk { + float disk_r1; + float disk_r2; + float disk_num; + float thickness; +}; + +layout(std140) uniform Objects { + int numObjects; + vec4 objPosRadius[16]; + vec4 objColor[16]; + float mass[16]; +}; + +const float SagA_rs = 1.269e10; +const float D_LAMBDA = 1e7; +// Rays are considered escaped once they pass the camera's maximum zoom-out +// distance (1e12); integrating out to 1e30 would waste millions of steps. +const float ESCAPE_R = 2e12; + +// Globals to store hit info +vec4 objectColor = vec4(0.0); +vec3 hitCenter = vec3(0.0); +float hitRadius = 0.0; + +struct Ray { + float x, y, z, r, theta, phi; + float dr, dtheta, dphi; + float E, L; +}; +Ray initRay(vec3 pos, vec3 dir) { + Ray ray; + ray.x = pos.x; ray.y = pos.y; ray.z = pos.z; + ray.r = length(pos); + ray.theta = acos(pos.z / ray.r); + ray.phi = atan(pos.y, pos.x); + + float dx = dir.x, dy = dir.y, dz = dir.z; + ray.dr = sin(ray.theta)*cos(ray.phi)*dx + sin(ray.theta)*sin(ray.phi)*dy + cos(ray.theta)*dz; + ray.dtheta = (cos(ray.theta)*cos(ray.phi)*dx + cos(ray.theta)*sin(ray.phi)*dy - sin(ray.theta)*dz) / ray.r; + ray.dphi = (-sin(ray.phi)*dx + cos(ray.phi)*dy) / (ray.r * sin(ray.theta)); + + ray.L = ray.r * ray.r * sin(ray.theta) * ray.dphi; + float f = 1.0 - SagA_rs / ray.r; + float dt_dL = sqrt((ray.dr*ray.dr)/f + ray.r*ray.r*(ray.dtheta*ray.dtheta + sin(ray.theta)*sin(ray.theta)*ray.dphi*ray.dphi)); + ray.E = f * dt_dL; + + return ray; +} + +bool intercept(Ray ray, float rs) { + return ray.r <= rs; +} +// Returns true on hit, captures center, radius, and base color +bool interceptObject(Ray ray) { + vec3 P = vec3(ray.x, ray.y, ray.z); + for (int i = 0; i < numObjects; ++i) { + vec3 center = objPosRadius[i].xyz; + float radius = objPosRadius[i].w; + if (distance(P, center) <= radius) { + objectColor = objColor[i]; + hitCenter = center; + hitRadius = radius; + return true; + } + } + return false; +} + +void geodesicRHS(Ray ray, out vec3 d1, out vec3 d2) { + float r = ray.r, theta = ray.theta; + float dr = ray.dr, dtheta = ray.dtheta, dphi = ray.dphi; + float f = 1.0 - SagA_rs / r; + float dt_dL = ray.E / f; + + d1 = vec3(dr, dtheta, dphi); + d2.x = - (SagA_rs / (2.0 * r*r)) * f * dt_dL * dt_dL + + (SagA_rs / (2.0 * r*r * f)) * dr * dr + + r * (dtheta*dtheta + sin(theta)*sin(theta)*dphi*dphi); + d2.y = -2.0*dr*dtheta/r + sin(theta)*cos(theta)*dphi*dphi; + d2.z = -2.0*dr*dphi/r - 2.0*cos(theta)/(sin(theta)) * dtheta * dphi; +} +void rk4Step(inout Ray ray, float dL) { + vec3 k1a, k1b; + geodesicRHS(ray, k1a, k1b); + + ray.r += dL * k1a.x; + ray.theta += dL * k1a.y; + ray.phi += dL * k1a.z; + ray.dr += dL * k1b.x; + ray.dtheta += dL * k1b.y; + ray.dphi += dL * k1b.z; + + ray.x = ray.r * sin(ray.theta) * cos(ray.phi); + ray.y = ray.r * sin(ray.theta) * sin(ray.phi); + ray.z = ray.r * cos(ray.theta); +} +bool crossesEquatorialPlane(vec3 oldPos, vec3 newPos) { + bool crossed = (oldPos.y * newPos.y < 0.0); + float r = length(vec2(newPos.x, newPos.z)); + return crossed && (r >= disk_r1 && r <= disk_r2); +} + +void main() { + int WIDTH = cam.moving ? 200 : 200; + int HEIGHT = cam.moving ? 150 : 150; + + // gl_FragCoord (0.5, 0.5) corresponds to compute-shader pixel (0, 0), + // so the ray setup below matches the compute version exactly. + ivec2 pix = ivec2(gl_FragCoord.xy); + if (pix.x >= WIDTH || pix.y >= HEIGHT) { FragColor = vec4(0.0); return; } + + // Init Ray + float u = (2.0 * (pix.x + 0.5) / WIDTH - 1.0) * cam.aspect * cam.tanHalfFov; + float v = (1.0 - 2.0 * (pix.y + 0.5) / HEIGHT) * cam.tanHalfFov; + vec3 dir = normalize(u * cam.camRight - v * cam.camUp + cam.camForward); + Ray ray = initRay(cam.camPos, dir); + + vec4 color = vec4(0.0); + vec3 prevPos = vec3(ray.x, ray.y, ray.z); + float lambda = 0.0; + + bool hitBlackHole = false; + bool hitDisk = false; + bool hitObject = false; + + int steps = cam.moving ? 20000 : 60000; + + for (int i = 0; i < steps; ++i) { + if (intercept(ray, SagA_rs)) { hitBlackHole = true; break; } + // Adaptive step: near the hole (small r) step at D_LAMBDA for accurate + // lensing; far away, where spacetime is nearly flat, take larger steps. + // Keeps each ray to a few thousand steps so the whole frame stays well + // under the macOS GPU watchdog limit. + float dL = D_LAMBDA * clamp(ray.r / (2.0 * SagA_rs), 1.0, 50.0); + rk4Step(ray, dL); + lambda += dL; + + vec3 newPos = vec3(ray.x, ray.y, ray.z); + if (crossesEquatorialPlane(prevPos, newPos)) { hitDisk = true; break; } + if (interceptObject(ray)) { hitObject = true; break; } + prevPos = newPos; + if (ray.r > ESCAPE_R) break; + } + + if (hitDisk) { + float r = length(vec3(ray.x, ray.y, ray.z)) / disk_r2; + vec3 diskColor = vec3(1.0, r, 0.2); + //r = 1.0 - abs(r - 0.5) * 2.0; + color = vec4(diskColor, r); + + } else if (hitBlackHole) { + color = vec4(0.0, 0.0, 0.0, 1.0); + + } else if (hitObject) { + // Compute shading + vec3 P = vec3(ray.x, ray.y, ray.z); + vec3 N = normalize(P - hitCenter); + vec3 V = normalize(cam.camPos - P); + float ambient = 0.1; + float diff = max(dot(N, V), 0.0); + float intensity = ambient + (1.0 - ambient) * diff; + vec3 shaded = objectColor.rgb * intensity; + color = vec4(shaded, objectColor.a); + + } else { + color = vec4(0.0); + } + + FragColor = color; +} diff --git a/grid.frag b/grid.frag index 5b0ad09..56e4e2b 100644 --- a/grid.frag +++ b/grid.frag @@ -1,4 +1,4 @@ -#version 330 core +#version 410 core out vec4 FragColor; void main() { FragColor = vec4(0.5, 0.5, 0.5, 0.7); // translucent blue lines diff --git a/grid.vert b/grid.vert index 2f3c81c..0b19521 100644 --- a/grid.vert +++ b/grid.vert @@ -1,4 +1,4 @@ -#version 330 core +#version 410 core layout(location = 0) in vec3 aPos; uniform mat4 viewProj; void main() { diff --git a/screenshots/macos-m4.png b/screenshots/macos-m4.png new file mode 100644 index 0000000..a5dfee3 Binary files /dev/null and b/screenshots/macos-m4.png differ