aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--assets/shaders/Geodesic.slang363
-rw-r--r--src/platform/vulkan/vulkan_renderer.cpp162
2 files changed, 245 insertions, 280 deletions
diff --git a/assets/shaders/Geodesic.slang b/assets/shaders/Geodesic.slang
index 9490f21..a0d030e 100644
--- a/assets/shaders/Geodesic.slang
+++ b/assets/shaders/Geodesic.slang
@@ -25,11 +25,11 @@ ConstantBuffer<Camera> cam;
struct Disk
{
- float disk_r1;
- float disk_r2;
- float disk_num;
- float thickness;
- float disk_density;
+ float disk_r1; // inner edge (clamped to the ISCO, 3 r_s, below)
+ float disk_r2; // outer edge
+ float disk_num; // turbulence strength (0 = smooth physical disk)
+ float thickness; // unused by the thin-disk model; kept for UBO layout
+ float disk_density; // overall disk brightness / exposure
};
ConstantBuffer<Disk> disk;
@@ -53,16 +53,28 @@ ConstantBuffer<Simulation> sim;
SamplerCube u_HDRIEnvironment;
+// Schwarzschild radius of Sgr A* (metres). Geometric units with c = G = 1 are
+// used throughout the geodesic integration; the black-hole mass is M = r_s / 2.
static const float SagA_rs = 1.269e10;
static const float D_LAMBDA = 1e7;
static const float ESCAPE_R = 1e30;
+static const float R_ISCO = 3.0 * SagA_rs; // innermost stable circular orbit (6M)
+static const float R_PHOTON = 1.5 * SagA_rs; // photon sphere (3M)
+static const float FLUX_PEAK = 0.0569; // peak of the r^-3(1-sqrt(r_in/r)) profile (at r/r_in ~ 1.36)
+
static const int DEFAULT_MAX_STEPS_MOVING = 12000;
static const int DEFAULT_MAX_STEPS_STATIC = 8000;
static const float DEFAULT_EARLY_EXIT_DISTANCE = 2e12;
static const float MIN_STEP_SIZE = 1e6;
-static const float MAX_STEP_SIZE = 5e7;
+static const float MAX_STEP_SIZE = 2e10;
+
+// Display mapping for the (relative) Novikov-Thorne flux -> visible colour.
+// The RADIAL PROFILE is physical; the absolute temperature scale is a display
+// choice (a real Sgr A* disk is far cooler / redder than this).
+static const float T_PEAK = 4800.0; // Kelvin at the flux peak (display scale)
+static const float DISK_EXPOSURE = 0.9; // overall brightness of the disk
struct Hit
{
@@ -76,21 +88,6 @@ float3 SampleHDRI(float3 direction)
return u_HDRIEnvironment.Sample(direction).rgb;
}
-float hash(float p)
-{
- p = frac(p * 0.1031);
- p *= p + 33.33;
- p *= p + p;
- return frac(p);
-}
-
-float hash(float2 p)
-{
- float3 p3 = frac(float3(p.xyx) * float3(0.1031, 0.1030, 0.0973));
- p3 += dot(p3, p3.yzx + 33.33);
- return frac((p3.x + p3.y) * p3.z);
-}
-
float hash(float3 p)
{
p = frac(p * float3(0.1031, 0.1030, 0.0973));
@@ -102,7 +99,6 @@ float noise(float3 x)
{
float3 i = floor(x);
float3 fr = frac(x);
-
float3 u = fr * fr * (3.0 - 2.0 * fr);
float a = hash(i);
@@ -122,62 +118,92 @@ float fbm(float3 x, int octaves)
{
float v = 0.0;
float a = 0.5;
- float f = 1.0;
float3 shift = float3(100, 200, 300);
-
for (int i = 0; i < octaves; ++i)
{
- v += a * noise(x * f);
+ v += a * noise(x);
x = x * 2.0 + shift;
a *= 0.5;
- f *= 2.0;
}
return v;
}
-float GetCloudDensity(float3 pos)
+// Planckian-locus blackbody colour (Tanner Helland approximation), T in Kelvin.
+// Returns an sRGB-ish chromaticity normalised so the brightest channel ~ 1.
+float3 Blackbody(float T)
{
- float r_cyl = length(float2(pos.x, pos.z));
- float r_norm = (r_cyl - disk.disk_r1) / (disk.disk_r2 - disk.disk_r1);
+ T = clamp(T, 1000.0, 40000.0);
+ float t = T / 100.0;
+ float3 c;
- if (r_norm < 0.0 || r_norm > 1.0)
- return 0.0;
+ c.r = (t <= 66.0) ? 1.0
+ : clamp(1.292936186 * pow(t - 60.0, -0.1332047592), 0.0, 1.0);
- float h_norm = abs(pos.y) / disk.thickness;
- float vertical_falloff = exp(-h_norm * h_norm * 3.0);
- float radial_density = 1.0 - r_norm * 0.5;
+ c.g = (t <= 66.0) ? clamp(0.3900815788 * log(t) - 0.6318414438, 0.0, 1.0)
+ : clamp(1.1298908609 * pow(t - 60.0, -0.0755148492), 0.0, 1.0);
- float keplerian_speed = 1.0 / sqrt(r_norm + 0.1);
+ c.b = (t >= 66.0) ? 1.0
+ : (t <= 19.0) ? 0.0
+ : clamp(0.5432067891 * log(t - 10.0) - 1.1962540891, 0.0, 1.0);
+ return c;
+}
- float rotation_angle = sim.time * keplerian_speed * 0.5;
- float3 rotated_pos = float3(
- pos.x * cos(rotation_angle) - pos.z * sin(rotation_angle),
- pos.y,
- pos.x * sin(rotation_angle) + pos.z * cos(rotation_angle)
- ) * 1e-10;
+// Emission from the thin accretion disk at an equatorial crossing point P, seen
+// along the (backward-traced) ray direction rayDir. Combines a Novikov-Thorne
+// temperature profile with the full gravitational + Doppler redshift.
+// g = sqrt(1 - 3M/r) / (1 - beta . nhat) (verified: g -> sqrt(1/2) at ISCO)
+// Brightness follows relativistic beaming (I_obs = g^4 I_emit); colour follows
+// the redshifted blackbody at T_obs = g * T_emit.
+float3 DiskEmission(float3 P, float3 rayDir)
+{
+ float rc = length(float2(P.x, P.z)); // cylindrical radius (disk axis = +Y)
+ float rin = max(disk.disk_r1, R_ISCO);
+ float rout = disk.disk_r2;
+ if (rc < rin || rc > rout)
+ return float3(0.0);
- float large_turbulence = fbm(rotated_pos * 1.2, 5);
- float medium_wisps = fbm(rotated_pos * 2.5, 4);
- float small_detail = fbm(rotated_pos * 6.0, 3);
- float fine_detail = fbm(rotated_pos * 10.0, 2);
+ // Novikov-Thorne-style radial flux: F(r) ~ r^-3 (1 - sqrt(r_in/r)), zero at
+ // the inner edge, peaking just outside it, then declining. T ~ F^(1/4).
+ float xr = rc / rin;
+ float flux = max((1.0 - sqrt(1.0 / xr)) / (xr * xr * xr), 0.0);
+ float Tn = pow(flux / FLUX_PEAK, 0.25); // normalised temperature, peak ~ 1
+ float Temit = T_PEAK * Tn;
- float noise_mask = large_turbulence * 0.4 +
- medium_wisps * 0.3 +
- small_detail * 0.2 +
- fine_detail * 0.1;
+ // Keplerian orbit (prograde about +Y). Locally-measured orbital speed for a
+ // Schwarzschild circular geodesic: v = sqrt( M / (r - 2M) ) = 0.5 c at ISCO.
+ float3 rhat = normalize(float3(P.x, 0.0, P.z));
+ float3 phiHat = normalize(cross(float3(0.0, 1.0, 0.0), rhat));
+ float v = sqrt((SagA_rs * 0.5) / max(rc - SagA_rs, 1.0));
+ float3 beta = v * phiHat;
+ float3 nhat = -normalize(rayDir); // photon direction toward the observer
- noise_mask = smoothstep(0.25, 0.75, noise_mask);
+ float g = sqrt(max(1.0 - 1.5 * SagA_rs / rc, 0.0)) / max(1.0 - dot(beta, nhat), 1e-3);
- float angle = atan2(pos.z, pos.x);
- float rotated_angle = angle + sim.time * 0.5;
+ float Tobs = g * Temit;
+ float3 colour = Blackbody(Tobs);
+ // Physical bolometric intensity is ~ T_emit^4 * g^4, an enormous dynamic
+ // range. The g^4 relativistic beaming (the physical asymmetry) is kept; the
+ // radial falloff is display-compressed (Tn^2) so the colour gradient across
+ // the disk stays visible instead of collapsing to a thin saturated ring.
+ float bright = pow(Tn, 2.0) * pow(g, 4.0);
- float spiral_arms = sin(rotated_angle * 3.0 + r_norm * 15.0) * 0.15 + 0.85;
+ // Soft inner/outer edges (disks have no hard rim); also tames rim aliasing.
+ float edge = smoothstep(rin, rin * 1.12, rc) * (1.0 - smoothstep(rout * 0.88, rout, rc));
+ bright *= edge;
- float orbital_angle = angle + sim.time * keplerian_speed * 0.8;
- float orbital_pattern = sin(orbital_angle * 2.0 + r_norm * 8.0) * 0.2 + 0.8;
+ // Optional turbulence overlay (disk.disk_num = strength; 0 = smooth).
+ if (disk.disk_num > 0.0)
+ {
+ float ang = sim.time * 0.3 / sqrt(xr);
+ float3 rp = float3(P.x * cos(ang) - P.z * sin(ang),
+ 0.0,
+ P.x * sin(ang) + P.z * cos(ang)) * 1e-10;
+ float turb = 1.0 + disk.disk_num * (fbm(rp * 3.0, 3) - 0.5);
+ bright *= max(turb, 0.0);
+ }
- float density = vertical_falloff * radial_density * noise_mask * spiral_arms * orbital_pattern;
- return density * disk.disk_density;
+ float exposure = DISK_EXPOSURE * max(disk.disk_density, 0.0) * 10.0;
+ return colour * bright * exposure;
}
struct Ray
@@ -198,18 +224,14 @@ Ray InitRay(float3 pos, float3 dir)
ray.theta = acos(pos.z / ray.r);
ray.phi = atan2(pos.y, pos.x);
- float dx = dir.x;
- float dy = dir.y;
- float dz = dir.z;
+ 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));
@@ -217,31 +239,22 @@ Ray InitRay(float3 pos, float3 dir)
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));
+ 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;
-}
+bool Intercept(Ray ray, float rs) { return ray.r <= rs; }
bool InterceptObject(Ray ray, inout Hit hit)
{
float3 P = float3(ray.x, ray.y, ray.z);
-
for (int i = 0; i < obj.numObjects; ++i)
{
float3 center = obj.objPosRadius[i].xyz;
float radius = obj.objPosRadius[i].w;
-
float distSq = dot(P - center, P - center);
- if (distSq > radius * radius * 4.0)
- continue;
-
+ if (distSq > radius * radius * 4.0) continue;
if (distSq <= radius * radius)
{
hit.objectColor = obj.objColor[i];
@@ -250,7 +263,6 @@ bool InterceptObject(Ray ray, inout Hit hit)
return true;
}
}
-
return false;
}
@@ -276,7 +288,6 @@ void RK4Step(inout Ray ray, float dL)
{
float3 k1a, k1b;
GeodesicRHS(ray, k1a, k1b);
-
ray.r += dL * k1a.x;
ray.theta += dL * k1a.y;
ray.phi += dL * k1a.z;
@@ -289,71 +300,19 @@ void RK4Step(inout Ray ray, float dL)
ray.z = ray.r * cos(ray.theta);
}
-bool IsInDiskVolume(float3 pos)
-{
- float r_cyl = length(float2(pos.x, pos.z));
- return (r_cyl >= disk.disk_r1 && r_cyl <= disk.disk_r2 && abs(pos.y) <= disk.thickness);
-}
-
-float4 SampleDiskColor(float3 pos)
+float CalculateAdaptiveStepSize(Ray ray, float baseStepSize)
{
- float r_cyl = length(float2(pos.x, pos.z));
- float r_norm = (r_cyl - disk.disk_r1) / (disk.disk_r2 - disk.disk_r1);
-
- float3 innerColor = float3(1.0, 0.9, 0.5);
- float3 midColor = float3(1.0, 0.6, 0.2);
- float3 outerColor = float3(0.9, 0.3, 0.1);
-
- float3 baseColor;
- if (r_norm < 0.5)
- baseColor = lerp(innerColor, midColor, r_norm * 2.0);
- else
- baseColor = lerp(midColor, outerColor, (r_norm - 0.5) * 2.0);
-
- float r_norm_rot = (r_cyl - disk.disk_r1) / (disk.disk_r2 - disk.disk_r1);
- float keplerian_speed = 1.0 / sqrt(r_norm_rot + 0.1);
-
- float color_rotation_angle = sim.time * keplerian_speed * 0.3;
- float3 rotated_color_pos = float3(
- pos.x * cos(color_rotation_angle) - pos.z * sin(color_rotation_angle),
- pos.y,
- pos.x * sin(color_rotation_angle) + pos.z * cos(color_rotation_angle)
- ) * 1e-10;
-
- float large_color = fbm(rotated_color_pos * 1.8, 4);
- float medium_color = fbm(rotated_color_pos * 4.0, 3);
- float small_color = fbm(rotated_color_pos * 8.0, 2);
- float colorVariation = (large_color * 0.5 + medium_color * 0.3 + small_color * 0.2) * 0.6;
- baseColor = baseColor * (1.0 + colorVariation);
-
- float density = GetCloudDensity(pos);
-
- float brightness_rotation_angle = sim.time * keplerian_speed * 0.7;
- float3 rotated_brightness_pos = float3(
- pos.x * cos(brightness_rotation_angle) - pos.z * sin(brightness_rotation_angle),
- pos.y,
- pos.x * sin(brightness_rotation_angle) + pos.z * cos(brightness_rotation_angle)
- ) * 1e-10;
-
- float brightness_large = fbm(rotated_brightness_pos * 3.0, 3);
- float brightness_medium = fbm(rotated_brightness_pos * 5.0, 2);
- float brightness_small = fbm(rotated_brightness_pos * 7.0, 2);
- float brightness_noise = (brightness_large * 0.6 + brightness_medium * 0.3 + brightness_small * 0.1);
-
- float baseBrightness = 1.0 + density * 1.5;
- float glowBrightness = brightness_noise * 0.8;
- float brightness = baseBrightness + glowBrightness;
-
- return float4(baseColor * brightness, density);
+ // Step proportional to the distance from the photon sphere: near-flat space
+ // far from the hole is crossed in a few huge steps, while the sharply curved
+ // region near the photon sphere is resolved with tiny ones. This keeps the
+ // integration accurate near the hole regardless of how far the camera is.
+ float step = 0.02 * max(ray.r - R_PHOTON, 0.0);
+ return clamp(step, MIN_STEP_SIZE, MAX_STEP_SIZE);
}
-float CalculateAdaptiveStepSize(Ray ray, float baseStepSize)
+float3 ACESFilm(float3 x)
{
- float r_factor = clamp(ray.r / (SagA_rs * 10.0), 0.1, 1.0);
- float curvature = length(float3(ray.dr, ray.dtheta * ray.r, ray.dphi * ray.r * sin(ray.theta)));
- float curvature_factor = clamp(1e12 / (curvature + 1e6), 0.1, 2.0);
-
- return clamp(baseStepSize * r_factor * curvature_factor, MIN_STEP_SIZE, MAX_STEP_SIZE);
+ return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0);
}
[shader("fragment")]
@@ -364,127 +323,79 @@ float4 fragmentMain(VSOutput input) : SV_Target
float3 dir = normalize(u * cam.camRight - v * cam.camUp + cam.camForward);
Ray ray = InitRay(cam.camPos, dir);
- float4 color = float4(0.0, 0.0, 0.0, 0.0);
-
- bool hitBlackHole = false;
- bool hitObject = false;
-
- Hit hit;
- hit.objectColor = float4(0.0, 0.0, 0.0, 0.0);
- hit.hitCenter = float3(0.0, 0.0, 0.0);
+ bool hitBlackHole = false;
+ bool hitObject = false;
+ Hit hit;
+ hit.objectColor = float4(0.0);
+ hit.hitCenter = float3(0.0);
hit.hitRadius = 0.0;
- float4 accumulatedColor = float4(0.0, 0.0, 0.0, 0.0);
- float transmittance = 1.0;
+ bool hitDisk = false;
+ float3 diskColor = float3(0.0); // emission of the first (opaque) disk surface hit
int maxSteps = cam.moving ? sim.maxStepsMoving : sim.maxStepsStatic;
-
if (maxSteps <= 0)
maxSteps = cam.moving ? DEFAULT_MAX_STEPS_MOVING : DEFAULT_MAX_STEPS_STATIC;
- float cameraDistance = length(cam.camPos);
- if (cameraDistance > 2e12)
- maxSteps = maxSteps / 2;
- else if (cameraDistance > 1e12)
- maxSteps = int(maxSteps * 0.75);
-
- float initialEscapeVelocity = sqrt(2.0 * SagA_rs / ray.r);
- if (ray.dr > initialEscapeVelocity * 0.95 &&
- ray.r > SagA_rs * 200.0)
- maxSteps = maxSteps / 2;
-
- float lambda = 0.0;
- int objectCheckInterval = 5;
+ float exitDistance = sim.earlyExitDistance > 0.0 ? sim.earlyExitDistance : DEFAULT_EARLY_EXIT_DISTANCE;
+ int objectCheckInterval = 5;
for (int i = 0; i < maxSteps; ++i)
{
- float exitDistance = sim.earlyExitDistance > 0.0 ? sim.earlyExitDistance : DEFAULT_EARLY_EXIT_DISTANCE;
- if (ray.r > exitDistance)
- break;
- if (ray.r > ESCAPE_R)
- break;
-
- if (Intercept(ray, SagA_rs))
- {
- hitBlackHole = true;
- break;
- }
-
- float currentStepSize = CalculateAdaptiveStepSize(ray, D_LAMBDA);
-
- RK4Step(ray, currentStepSize);
- lambda += currentStepSize;
+ if (Intercept(ray, SagA_rs)) { hitBlackHole = true; break; }
+ if (ray.r > exitDistance || ray.r > ESCAPE_R) break;
+ float3 prevPos = float3(ray.x, ray.y, ray.z);
+ float stepSize = CalculateAdaptiveStepSize(ray, D_LAMBDA);
+ RK4Step(ray, stepSize);
float3 newPos = float3(ray.x, ray.y, ray.z);
- if (IsInDiskVolume(newPos))
+ // Opaque thin disk: a sign change in y means the ray pierced the disk
+ // plane (y = 0). The first crossing inside the annulus is a solid,
+ // self-luminous surface -- it emits and blocks everything behind it, so
+ // the ray stops here (near side occludes far side / background).
+ if (prevPos.y * newPos.y < 0.0)
{
- float4 diskSample = SampleDiskColor(newPos);
- float density = diskSample.a;
- float3 diskColor = diskSample.rgb;
-
- float stepLength = currentStepSize * 1e-8;
-
- float absorption = density * stepLength * 0.8;
- float scattering = density * stepLength * 1.5;
- float extinction = absorption + scattering;
-
- float stepTransmittance = exp(-extinction);
-
- float3 emission = diskColor * density * stepLength * 4.0 * sqrt(disk.disk_density);
-
- float3 glowColor = lerp(diskColor, float3(1.0, 0.8, 0.6), 0.3);
- float glowIntensity = density * stepLength * 2.0;
- float3 atmosphericGlow = glowColor * glowIntensity * 0.8;
-
- float3 totalEmission = emission + atmosphericGlow;
- accumulatedColor.rgb += totalEmission * transmittance;
-
- transmittance *= stepTransmittance;
-
- if (transmittance < 0.01)
+ float t = prevPos.y / (prevPos.y - newPos.y);
+ float3 cross = lerp(prevPos, newPos, t);
+ float rc = length(float2(cross.x, cross.z));
+ if (rc >= max(disk.disk_r1, R_ISCO) && rc <= disk.disk_r2)
{
- accumulatedColor.a = 1.0 - transmittance;
+ diskColor = DiskEmission(cross, newPos - prevPos);
+ hitDisk = true;
break;
}
}
- if (i % objectCheckInterval == 0 && InterceptObject(ray, hit))
- {
- hitObject = true;
- break;
- }
+ if (i % objectCheckInterval == 0 && InterceptObject(ray, hit)) { hitObject = true; break; }
- if (ray.dr > 0.0 && ray.r > SagA_rs * 100.0 && lambda > 2e8)
- break;
+ // Principled escape: once outbound in near-flat spacetime (r >> r_s) the
+ // ray direction no longer changes, so stop and read the background.
+ if (ray.dr > 0.0 && ray.r > 50.0 * SagA_rs) break;
}
- accumulatedColor.a = 1.0 - transmittance;
-
- if (hitBlackHole)
+ float3 shade;
+ if (hitDisk)
{
- color = float4(0.0, 0.0, 0.0, 1.0);
+ shade = diskColor; // opaque, self-luminous disk surface
+ }
+ else if (hitBlackHole)
+ {
+ shade = float3(0.0); // event-horizon shadow
}
else if (hitObject)
{
float3 P = float3(ray.x, ray.y, ray.z);
float3 N = normalize(P - hit.hitCenter);
float3 V = normalize(cam.camPos - P);
-
- float ambient = 0.1;
- float diff = max(dot(N, V), 0.0);
- float intensity = ambient + (1.0 - ambient) * diff;
- float3 shaded = hit.objectColor.rgb * intensity;
-
- color = float4(shaded, hit.objectColor.a);
- color = lerp(accumulatedColor, color, color.a);
+ float intensity = 0.1 + 0.9 * max(dot(N, V), 0.0);
+ shade = hit.objectColor.rgb * intensity;
}
else
{
- float3 rayDirection = normalize(float3(ray.x, ray.y, ray.z) - cam.camPos);
- float3 hdriColor = SampleHDRI(rayDirection);
- color = float4(lerp(accumulatedColor.rgb, hdriColor, 1.0 - accumulatedColor.a), 1.0);
+ float3 rayDir = normalize(float3(ray.x, ray.y, ray.z) - cam.camPos);
+ shade = SampleHDRI(rayDir);
}
- return color;
+ return float4(ACESFilm(shade), 1.0);
}
diff --git a/src/platform/vulkan/vulkan_renderer.cpp b/src/platform/vulkan/vulkan_renderer.cpp
index f2037dc..e19bee4 100644
--- a/src/platform/vulkan/vulkan_renderer.cpp
+++ b/src/platform/vulkan/vulkan_renderer.cpp
@@ -1,6 +1,7 @@
#include "vulkan_renderer.h"
#include "core/log.h"
#include "core/camera.h"
+#include "core/settings_manager.h"
#define GLFW_INCLUDE_VULKAN
#include <GLFW/glfw3.h>
@@ -82,20 +83,29 @@ namespace Donut
VkPhysicalDeviceMemoryProperties mem_props{};
- // Geodesic scene, rendered every frame into a fixed low-resolution
- // offscreen image (keeps each draw well under the Metal GPU watchdog),
- // then upscaled onto the swapchain by the present pass below.
- static constexpr uint32_t GEO_W = 480, GEO_H = 270;
- VkImage geo_image = VK_NULL_HANDLE;
- VkDeviceMemory geo_image_mem = VK_NULL_HANDLE;
- VkImageView geo_image_view = VK_NULL_HANDLE;
- VkRenderPass geo_render_pass = VK_NULL_HANDLE;
- VkFramebuffer geo_framebuffer = VK_NULL_HANDLE;
+ // The geodesic is rendered every frame into an offscreen image, then
+ // upscaled onto the swapchain by the present pass. Progressive resolution:
+ // low-res while the camera moves (keeps dragging responsive and each draw
+ // well under the Metal GPU watchdog) and high-res once it settles (resolves
+ // the photon ring cleanly). Both are 16:9 so the camera aspect is shared.
+ static constexpr uint32_t GEO_LO_W = 480, GEO_LO_H = 270;
+ static constexpr uint32_t GEO_HI_W = 960, GEO_HI_H = 540;
+ struct GeoTarget
+ {
+ uint32_t w = 0, h = 0;
+ VkImage image = VK_NULL_HANDLE;
+ VkDeviceMemory mem = VK_NULL_HANDLE;
+ VkImageView view = VK_NULL_HANDLE;
+ VkFramebuffer fb = VK_NULL_HANDLE;
+ VkDescriptorSet present_set = VK_NULL_HANDLE; // present pass samples this target
+ };
+ GeoTarget geo_lo, geo_hi;
+ VkRenderPass geo_render_pass = VK_NULL_HANDLE; // shared (resolution-independent)
VkBuffer cam_buf = VK_NULL_HANDLE, disk_buf = VK_NULL_HANDLE, obj_buf = VK_NULL_HANDLE, sim_buf = VK_NULL_HANDLE;
VkDeviceMemory cam_mem = VK_NULL_HANDLE, disk_mem = VK_NULL_HANDLE, obj_mem = VK_NULL_HANDLE, sim_mem = VK_NULL_HANDLE;
void* cam_mapped = nullptr;
void* sim_mapped = nullptr;
- Camera camera{ 60.0f, (float)GEO_W / (float)GEO_H, 0.1f, 100.0f };
+ Camera camera{ 60.0f, (float)GEO_HI_W / (float)GEO_HI_H, 0.1f, 100.0f };
bool left_was_down = false;
double last_frame_time = 0.0; // for free-fly dt
double fps_last_x = 0.0, fps_last_y = 0.0; // cursor tracking for free-fly look
@@ -117,14 +127,13 @@ namespace Donut
VkSampler present_sampler = VK_NULL_HANDLE;
VkDescriptorSetLayout present_set_layout = VK_NULL_HANDLE;
VkDescriptorPool present_pool = VK_NULL_HANDLE;
- VkDescriptorSet present_set = VK_NULL_HANDLE;
VkPipelineLayout present_pipeline_layout = VK_NULL_HANDLE;
VkPipeline present_pipeline = VK_NULL_HANDLE;
// World-builder scene view (grid now; sphere/skybox next). Normal-scale
// orbital camera; geometry drawn straight into the swapchain render pass.
bool scene_mode = false;
- Camera scene_camera{ 45.0f, (float)GEO_W / (float)GEO_H, 0.1f, 1000.0f };
+ Camera scene_camera{ 45.0f, (float)GEO_HI_W / (float)GEO_HI_H, 0.1f, 1000.0f };
VkBuffer grid_vb = VK_NULL_HANDLE; VkDeviceMemory grid_vb_mem = VK_NULL_HANDLE;
uint32_t grid_vertex_count = 0;
VkBuffer grid_ubo = VK_NULL_HANDLE; VkDeviceMemory grid_ubo_mem = VK_NULL_HANDLE;
@@ -184,6 +193,7 @@ namespace Donut
auto create_command_buffers() -> bool;
auto create_sync_objects() -> bool;
auto create_geodesic_resources() -> bool;
+ auto create_geo_target(GeoTarget& target, uint32_t w, uint32_t h) -> bool;
auto create_hdri_cubemap(const char* path) -> bool;
auto rebuild_hdri_cubemap(const char* path) -> void;
auto create_present_resources() -> bool;
@@ -584,28 +594,42 @@ namespace Donut
return vkCreateShaderModule(device, &ci, nullptr, &out) == VK_SUCCESS;
}
- auto VulkanRenderer::Impl::create_geodesic_resources() -> bool
+ // Creates one geodesic render target (image + memory + view + framebuffer)
+ // at w x h against the shared geo_render_pass. Used for the low- and high-res
+ // progressive targets.
+ auto VulkanRenderer::Impl::create_geo_target(GeoTarget& t, uint32_t w, uint32_t h) -> bool
{
const VkFormat fmt = VK_FORMAT_R8G8B8A8_UNORM;
- const VkMemoryPropertyFlags host_vis = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
- const float SagA_rs = 1.269e10f;
+ t.w = w; t.h = h;
VkImageCreateInfo ici{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO };
- ici.imageType = VK_IMAGE_TYPE_2D; ici.format = fmt; ici.extent = { GEO_W, GEO_H, 1 };
+ ici.imageType = VK_IMAGE_TYPE_2D; ici.format = fmt; ici.extent = { w, h, 1 };
ici.mipLevels = 1; ici.arrayLayers = 1; ici.samples = VK_SAMPLE_COUNT_1_BIT;
ici.tiling = VK_IMAGE_TILING_OPTIMAL;
ici.usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT;
- VK_CHECK(vkCreateImage(device, &ici, nullptr, &geo_image));
- VkMemoryRequirements im_req{}; vkGetImageMemoryRequirements(device, geo_image, &im_req);
+ VK_CHECK(vkCreateImage(device, &ici, nullptr, &t.image));
+ VkMemoryRequirements im_req{}; vkGetImageMemoryRequirements(device, t.image, &im_req);
VkMemoryAllocateInfo im_alloc{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO };
im_alloc.allocationSize = im_req.size;
im_alloc.memoryTypeIndex = find_memory_type(im_req.memoryTypeBits, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT);
- VK_CHECK(vkAllocateMemory(device, &im_alloc, nullptr, &geo_image_mem));
- VK_CHECK(vkBindImageMemory(device, geo_image, geo_image_mem, 0));
+ VK_CHECK(vkAllocateMemory(device, &im_alloc, nullptr, &t.mem));
+ VK_CHECK(vkBindImageMemory(device, t.image, t.mem, 0));
VkImageViewCreateInfo vci{ VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO };
- vci.image = geo_image; vci.viewType = VK_IMAGE_VIEW_TYPE_2D; vci.format = fmt;
+ vci.image = t.image; vci.viewType = VK_IMAGE_VIEW_TYPE_2D; vci.format = fmt;
vci.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 };
- VK_CHECK(vkCreateImageView(device, &vci, nullptr, &geo_image_view));
+ VK_CHECK(vkCreateImageView(device, &vci, nullptr, &t.view));
+ VkFramebufferCreateInfo fbci{ VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO };
+ fbci.renderPass = geo_render_pass; fbci.attachmentCount = 1; fbci.pAttachments = &t.view;
+ fbci.width = w; fbci.height = h; fbci.layers = 1;
+ VK_CHECK(vkCreateFramebuffer(device, &fbci, nullptr, &t.fb));
+ return true;
+ }
+
+ auto VulkanRenderer::Impl::create_geodesic_resources() -> bool
+ {
+ const VkFormat fmt = VK_FORMAT_R8G8B8A8_UNORM;
+ const VkMemoryPropertyFlags host_vis = VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT;
+ const float SagA_rs = 1.269e10f;
VkAttachmentDescription color{};
color.format = fmt; color.samples = VK_SAMPLE_COUNT_1_BIT;
@@ -627,10 +651,9 @@ namespace Donut
rpci.subpassCount = 1; rpci.pSubpasses = &subpass;
rpci.dependencyCount = 2; rpci.pDependencies = deps;
VK_CHECK(vkCreateRenderPass(device, &rpci, nullptr, &geo_render_pass));
- VkFramebufferCreateInfo fbci{ VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO };
- fbci.renderPass = geo_render_pass; fbci.attachmentCount = 1; fbci.pAttachments = &geo_image_view;
- fbci.width = GEO_W; fbci.height = GEO_H; fbci.layers = 1;
- VK_CHECK(vkCreateFramebuffer(device, &fbci, nullptr, &geo_framebuffer));
+
+ if (!create_geo_target(geo_lo, GEO_LO_W, GEO_LO_H)) return false;
+ if (!create_geo_target(geo_hi, GEO_HI_W, GEO_HI_H)) return false;
create_buffer(128, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, host_vis, cam_buf, cam_mem);
create_buffer(32, VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, host_vis, disk_buf, disk_mem);
@@ -639,17 +662,19 @@ namespace Donut
camera.set_camera_mode(CameraMode::Orbital);
camera.set_orbital_target(glm::vec3(0.0f));
- camera.set_orbital_radius(1e11);
- camera.set_orbital_limits(4e10, 3e11);
+ camera.set_orbital_radius(4e11); // ~31 r_s: outside the 12 r_s disk
+ camera.set_orbital_limits(2.2e11, 1.5e12);
camera.set_orbital_speed(0.01f);
- camera.set_zoom_speed(1e10);
+ camera.set_zoom_speed(3e10);
camera.set_azimuth(0.0f);
camera.set_elevation(1.25f);
void* p = nullptr;
vkMapMemory(device, cam_mem, 0, 128, 0, &cam_mapped); // camera UBO is refilled every frame
- float disk_data[8] = { SagA_rs * 2.2f, SagA_rs * 5.2f, 2.0f, SagA_rs * 0.1f, 0.1f, 0, 0, 0 };
+ // disk_r1=inner (ISCO 3 r_s), disk_r2=outer, disk_num=turbulence strength,
+ // thickness (unused by the thin-disk model), disk_density=exposure.
+ float disk_data[8] = { SagA_rs * 3.0f, SagA_rs * 12.0f, 0.4f, SagA_rs * 0.1f, 0.1f, 0, 0, 0 };
vkMapMemory(device, disk_mem, 0, 32, 0, &p); memcpy(p, disk_data, sizeof(disk_data)); vkUnmapMemory(device, disk_mem);
std::vector<uint8_t> obj_data(800, 0);
@@ -704,8 +729,11 @@ namespace Donut
vin.vertexBindingDescriptionCount = 1; vin.pVertexBindingDescriptions = &vib;
vin.vertexAttributeDescriptionCount = 2; vin.pVertexAttributeDescriptions = via;
VkPipelineInputAssemblyStateCreateInfo ia{ VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO }; ia.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST;
- VkViewport vp{ 0, 0, (float)GEO_W, (float)GEO_H, 0, 1 }; VkRect2D sc{ { 0, 0 }, { GEO_W, GEO_H } };
- VkPipelineViewportStateCreateInfo vps{ VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO }; vps.viewportCount = 1; vps.pViewports = &vp; vps.scissorCount = 1; vps.pScissors = &sc;
+ // Dynamic viewport/scissor: the same pipeline renders into either the
+ // low- or high-res target, sized per frame in record_command_buffer.
+ VkPipelineViewportStateCreateInfo vps{ VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO }; vps.viewportCount = 1; vps.scissorCount = 1;
+ VkDynamicState dyn_states[2] = { VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR };
+ VkPipelineDynamicStateCreateInfo dyn{ VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO }; dyn.dynamicStateCount = 2; dyn.pDynamicStates = dyn_states;
VkPipelineRasterizationStateCreateInfo rs{ VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO }; rs.polygonMode = VK_POLYGON_MODE_FILL; rs.cullMode = VK_CULL_MODE_NONE; rs.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; rs.lineWidth = 1.0f;
VkPipelineMultisampleStateCreateInfo ms{ VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO }; ms.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT;
VkPipelineColorBlendAttachmentState cba{}; cba.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT;
@@ -714,6 +742,7 @@ namespace Donut
gpci.stageCount = 2; gpci.pStages = stages;
gpci.pVertexInputState = &vin; gpci.pInputAssemblyState = &ia; gpci.pViewportState = &vps;
gpci.pRasterizationState = &rs; gpci.pMultisampleState = &ms; gpci.pColorBlendState = &cb;
+ gpci.pDynamicState = &dyn;
gpci.layout = geo_pipeline_layout; gpci.renderPass = geo_render_pass; gpci.subpass = 0;
VkResult pr = vkCreateGraphicsPipelines(device, VK_NULL_HANDLE, 1, &gpci, nullptr, &geo_pipeline);
vkDestroyShaderModule(device, vmod, nullptr); vkDestroyShaderModule(device, fmod, nullptr);
@@ -721,7 +750,8 @@ namespace Donut
start_time = glfwGetTime();
update_geodesic_uniforms();
- DONUT_INFO("Vulkan: geodesic resources ready ({}x{} offscreen)", (int)GEO_W, (int)GEO_H);
+ DONUT_INFO("Vulkan: geodesic resources ready ({}x{} moving / {}x{} settled)",
+ (int)GEO_LO_W, (int)GEO_LO_H, (int)GEO_HI_W, (int)GEO_HI_H);
return true;
}
@@ -1021,17 +1051,22 @@ namespace Donut
VkDescriptorSetLayoutCreateInfo dslci{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO };
dslci.bindingCount = 1; dslci.pBindings = &bind;
VK_CHECK(vkCreateDescriptorSetLayout(device, &dslci, nullptr, &present_set_layout));
- VkDescriptorPoolSize psize{ VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1 };
+ // One present set per geodesic target (low-/high-res), each sampling its
+ // own image; record_command_buffer binds whichever was rendered this frame.
+ VkDescriptorPoolSize psize{ VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 2 };
VkDescriptorPoolCreateInfo dpci{ VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO };
- dpci.maxSets = 1; dpci.poolSizeCount = 1; dpci.pPoolSizes = &psize;
+ dpci.maxSets = 2; dpci.poolSizeCount = 1; dpci.pPoolSizes = &psize;
VK_CHECK(vkCreateDescriptorPool(device, &dpci, nullptr, &present_pool));
- VkDescriptorSetAllocateInfo dsai{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO };
- dsai.descriptorPool = present_pool; dsai.descriptorSetCount = 1; dsai.pSetLayouts = &present_set_layout;
- VK_CHECK(vkAllocateDescriptorSets(device, &dsai, &present_set));
- VkDescriptorImageInfo ii{ present_sampler, geo_image_view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL };
- VkWriteDescriptorSet write{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET };
- write.dstSet = present_set; write.dstBinding = 0; write.descriptorCount = 1; write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; write.pImageInfo = &ii;
- vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
+ for (GeoTarget* t : { &geo_lo, &geo_hi })
+ {
+ VkDescriptorSetAllocateInfo dsai{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO };
+ dsai.descriptorPool = present_pool; dsai.descriptorSetCount = 1; dsai.pSetLayouts = &present_set_layout;
+ VK_CHECK(vkAllocateDescriptorSets(device, &dsai, &t->present_set));
+ VkDescriptorImageInfo ii{ present_sampler, t->view, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL };
+ VkWriteDescriptorSet write{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET };
+ write.dstSet = t->present_set; write.dstBinding = 0; write.descriptorCount = 1; write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; write.pImageInfo = &ii;
+ vkUpdateDescriptorSets(device, 1, &write, 0, nullptr);
+ }
VkShaderModule vmod, fmod;
if (!create_shader_module("assets/shaders/generated/TexturedQuad.vertexMain.spv", vmod)) return false;
@@ -1399,14 +1434,24 @@ namespace Donut
glm::vec3 right = glm::normalize(glm::cross(fwd, glm::vec3(0, 1, 0)));
cam_data.pos = pos; cam_data.right = right; cam_data.up = glm::cross(right, fwd); cam_data.fwd = fwd;
cam_data.tan_half_fov = (float)tan(glm::radians(60.0f * 0.5f));
- cam_data.aspect = (float)GEO_W / (float)GEO_H;
+ cam_data.aspect = (float)GEO_HI_W / (float)GEO_HI_H; // 16:9, same for both targets
cam_data.moving = user_moving ? 1u : 0u;
if (cam_mapped) memcpy(cam_mapped, &cam_data, sizeof(cam_data));
- // Fewer integration steps while the camera moves keeps dragging responsive;
- // more steps once it settles renders the disk in full.
+ // Integration budget drives how deep the geodesics are traced: too few
+ // steps and grazing rays exhaust the loop mid-lensing, so the shader
+ // paints them with the HDRI (Geodesic.slang) and the hole looks sliced
+ // off "up to a certain depth" -- and near face-on it vanishes entirely.
+ // Measured: at ~8000 steps the disk disappears at steep poses, ~15000 is
+ // needed for it to resolve, INDEPENDENT of resolution. So we DON'T drop
+ // steps while moving (that would make the hole flicker out mid-drag);
+ // responsiveness comes from the lower-res target instead (see record).
+ // Sourced from settings.toml (max_steps_static), capped GPU-safe.
+ constexpr int kStepCeil = 15000;
struct SimUBO { int steps_moving; int steps_static; float early_exit; float time; } sim;
- sim.steps_moving = 3500; sim.steps_static = 5000; sim.early_exit = 5e12f;
+ sim.steps_static = std::clamp(SettingsManager::get_max_steps_static(), 1000, kStepCeil);
+ sim.steps_moving = sim.steps_static; // same budget both frames; resolution is the lever
+ sim.early_exit = SettingsManager::get_early_exit_distance();
sim.time = (float)(glfwGetTime() - start_time);
if (sim_mapped) memcpy(sim_mapped, &sim, sizeof(sim));
}
@@ -1529,11 +1574,14 @@ namespace Donut
VkBuffer ubos[4] = { cam_buf, disk_buf, obj_buf, sim_buf };
VkDeviceMemory umem[4] = { cam_mem, disk_mem, obj_mem, sim_mem };
for (int i = 0; i < 4; ++i) { if (ubos[i]) vkDestroyBuffer(device, ubos[i], nullptr); if (umem[i]) vkFreeMemory(device, umem[i], nullptr); }
- if (geo_framebuffer) vkDestroyFramebuffer(device, geo_framebuffer, nullptr);
+ for (GeoTarget* t : { &geo_lo, &geo_hi })
+ {
+ if (t->fb) vkDestroyFramebuffer(device, t->fb, nullptr);
+ if (t->view) vkDestroyImageView(device, t->view, nullptr);
+ if (t->image) vkDestroyImage(device, t->image, nullptr);
+ if (t->mem) vkFreeMemory(device, t->mem, nullptr);
+ }
if (geo_render_pass) vkDestroyRenderPass(device, geo_render_pass, nullptr);
- if (geo_image_view) vkDestroyImageView(device, geo_image_view, nullptr);
- if (geo_image) vkDestroyImage(device, geo_image, nullptr);
- if (geo_image_mem) vkFreeMemory(device, geo_image_mem, nullptr);
}
auto VulkanRenderer::Impl::record_command_buffer(VkCommandBuffer cmd, uint32_t image_index, const glm::vec4& clear, ImDrawData* draw_data) -> bool
@@ -1589,14 +1637,20 @@ namespace Donut
return true;
}
- // Geodesic offscreen pass
+ // Geodesic offscreen pass. Progressive resolution: low-res while the
+ // camera moves (4x fewer pixels -> responsive), high-res once it settles
+ // (resolves the photon ring). Step count is the same for both (the disk
+ // vanishes below ~15000 steps at steep poses), so resolution is the lever.
+ GeoTarget& gt = user_moving ? geo_lo : geo_hi;
VkClearValue geo_clear{}; geo_clear.color = { { 0, 0, 0, 1 } };
VkRenderPassBeginInfo grp{ VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO };
- grp.renderPass = geo_render_pass; grp.framebuffer = geo_framebuffer;
- grp.renderArea = { { 0, 0 }, { GEO_W, GEO_H } };
+ grp.renderPass = geo_render_pass; grp.framebuffer = gt.fb;
+ grp.renderArea = { { 0, 0 }, { gt.w, gt.h } };
grp.clearValueCount = 1; grp.pClearValues = &geo_clear;
vkCmdBeginRenderPass(cmd, &grp, VK_SUBPASS_CONTENTS_INLINE);
vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, geo_pipeline);
+ VkViewport gvp{ 0, 0, (float)gt.w, (float)gt.h, 0, 1 }; VkRect2D gsc{ { 0, 0 }, { gt.w, gt.h } };
+ vkCmdSetViewport(cmd, 0, 1, &gvp); vkCmdSetScissor(cmd, 0, 1, &gsc);
vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, geo_pipeline_layout, 0, 1, &geo_set, 0, nullptr);
VkDeviceSize off = 0; vkCmdBindVertexBuffers(cmd, 0, 1, &quad_vb, &off);
vkCmdDraw(cmd, 6, 1, 0, 0);
@@ -1618,7 +1672,7 @@ namespace Donut
VkRect2D scissor{ { 0, 0 }, swapchain_extent };
vkCmdSetViewport(cmd, 0, 1, &vp);
vkCmdSetScissor(cmd, 0, 1, &scissor);
- vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, present_pipeline_layout, 0, 1, &present_set, 0, nullptr);
+ vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, present_pipeline_layout, 0, 1, &gt.present_set, 0, nullptr);
vkCmdBindVertexBuffers(cmd, 0, 1, &quad_vb, &off);
vkCmdDraw(cmd, 6, 1, 0, 0);
if (draw_data)