diff options
| author | hachem <im@hachem.wtf> | 2026-08-24 17:07:54 +0200 |
|---|---|---|
| committer | hachem <im@hachem.wtf> | 2026-08-24 17:07:54 +0200 |
| commit | 06398a7a176e123506de6e8851866a8bec0b3427 (patch) | |
| tree | 7167ef4818f2c224840122961882efb5ec26c80b | |
| parent | 1c85c2ecaa826afd8bf80f93741cf1b2deba5798 (diff) | |
[chore]: cleanup shaders
| -rw-r--r-- | README.md | 2 | ||||
| -rw-r--r-- | assets/shaders/blur.slang | 60 | ||||
| -rw-r--r-- | assets/shaders/equirect_to_cubemap.slang | 12 | ||||
| -rw-r--r-- | assets/shaders/geodesic.slang | 148 | ||||
| -rw-r--r-- | assets/shaders/grid.slang | 26 | ||||
| -rw-r--r-- | assets/shaders/skybox.slang | 12 | ||||
| -rw-r--r-- | assets/shaders/sphere.slang | 60 | ||||
| -rw-r--r-- | assets/shaders/textured_quad.slang | 14 | ||||
| -rw-r--r-- | assets/shaders/vk_pipeline_test.slang | 10 | ||||
| -rw-r--r-- | branding/logo_white.jpg | bin | 31870 -> 133060 bytes | |||
| -rw-r--r-- | docs/README.md | 2 | ||||
| -rw-r--r-- | docs/architecture.md | 58 | ||||
| -rw-r--r-- | docs/physics.md | 6 | ||||
| -rw-r--r-- | src/core/settings_manager.cpp | 10 | ||||
| -rw-r--r-- | src/core/settings_manager.h | 4 | ||||
| -rw-r--r-- | src/platform/opengl/opengl_device.cpp | 6 | ||||
| -rw-r--r-- | src/rendering/scene_renderer.cpp | 6 | ||||
| -rw-r--r-- | src/ui/ui_layer.cpp | 685 | ||||
| -rw-r--r-- | src/ui/ui_layer.h | 60 | ||||
| -rw-r--r-- | src/ui/workspace.h | 23 |
20 files changed, 664 insertions, 540 deletions
@@ -24,7 +24,7 @@ For each pixel, Donut generates a ray from the camera using its position, orient ## Documentation For a deeper explanation of how Donut works, see the [`docs/`](docs/) folder: - [docs/physics.md](docs/physics.md) — the physics and mathematics: null geodesics and the equations of motion, the numerical integrator, the event horizon / photon sphere / ISCO, the Novikov–Thorne accretion disk, gravitational + Doppler redshift and relativistic beaming, and the observable quantities the renderer can measure. -- [docs/architecture.md](docs/architecture.md) — how the program is built: the portable OpenGL/Vulkan RHI, the two renderers, the unified scene-and-simulation world, the rendering pipeline, the Dorico-style tabs, and the physical-value export pipeline. +- [docs/architecture.md](docs/architecture.md) — how the program is built: the portable OpenGL/Vulkan RHI, the two renderers, the unified scene-and-simulation world, the rendering pipeline, the docking UI, and the physical-value export pipeline. ## License Donut is released under the MIT License. diff --git a/assets/shaders/blur.slang b/assets/shaders/blur.slang deleted file mode 100644 index 17e4eae..0000000 --- a/assets/shaders/blur.slang +++ /dev/null @@ -1,60 +0,0 @@ -struct VSInput -{ - float2 position : POSITION; - float2 texCoord : TEXCOORD0; -}; - -struct VSOutput -{ - float4 position : SV_Position; - float2 texCoord : TEXCOORD0; -}; - -[shader("vertex")] -VSOutput vertexMain(VSInput input) -{ - VSOutput output; - output.position = float4(input.position, 0.0, 1.0); - output.texCoord = input.texCoord; - return output; -} - -Sampler2D u_ScreenTexture; -uniform float2 u_Resolution; -uniform float u_BlurStrength; -uniform float u_GlowIntensity; - -[shader("fragment")] -float4 fragmentMain(VSOutput input) : SV_Target -{ - float2 texelSize = 1.0 / u_Resolution; - float4 centerColor = u_ScreenTexture.Sample(input.texCoord); - - float brightness = (centerColor.r + centerColor.g + centerColor.b) / 3.0; - float glowMask = smoothstep(0.05, 0.3, brightness); - - float4 blurredColor = float4(0.0, 0.0, 0.0, 0.0); - float totalWeight = 0.0; - - for (int x = -8; x <= 8; x++) - { - for (int y = -8; y <= 8; y++) - { - float2 offset = float2(x, y) * texelSize * u_BlurStrength; - float4 sampleColor = u_ScreenTexture.Sample(input.texCoord + offset); - - float weight = exp(-float(x * x + y * y) / (2.0 * 8.0 * 8.0)); - blurredColor += sampleColor * weight; - totalWeight += weight; - } - } - - blurredColor /= totalWeight; - - float3 emissionColor = float3(1.0, 0.8, 0.6); - float3 glowColor = emissionColor * glowMask * u_GlowIntensity * 2.0; - float3 auraColor = blurredColor.rgb * glowMask * u_GlowIntensity * 0.8; - float3 finalColor = centerColor.rgb + glowColor + auraColor; - - return float4(finalColor, centerColor.a); -} diff --git a/assets/shaders/equirect_to_cubemap.slang b/assets/shaders/equirect_to_cubemap.slang index 9b2ea4b..510e6b0 100644 --- a/assets/shaders/equirect_to_cubemap.slang +++ b/assets/shaders/equirect_to_cubemap.slang @@ -1,5 +1,11 @@ -uniform float4x4 u_Projection; -uniform float4x4 u_View; +// Equirectangular HDRI -> cubemap face projection. NOTE: the OpenGL path uses the +// hand-written assets/shaders/EquirectToCubemap.glsl instead; this Slang version +// feeds the Vulkan cubemap bake (vulkan_cubemap.cpp), which binds the view / +// projection as a $Globals UBO. Kept as loose `uniform` globals (not a +// ConstantBuffer) so both paths bind them the same way. + +uniform float4x4 u_projection; +uniform float4x4 u_view; struct VSInput { float3 position : POSITION; }; struct VSOutput { float4 position : SV_Position; float3 worldPos : TEXCOORD0; }; @@ -9,7 +15,7 @@ VSOutput vertexMain(VSInput input) { VSOutput output; output.worldPos = input.position; - output.position = mul(u_Projection, mul(u_View, float4(input.position, 1.0))); + output.position = mul(u_projection, mul(u_view, float4(input.position, 1.0))); return output; } diff --git a/assets/shaders/geodesic.slang b/assets/shaders/geodesic.slang index eee62da..eb536c9 100644 --- a/assets/shaders/geodesic.slang +++ b/assets/shaders/geodesic.slang @@ -1,3 +1,9 @@ +// Ray-traced Schwarzschild black hole: back-traces one null geodesic per pixel +// through curved spacetime, shading the accretion disk, the lensed HDRI +// background, and placed objects. Runs as a full-screen fragment pass. Uniforms +// live in ConstantBuffers (UBOs) driven through the RHI; the HDRI is a cubemap +// sampler. See docs/physics.md for the maths. + struct VSInput { float2 position : POSITION; float2 texCoord : TEXCOORD0; }; struct VSOutput { float4 position : SV_Position; float2 texCoord : TEXCOORD0; }; @@ -12,54 +18,44 @@ VSOutput vertexMain(VSInput input) struct Camera { - float3 camPos; float _pad0; - float3 camRight; float _pad1; - float3 camUp; float _pad2; - float3 camForward; float _pad3; - float tanHalfFov; - float aspect; - bool moving; - int outputChannel; // 0 = colour; 1 = redshift g; 2 = emission T; 3 = impact parameter - int rawOutput; // 0 = display (false-colour / tone-mapped); 1 = raw float value + float3 u_cam_pos; float _pad0; + float3 u_cam_right; float _pad1; + float3 u_cam_up; float _pad2; + float3 u_cam_forward; float _pad3; + float u_tan_half_fov; + float u_aspect; + bool u_moving; + int u_output_channel; // 0 = colour; 1 = redshift g; 2 = emission T; 3 = impact parameter + int u_raw_output; // 0 = display (false-colour / tone-mapped); 1 = raw float value }; - -// A jet-ish false-colour ramp (blue -> cyan -> green -> yellow -> red) for the -// observable export channels. t is expected in [0, 1]. -float3 Falsecolor(float t) -{ - t = clamp(t, 0.0, 1.0); - return clamp(float3(1.5 - abs(4.0 * t - 3.0), - 1.5 - abs(4.0 * t - 2.0), - 1.5 - abs(4.0 * t - 1.0)), 0.0, 1.0); -} ConstantBuffer<Camera> cam; struct Disk { - 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; // slab half-height (anti-aliases the edge-on disk) - float disk_density; // disk brightness / exposure - float temperature; // Kelvin at the flux peak (disk colour) + float u_inner_radius; // inner edge (clamped to the ISCO, 3 r_s, below) + float u_outer_radius; // outer edge + float u_turbulence; // turbulence strength (0 = smooth physical disk) + float u_thickness; // slab half-height (anti-aliases the edge-on disk) + float u_brightness; // disk brightness / exposure + float u_temperature; // Kelvin at the flux peak (disk colour) }; ConstantBuffer<Disk> disk; struct Objects { - int numObjects; - float4 objPosRadius[16]; - float4 objColor[16]; - float mass[16]; + int u_num_objects; + float4 u_obj_pos_radius[16]; + float4 u_obj_color[16]; + float u_mass[16]; }; ConstantBuffer<Objects> obj; struct Simulation { - int maxStepsMoving; - int maxStepsStatic; - float earlyExitDistance; - float time; + int u_max_steps_moving; + int u_max_steps_static; + float u_early_exit_distance; + float u_time; }; ConstantBuffer<Simulation> sim; @@ -163,8 +159,8 @@ float3 DiskEmission(float3 P, float3 rayDir, out float outG, out float outTemit) { outG = 0.0; outTemit = 0.0; 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; + float rin = max(disk.u_inner_radius, R_ISCO); + float rout = disk.u_outer_radius; if (rc < rin || rc > rout) return float3(0.0); @@ -173,7 +169,7 @@ float3 DiskEmission(float3 P, float3 rayDir, out float outG, out float outTemit) 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 = disk.temperature * Tn; + float Temit = disk.u_temperature * Tn; // Keplerian orbit (prograde about +Y). Locally-measured orbital speed for a // Schwarzschild circular geodesic: v = sqrt( M / (r - 2M) ) = 0.5 c at ISCO. @@ -198,18 +194,18 @@ float3 DiskEmission(float3 P, float3 rayDir, out float outG, out float outTemit) float edge = smoothstep(rin, rin * 1.12, rc) * (1.0 - smoothstep(rout * 0.88, rout, rc)); bright *= edge; - // Optional turbulence overlay (disk.disk_num = strength; 0 = smooth). - if (disk.disk_num > 0.0) + // Optional turbulence overlay (disk.u_turbulence = strength; 0 = smooth). + if (disk.u_turbulence > 0.0) { - float ang = sim.time * 0.3 / sqrt(xr); + float ang = sim.u_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); + float turb = 1.0 + disk.u_turbulence * (fbm(rp * 3.0, 3) - 0.5); bright *= max(turb, 0.0); } - return colour * bright * max(disk.disk_density, 0.0); + return colour * bright * max(disk.u_brightness, 0.0); } struct Ray @@ -255,15 +251,15 @@ 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) + for (int i = 0; i < obj.u_num_objects; ++i) { - float3 center = obj.objPosRadius[i].xyz; - float radius = obj.objPosRadius[i].w; + float3 center = obj.u_obj_pos_radius[i].xyz; + float radius = obj.u_obj_pos_radius[i].w; float distSq = dot(P - center, P - center); if (distSq > radius * radius * 4.0) continue; if (distSq <= radius * radius) { - hit.objectColor = obj.objColor[i]; + hit.objectColor = obj.u_obj_color[i]; hit.hitCenter = center; hit.hitRadius = radius; return true; @@ -317,8 +313,8 @@ float CalculateAdaptiveStepSize(Ray ray, float baseStepSize) // Slow down when near the disk plane (within its radial extent) so the thin // slab is never stepped over -- otherwise grazing rays leak through it. float rc = length(float2(ray.x, ray.z)); - if (rc < disk.disk_r2 * 3.0 && abs(ray.y) < disk.thickness * 8.0) - step = min(step, disk.thickness); + if (rc < disk.u_outer_radius * 3.0 && abs(ray.y) < disk.u_thickness * 8.0) + step = min(step, disk.u_thickness); return clamp(step, MIN_STEP_SIZE, MAX_STEP_SIZE); } @@ -328,15 +324,25 @@ float3 ACESFilm(float3 x) return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); } +// A jet-ish false-colour ramp (blue -> cyan -> green -> yellow -> red) for the +// observable export channels. t is expected in [0, 1]. +float3 Falsecolor(float t) +{ + t = clamp(t, 0.0, 1.0); + return clamp(float3(1.5 - abs(4.0 * t - 3.0), + 1.5 - abs(4.0 * t - 2.0), + 1.5 - abs(4.0 * t - 1.0)), 0.0, 1.0); +} + // Trace one primary ray for the given image UV and return its linear, // pre-tone-map radiance. Called once per sub-sample by fragmentMain. float3 TracePixel(float2 texCoord, out float outG, out float outTemit, out bool outHitDisk) { outG = 0.0; outTemit = 0.0; outHitDisk = false; - float u = (2.0 * texCoord.x - 1.0) * cam.aspect * cam.tanHalfFov; - float v = (1.0 - 2.0 * texCoord.y) * cam.tanHalfFov; - float3 dir = normalize(u * cam.camRight - v * cam.camUp + cam.camForward); - Ray ray = InitRay(cam.camPos, dir); + float u = (2.0 * texCoord.x - 1.0) * cam.u_aspect * cam.u_tan_half_fov; + float v = (1.0 - 2.0 * texCoord.y) * cam.u_tan_half_fov; + float3 dir = normalize(u * cam.u_cam_right - v * cam.u_cam_up + cam.u_cam_forward); + Ray ray = InitRay(cam.u_cam_pos, dir); bool hitBlackHole = false; bool hitObject = false; @@ -348,11 +354,11 @@ float3 TracePixel(float2 texCoord, out float outG, out float outTemit, out bool bool hitDisk = false; float3 diskColor = float3(0.0); // emission of the first (opaque) disk surface hit - int maxSteps = cam.moving ? sim.maxStepsMoving : sim.maxStepsStatic; + int maxSteps = cam.u_moving ? sim.u_max_steps_moving : sim.u_max_steps_static; if (maxSteps <= 0) - maxSteps = cam.moving ? DEFAULT_MAX_STEPS_MOVING : DEFAULT_MAX_STEPS_STATIC; + maxSteps = cam.u_moving ? DEFAULT_MAX_STEPS_MOVING : DEFAULT_MAX_STEPS_STATIC; - float exitDistance = sim.earlyExitDistance > 0.0 ? sim.earlyExitDistance : DEFAULT_EARLY_EXIT_DISTANCE; + float exitDistance = sim.u_early_exit_distance > 0.0 ? sim.u_early_exit_distance : DEFAULT_EARLY_EXIT_DISTANCE; int objectCheckInterval = 5; for (int i = 0; i < maxSteps; ++i) @@ -370,7 +376,7 @@ float3 TracePixel(float2 texCoord, out float outG, out float outTemit, out bool // while grazing along it. Real (nonzero) thickness stops the zero-height // edge-on "razor" from aliasing into a beam streaking across the frame. { - float H = disk.thickness; + float H = disk.u_thickness; bool crossed = prevPos.y * newPos.y < 0.0; bool inSlab = abs(newPos.y) <= H; if (crossed || inSlab) @@ -379,7 +385,7 @@ float3 TracePixel(float2 texCoord, out float outG, out float outTemit, out bool ? lerp(prevPos, newPos, prevPos.y / (prevPos.y - newPos.y)) : newPos; float rc = length(float2(hitP.x, hitP.z)); - if (rc >= max(disk.disk_r1, R_ISCO) && rc <= disk.disk_r2) + if (rc >= max(disk.u_inner_radius, R_ISCO) && rc <= disk.u_outer_radius) { diskColor = DiskEmission(hitP, newPos - prevPos, outG, outTemit); hitDisk = true; @@ -400,7 +406,7 @@ float3 TracePixel(float2 texCoord, out float outG, out float outTemit, out bool // Computed UNCONDITIONALLY (before the branch) so ddx/ddy are valid; strongly // lensed background rays diverge fast, so they read a blurred cubemap mip and // the starfield stops aliasing into a fan along the equatorial plane. - float3 rayDir = normalize(float3(ray.x, ray.y, ray.z) - cam.camPos); + float3 rayDir = normalize(float3(ray.x, ray.y, ray.z) - cam.u_cam_pos); float footprint = max(length(ddx(rayDir)), length(ddy(rayDir))); float envLod = clamp(log2(max(footprint / 0.0015, 1.0)), 0.0, 10.0); @@ -417,7 +423,7 @@ float3 TracePixel(float2 texCoord, out float outG, out float outTemit, out bool { float3 P = float3(ray.x, ray.y, ray.z); float3 N = normalize(P - hit.hitCenter); - float3 V = normalize(cam.camPos - P); + float3 V = normalize(cam.u_cam_pos - P); float intensity = 0.1 + 0.9 * max(dot(N, V), 0.0); shade = hit.objectColor.rgb * intensity; } @@ -438,41 +444,41 @@ float4 fragmentMain(VSOutput input) : SV_Target // ordered grid). Radiance is averaged before tone-mapping; ddx/ddy give the // resolution-correct per-pixel UV footprint. // Observable export channels: the chosen scalar quantity, either as a raw - // float (rawOutput: value in RGB, validity mask in A) or false-coloured. + // float (u_raw_output: value in RGB, validity mask in A) or false-coloured. // Channel 0 is the normal colour image. - if (cam.outputChannel != 0) + if (cam.u_output_channel != 0) { float g, Temit; bool hitDisk; TracePixel(input.texCoord, g, Temit, hitDisk); float value; float valid = 1.0; - if (cam.outputChannel == 3) // impact parameter: a per-ray geometric quantity (r_s) + if (cam.u_output_channel == 3) // impact parameter: a per-ray geometric quantity (r_s) { - float uu = (2.0 * input.texCoord.x - 1.0) * cam.aspect * cam.tanHalfFov; - float vv = (1.0 - 2.0 * input.texCoord.y) * cam.tanHalfFov; - float3 dir = normalize(uu * cam.camRight - vv * cam.camUp + cam.camForward); - value = length(cross(cam.camPos, dir)) / SagA_rs; + float uu = (2.0 * input.texCoord.x - 1.0) * cam.u_aspect * cam.u_tan_half_fov; + float vv = (1.0 - 2.0 * input.texCoord.y) * cam.u_tan_half_fov; + float3 dir = normalize(uu * cam.u_cam_right - vv * cam.u_cam_up + cam.u_cam_forward); + value = length(cross(cam.u_cam_pos, dir)) / SagA_rs; } else // g / T are disk-only { valid = hitDisk ? 1.0 : 0.0; - value = (cam.outputChannel == 1) ? g : Temit; + value = (cam.u_output_channel == 1) ? g : Temit; } - if (cam.rawOutput != 0) + if (cam.u_raw_output != 0) return float4(value, value, value, valid); // raw: physical value + validity if (valid < 0.5) return float4(0.0, 0.0, 0.0, 1.0); - float norm = (cam.outputChannel == 1) ? value / 1.5 - : (cam.outputChannel == 2) ? value / 15000.0 + float norm = (cam.u_output_channel == 1) ? value / 1.5 + : (cam.u_output_channel == 2) ? value / 15000.0 : value / 30.0; return float4(Falsecolor(norm), 1.0); } float _g, _t; bool _hd; - if (cam.rawOutput != 0) // raw colour: linear pre-tone-map radiance (HDR) + if (cam.u_raw_output != 0) // raw colour: linear pre-tone-map radiance (HDR) return float4(TracePixel(input.texCoord, _g, _t, _hd), 1.0); - if (cam.moving) + if (cam.u_moving) return float4(ACESFilm(TracePixel(input.texCoord, _g, _t, _hd)), 1.0); float2 dUV = float2(ddx(input.texCoord.x), ddy(input.texCoord.y)); diff --git a/assets/shaders/grid.slang b/assets/shaders/grid.slang index f549cb1..8dc4362 100644 --- a/assets/shaders/grid.slang +++ b/assets/shaders/grid.slang @@ -1,15 +1,15 @@ // Reference grid on the XZ plane. Uniforms live in a ConstantBuffer (UBO) so both // backends drive it through the same RHI path (bind a uniform buffer at binding 0). -struct GridU +struct Grid { - float4x4 u_ViewProjection; - float4x4 u_Transform; - float u_GridSize; - float3 u_GridColor; - float u_GridAlpha; - float3 u_CameraPos; + float4x4 u_view_projection; + float4x4 u_transform; + float u_grid_size; + float3 u_grid_color; + float u_grid_alpha; + float3 u_camera_pos; }; -ConstantBuffer<GridU> gu; +ConstantBuffer<Grid> gu; struct VSInput { float3 position : POSITION; }; struct VSOutput { float4 position : SV_Position; }; @@ -18,19 +18,19 @@ struct VSOutput { float4 position : SV_Position; }; VSOutput vertexMain(VSInput input) { VSOutput output; - float3 scaledPosition = input.position * (gu.u_GridSize / 50.0); - output.position = mul(gu.u_ViewProjection, mul(gu.u_Transform, float4(scaledPosition, 1.0))); + float3 scaledPosition = input.position * (gu.u_grid_size / 50.0); + output.position = mul(gu.u_view_projection, mul(gu.u_transform, float4(scaledPosition, 1.0))); return output; } [shader("fragment")] float4 fragmentMain(VSOutput input) : SV_Target { - float dist = length(gu.u_CameraPos); + float dist = length(gu.u_camera_pos); float fadeFactor = 1.0 - clamp(dist / 100.0, 0.0, 0.8); - float3 color = gu.u_GridColor; - float alpha = gu.u_GridAlpha * fadeFactor; + float3 color = gu.u_grid_color; + float alpha = gu.u_grid_alpha * fadeFactor; float gridFade = 1.0 - clamp(dist / 50.0, 0.0, 0.9); alpha *= gridFade; diff --git a/assets/shaders/skybox.slang b/assets/shaders/skybox.slang index 6447bea..f7aeb50 100644 --- a/assets/shaders/skybox.slang +++ b/assets/shaders/skybox.slang @@ -1,11 +1,11 @@ -// Fullscreen skybox cube. View/projection in a ConstantBuffer (UBO, binding 0) +// Full-screen skybox cube. View/projection in a ConstantBuffer (UBO, binding 0) // so both backends drive it through the RHI; the cubemap is a sampler at binding 1. -struct SkyboxU +struct Skybox { - float4x4 u_Projection; - float4x4 u_View; + float4x4 u_projection; + float4x4 u_view; }; -ConstantBuffer<SkyboxU> sb; +ConstantBuffer<Skybox> sb; struct VSInput { float3 position : POSITION; }; struct VSOutput { float4 position : SV_Position; float3 texCoords : TEXCOORD0; }; @@ -15,7 +15,7 @@ VSOutput vertexMain(VSInput input) { VSOutput output; output.texCoords = input.position; - float4 pos = mul(sb.u_Projection, mul(sb.u_View, float4(input.position, 1.0))); + float4 pos = mul(sb.u_projection, mul(sb.u_view, float4(input.position, 1.0))); output.position = pos.xyww; // force depth = 1 so the skybox stays behind everything return output; } diff --git a/assets/shaders/sphere.slang b/assets/shaders/sphere.slang index e37700d..41badde 100644 --- a/assets/shaders/sphere.slang +++ b/assets/shaders/sphere.slang @@ -1,42 +1,32 @@ // Lit sphere (Blinn-Phong + HDRI ambient + selection rim). Uniforms in a // ConstantBuffer (UBO, binding 0) so both backends drive it through the RHI; // the environment cubemap is a separate sampler at binding 1. -struct SphereU +struct Sphere { - float4x4 u_ViewProjection; - float4x4 u_Transform; - float3 u_Color; - float u_Specular; - float u_Emission; - float3 u_LightPos; - float3 u_CameraPos; - int u_IsSelected; - float3 u_OutlineColor; - float u_OutlineWidth; + float4x4 u_view_projection; + float4x4 u_transform; + float3 u_color; + float u_specular; + float u_emission; + float3 u_light_pos; + float3 u_camera_pos; + int u_is_selected; + float3 u_outline_color; + float u_outline_width; }; -ConstantBuffer<SphereU> su; +ConstantBuffer<Sphere> su; -struct VSInput -{ - float3 position : POSITION; - float3 normal : NORMAL; -}; - -struct VSOutput -{ - float4 position : SV_Position; - float3 normal : TEXCOORD0; - float3 worldPos : TEXCOORD1; -}; +struct VSInput { float3 position : POSITION; float3 normal : NORMAL; }; +struct VSOutput { float4 position : SV_Position; float3 normal : TEXCOORD0; float3 worldPos : TEXCOORD1; }; [shader("vertex")] VSOutput vertexMain(VSInput input) { VSOutput output; - output.worldPos = mul(su.u_Transform, float4(input.position, 1.0)).xyz; - float3x3 normalMatrix = (float3x3)su.u_Transform; + output.worldPos = mul(su.u_transform, float4(input.position, 1.0)).xyz; + float3x3 normalMatrix = (float3x3)su.u_transform; output.normal = mul(normalMatrix, input.normal); - output.position = mul(su.u_ViewProjection, float4(output.worldPos, 1.0)); + output.position = mul(su.u_view_projection, float4(output.worldPos, 1.0)); return output; } @@ -46,8 +36,8 @@ SamplerCube u_HDRIEnvironment; float4 fragmentMain(VSOutput input) : SV_Target { float3 normal = normalize(input.normal); - float3 lightDir = normalize(su.u_LightPos - input.worldPos); - float3 viewDir = normalize(su.u_CameraPos - input.worldPos); + float3 lightDir = normalize(su.u_light_pos - input.worldPos); + float3 viewDir = normalize(su.u_camera_pos - input.worldPos); float3 reflectDir = reflect(-lightDir, normal); float3 hdriLight = u_HDRIEnvironment.Sample(normal).rgb; @@ -56,18 +46,18 @@ float4 fragmentMain(VSOutput input) : SV_Target float diff = max(dot(normal, lightDir), 0.0); float spec = pow(max(dot(viewDir, reflectDir), 0.0), 32.0); - float3 diffuse = su.u_Color * (diff + hdriIntensity * hdriLight); - float3 specular = float3(su.u_Specular, su.u_Specular, su.u_Specular) * spec; - float3 emission = su.u_Color * su.u_Emission; + float3 diffuse = su.u_color * (diff + hdriIntensity * hdriLight); + float3 specular = float3(su.u_specular, su.u_specular, su.u_specular) * spec; + float3 emission = su.u_color * su.u_emission; float3 result = diffuse + specular + emission; - if (su.u_IsSelected > 0) + if (su.u_is_selected > 0) { float ndotv = max(dot(normal, viewDir), 0.0); float rim = 1.0 - ndotv; - float outlineMask = step(1.0 - su.u_OutlineWidth, rim); - result = lerp(result, su.u_OutlineColor, outlineMask); + float outlineMask = step(1.0 - su.u_outline_width, rim); + result = lerp(result, su.u_outline_color, outlineMask); } return float4(result, 1.0); diff --git a/assets/shaders/textured_quad.slang b/assets/shaders/textured_quad.slang index 52d46ed..b72db81 100644 --- a/assets/shaders/textured_quad.slang +++ b/assets/shaders/textured_quad.slang @@ -1,14 +1,8 @@ -struct VSInput -{ - float2 position : POSITION; - float2 texCoord : TEXCOORD0; -}; +// Full-screen textured quad: samples one 2D texture straight to the target. +// Used to blit an off-screen colour buffer to the swapchain. -struct VSOutput -{ - float4 position : SV_Position; - float2 texCoord : TEXCOORD0; -}; +struct VSInput { float2 position : POSITION; float2 texCoord : TEXCOORD0; }; +struct VSOutput { float4 position : SV_Position; float2 texCoord : TEXCOORD0; }; [shader("vertex")] VSOutput vertexMain(VSInput input) diff --git a/assets/shaders/vk_pipeline_test.slang b/assets/shaders/vk_pipeline_test.slang index f4d1897..453fcd3 100644 --- a/assets/shaders/vk_pipeline_test.slang +++ b/assets/shaders/vk_pipeline_test.slang @@ -1,12 +1,8 @@ // Minimal shader used only to verify the Vulkan graphics-pipeline path (SPIR-V -// module -> pipeline -> draw -> read-back). Draws a full-screen gradient -// triangle from the vertex id, so it needs no vertex buffer, UBO, or sampler. +// module -> pipeline -> draw -> read-back). Draws a full-screen gradient triangle +// from the vertex id, so it needs no vertex buffer, UBO, or sampler. -struct VSOutput -{ - float4 position : SV_Position; - float3 color : COLOR0; -}; +struct VSOutput { float4 position : SV_Position; float3 color : COLOR0; }; [shader("vertex")] VSOutput vertexMain(uint vid : SV_VertexID) diff --git a/branding/logo_white.jpg b/branding/logo_white.jpg Binary files differindex 7981c4b..417a96d 100644 --- a/branding/logo_white.jpg +++ b/branding/logo_white.jpg diff --git a/docs/README.md b/docs/README.md index fa5d6ed..57afb1a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,7 +16,7 @@ go into how it actually works. layering (Application / Scene / RenderPath / UILayer), the portable RHI that lets the same rendering run on OpenGL and Vulkan, the frame loop, the unified scene-and-simulation world, the rendering pipeline (progressive resolution, - supersampling, environment lighting, tone-mapping), the tabs, the export + supersampling, environment lighting, tone-mapping), the docking UI, the export pipeline, and the build system. For each pixel, Donut casts a ray from the camera and follows it backward through diff --git a/docs/architecture.md b/docs/architecture.md index 5762dfd..c244586 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ and the interface can change independently. flowchart TD App["Application<br/>thin shell + main loop"] App --> Scene["Scene<br/>the document/world:<br/>objects, black hole, cameras"] - App --> UI["UILayer / Workspace<br/>the tabbed ImGui interface"] + App --> UI["UILayer<br/>the docking UI shell"] App --> RP["RenderPath<br/>device-side rendering"] App --> Dev["RHI::Device<br/>the GPU, abstracted"] @@ -48,7 +48,7 @@ flowchart TD | --- | --- | --- | | `Application` | [`src/core/application.cpp`](../src/core/application.cpp) | Owns everything; runs the main loop; handles input, resize, vsync, fullscreen; exposes actions to the UI | | `Scene` | [`src/scene/scene.h`](../src/scene/scene.h) | The world as plain data — placed objects, black-hole/disk parameters, the editor and simulation cameras, the HDRI path | -| `UILayer` / `Workspace` | [`src/ui/ui_layer.cpp`](../src/ui/ui_layer.cpp) | The tabbed interface; returns which view is live and drives the scene through `AppActions` | +| `UILayer` | [`src/ui/ui_layer.cpp`](../src/ui/ui_layer.cpp) | The docking shell: menu bar, dockable panels, default layouts; returns which view is live and drives the scene through `AppActions` | | `RenderPath` | [`src/rendering/render_path.cpp`](../src/rendering/render_path.cpp) | Turns the scene into pixels on whatever device is active; owns the two renderers and the environment cubemap | | `RHI::Device` | [`src/rendering/rhi.h`](../src/rendering/rhi.h) | The portable GPU interface every backend implements | @@ -145,13 +145,13 @@ path described below. world editor: a conventional rasteriser that draws the placed spheres, the ground grid, the selection outline and gizmo, and a near-black black-hole marker at the origin, sized to the horizon and ringed with an amber accretion-glow outline so it -reads against the dark background. This is what you manipulate on the Scene tab. +reads against the dark background. This is what you manipulate in the Scene view. `BlackHoleRenderer` ([`black_hole_renderer.cpp`](../src/rendering/black_hole_renderer.cpp)) is the geodesic ray tracer. It runs the physics shader from [`physics.md`](physics.md) as a full-screen fragment pass into an off-screen target, then presents that target -to the screen. This is the expensive work, and it runs only on the Simulation tab -and during export. +to the screen. This is the expensive work, and it runs only when the centre view is +Simulation, and during export. `RenderPath::render` routes to the right one based on the `View`: @@ -172,8 +172,8 @@ that radius, and `BlackHoleRenderer` takes every placed `SceneObject`, multiplie its position and radius by $r_s/3$ (the code's `SagA_rs / 3`) to reach physical metres, and uploads them into the shader's `Objects` uniform (up to 16 spheres). -So a sphere placed on the Scene tab shows up in the same spot on the Simulation -tab, except now the curved rays bend around the hole and lens it, and it can appear +So a sphere placed in the Scene view shows up in the same spot in the Simulation +view, except now the curved rays bend around the hole and lens it, and it can appear stretched, doubled, or smeared into an arc. The spheres are passive lit objects, planets and the like; they don't exert their own gravity, only the black hole bends light. @@ -219,20 +219,42 @@ fragment shader averages four sub-pixel samples in a rotated-grid ("4-rook") pat before tone-mapping, which cleans up the near-horizontal lensed edges and the thin photon ring. -## The workspace: tabs +## The workspace: docking and panels -The interface follows Dorico's mode tabs: separate workspaces, one active at a -time, each returning a `View` so the renderer knows what to draw. +The interface is a docking shell. A main menu bar sits above a full-viewport dock +space with a pass-through centre, so the live 3D render shows through the middle +while dockable tool panels attach to the edges — drag, tab, hide, or restore them +like a normal desktop app. The layout persists in `imgui.ini` between runs. -| Tab | View | For | +The panels are separate windows, each toggled from the **Window** menu: + +| Panel | For | +| --- | --- | +| Outliner | The sphere list: add, delete, select | +| Properties | The selected sphere's transform and colour, plus the viewport gizmo | +| Black Hole | Camera mode/FOV, the accretion disk, and integration quality | +| Settings | Display & renderer: vsync, frame cap, resolution, fullscreen, UI scale, API, overlay, HDRI | +| Export | Choose which observable channels, at what resolution and format, then render to disk | +| Stats | Device, backend, FPS and frame time | + +Two menus drive the rest. **View** picks what the centre viewport shows — `Scene` +(the world editor) or `Simulation` (the lensed black hole) — which is the `View` +`UILayer::draw` returns to the `RenderPath`. **Layout** applies one of the default +arrangements, each built programmatically with ImGui's `DockBuilder` API and paired +with a sensible view and panel set: + +| Layout | View | Panels shown | | --- | --- | --- | -| Setup | `None` | Display & quality: vsync, target FPS, resolution, fullscreen, UI scale, HDRI selection, integration quality, early-exit distance | -| Scene | `Scene` | The world builder: place, select and transform objects with a gizmo; orbit the editor camera around the black-hole marker | -| Simulation | `BlackHole` | The live lensed view — the only place the geodesic tracer runs; tune the black hole and disk; orbit or fly (FPS) the camera | -| Export | `None` | Choose which observable channels, at what resolution and format, then render them to disk | +| Simulation (default) | `Simulation` | Black Hole, Stats | +| Scene editing | `Scene` | Outliner, Properties, Stats | +| Export | `Simulation` | Export, Black Hole, Stats | -Tabs whose view is `None` have no live 3-D viewport, so `Application::update_input` -skips camera handling for them. +On first launch (no saved `imgui.ini`) the Simulation layout is built by +`DockBuilder`; after that the user's arrangement is restored, and *Layout → Reset* +rebuilds the current preset. Only panels are docked windows — the centre stays a +pass-through hole onto the full-frame 3D render, so `Application::update_input` +drives the camera whenever the cursor is over that centre (ImGui reports it doesn't +want the mouse) and yields to the panels otherwise. ## The export pipeline @@ -283,7 +305,7 @@ there is no separate "OpenGL build" and "Vulkan build". Generate and build (arm64 macOS): ```bash -premake5 gmake && make config=debug-macosx +premake5 gmake && make config=debug ``` `premake5 clean` is wired up as a custom action that removes the generated build diff --git a/docs/physics.md b/docs/physics.md index d484cb0..98807e1 100644 --- a/docs/physics.md +++ b/docs/physics.md @@ -6,8 +6,8 @@ of that trace, in roughly the order the shader applies it, with references to th code in [`assets/shaders/geodesic.slang`](../assets/shaders/geodesic.slang) — symbol names below (`InitRay`, `GeodesicRHS`, `DiskEmission`) all live in that file. -For the software side — how the shader gets fed, the render backends, the tabs -and the export path — see [`architecture.md`](architecture.md). +For the software side — how the shader gets fed, the render backends, the docking +UI and the export path — see [`architecture.md`](architecture.md). ## Contents @@ -366,4 +366,4 @@ rendered off-screen and written to PFM or CSV. --- See also [`architecture.md`](architecture.md) for how the renderer is built, from -the portable GPU layer up through the tabs and the export pipeline. +the portable GPU layer up through the panels and the export pipeline. diff --git a/src/core/settings_manager.cpp b/src/core/settings_manager.cpp index 6909c60..eb7f800 100644 --- a/src/core/settings_manager.cpp +++ b/src/core/settings_manager.cpp @@ -53,8 +53,6 @@ namespace Donut s_settings.simulation.disk_thickness = toml::find_or(sim, "disk_thickness", 0.1f); s_settings.simulation.disk_density = toml::find_or(sim, "disk_density", 0.1f); s_settings.simulation.rotation_speed = toml::find_or(sim, "rotation_speed", 1.0f); - s_settings.simulation.blur_strength = toml::find_or(sim, "blur_strength", 2.0f); - s_settings.simulation.glow_intensity = toml::find_or(sim, "glow_intensity", 0.1f); s_settings.simulation.target_fps = std::max(30, std::min(120, s_settings.simulation.target_fps)); s_settings.simulation.compute_height = std::max(64, std::min(2048, s_settings.simulation.compute_height)); @@ -64,8 +62,6 @@ namespace Donut s_settings.simulation.disk_thickness = std::max(0.01f, std::min(5.0f, s_settings.simulation.disk_thickness)); s_settings.simulation.disk_density = std::max(0.01f, std::min(5.0f, s_settings.simulation.disk_density)); s_settings.simulation.rotation_speed = std::max(0.0f, std::min(5.0f, s_settings.simulation.rotation_speed)); - s_settings.simulation.blur_strength = std::max(0.1f, std::min(10.0f, s_settings.simulation.blur_strength)); - s_settings.simulation.glow_intensity = std::max(0.01f, std::min(5.0f, s_settings.simulation.glow_intensity)); } if (config.contains("graphics")) @@ -123,9 +119,7 @@ namespace Donut {"gravity_enabled", s_settings.simulation.gravity_enabled }, {"disk_thickness", s_settings.simulation.disk_thickness }, {"disk_density", s_settings.simulation.disk_density }, - {"rotation_speed", s_settings.simulation.rotation_speed }, - {"blur_strength", s_settings.simulation.blur_strength }, - {"glow_intensity", s_settings.simulation.glow_intensity } + {"rotation_speed", s_settings.simulation.rotation_speed } }; toml::value graphics = toml::table @@ -185,8 +179,6 @@ namespace Donut s_settings.simulation.disk_thickness = 0.1f; s_settings.simulation.disk_density = 0.1f; s_settings.simulation.rotation_speed = 1.0f; - s_settings.simulation.blur_strength = 2.0f; - s_settings.simulation.glow_intensity = 0.1f; s_settings.graphics.render_api = "OpenGL"; s_settings.graphics.v_sync_enabled = true; diff --git a/src/core/settings_manager.h b/src/core/settings_manager.h index 95826c7..e28e0e1 100644 --- a/src/core/settings_manager.h +++ b/src/core/settings_manager.h @@ -16,8 +16,6 @@ namespace Donut float disk_thickness = 0.1f; float disk_density = 0.1f; float rotation_speed = 1.0f; - float blur_strength = 2.0f; - float glow_intensity = 0.1f; }; struct GraphicsSettings @@ -61,8 +59,6 @@ namespace Donut static auto get_disk_thickness() -> float { return s_settings.simulation.disk_thickness; } static auto get_disk_density() -> float { return s_settings.simulation.disk_density; } static auto get_rotation_speed() -> float { return s_settings.simulation.rotation_speed; } - static auto get_blur_strength() -> float { return s_settings.simulation.blur_strength; } - static auto get_glow_intensity() -> float { return s_settings.simulation.glow_intensity; } static auto get_render_api() -> std::string { return s_settings.graphics.render_api; } static auto get_v_sync_enabled() -> bool { return s_settings.graphics.v_sync_enabled; } static auto get_show_fps() -> bool { return s_settings.graphics.show_fps; } diff --git a/src/platform/opengl/opengl_device.cpp b/src/platform/opengl/opengl_device.cpp index 7ef0ba1..614e515 100644 --- a/src/platform/opengl/opengl_device.cpp +++ b/src/platform/opengl/opengl_device.cpp @@ -120,11 +120,17 @@ namespace Donut::RHI { GLuint bi = glGetUniformBlockIndex(m_prog, r.name.c_str()); if (bi != GL_INVALID_INDEX) glUniformBlockBinding(m_prog, bi, r.binding); + // Not found: the ConstantBuffer<> type name in the shader no longer + // matches this resource string, so the UBO silently won't bind. + else DONUT_WARN("GL RHI: shader '{}' has no uniform block '{}' (binding {})", + d.shader, r.name, r.binding); } else // Texture: point the sampler at texture unit == binding { GLint loc = glGetUniformLocation(m_prog, r.name.c_str()); if (loc >= 0) glUniform1i(loc, (GLint)r.binding); + else DONUT_WARN("GL RHI: shader '{}' has no sampler '{}' (unit {})", + d.shader, r.name, r.binding); } } glUseProgram(0); diff --git a/src/rendering/scene_renderer.cpp b/src/rendering/scene_renderer.cpp index 50b666d..bec609c 100644 --- a/src/rendering/scene_renderer.cpp +++ b/src/rendering/scene_renderer.cpp @@ -70,7 +70,7 @@ namespace Donut PipelineDesc d; d.shader = "grid"; d.vertex_layout = { sizeof(glm::vec3), { { 0, 3, 0 } } }; - d.resources = { { ResourceKind::UniformBuffer, 0, "GridU" } }; + d.resources = { { ResourceKind::UniformBuffer, 0, "Grid" } }; d.topology = Topology::Lines; d.blend = BlendMode::AlphaBlend; d.depth_test = true; d.depth_write = false; d.depth_op = CompareOp::LessEqual; @@ -107,7 +107,7 @@ namespace Donut PipelineDesc d; d.shader = "sphere"; d.vertex_layout = { 6 * sizeof(float), { { 0, 3, 0 }, { 1, 3, 3 * sizeof(float) } } }; - d.resources = { { ResourceKind::UniformBuffer, 0, "SphereU" }, + d.resources = { { ResourceKind::UniformBuffer, 0, "Sphere" }, { ResourceKind::Texture, 1, "u_HDRIEnvironment" } }; d.topology = Topology::Triangles; d.depth_test = true; d.depth_write = true; d.depth_op = CompareOp::Less; @@ -132,7 +132,7 @@ namespace Donut PipelineDesc d; d.shader = "skybox"; d.vertex_layout = { 3 * sizeof(float), { { 0, 3, 0 } } }; - d.resources = { { ResourceKind::UniformBuffer, 0, "SkyboxU" }, + d.resources = { { ResourceKind::UniformBuffer, 0, "Skybox" }, { ResourceKind::Texture, 1, "u_Skybox" } }; d.topology = Topology::Triangles; d.depth_test = false; d.depth_write = false; diff --git a/src/ui/ui_layer.cpp b/src/ui/ui_layer.cpp index f1ec4d2..88e1b1d 100644 --- a/src/ui/ui_layer.cpp +++ b/src/ui/ui_layer.cpp @@ -6,16 +6,25 @@ #include "rendering/render_api.h" #include <imgui.h> +#include <imgui_internal.h> // DockBuilder* #include <ImGuizmo.h> #include <glm/glm.hpp> #include <glm/gtc/matrix_transform.hpp> #include <algorithm> -#include <cstdlib> #include <cstdio> #include <string> namespace Donut { + // Panel window titles. These are the identity DockBuilder docks against, so + // they must match the ImGui::Begin() titles exactly. + static const char* kOutliner = "Outliner"; + static const char* kProperties = "Properties"; + static const char* kBlackHole = "Black Hole"; + static const char* kSettings = "Settings"; + static const char* kExport = "Export"; + static const char* kStats = "Stats"; + // One-time ImGui theme: rounded, roomy, dark with a warm accretion-disk accent. static auto apply_donut_style() -> void { @@ -32,6 +41,7 @@ namespace Donut c[ImGuiCol_WindowBg] = ImVec4(0.07f, 0.08f, 0.10f, 0.97f); c[ImGuiCol_ChildBg] = ImVec4(0.10f, 0.11f, 0.13f, 0.55f); c[ImGuiCol_PopupBg] = ImVec4(0.09f, 0.10f, 0.12f, 0.98f); + c[ImGuiCol_MenuBarBg] = ImVec4(0.09f, 0.10f, 0.12f, 1.00f); c[ImGuiCol_TitleBg] = ImVec4(0.09f, 0.10f, 0.12f, 1.00f); c[ImGuiCol_TitleBgActive] = ImVec4(0.13f, 0.14f, 0.17f, 1.00f); c[ImGuiCol_Text] = ImVec4(0.90f, 0.91f, 0.93f, 1.00f); @@ -53,163 +63,260 @@ namespace Donut c[ImGuiCol_TabActive] = ImVec4(0.22f, 0.24f, 0.28f, 1.00f); c[ImGuiCol_Separator] = ImVec4(0.20f, 0.22f, 0.26f, 1.00f); c[ImGuiCol_ScrollbarGrab] = ImVec4(0.24f, 0.26f, 0.30f, 1.00f); + c[ImGuiCol_DockingPreview] = ImVec4(0.98f, 0.62f, 0.20f, 0.55f); + c[ImGuiCol_DockingEmptyBg] = ImVec4(0.00f, 0.00f, 0.00f, 0.00f); // pass-through } - namespace + auto UILayer::draw(const UIContext& ctx) -> View { - // Setup: display / renderer / overlay / environment settings - class SetupWorkspace : public Workspace + static bool styled = false; + if (!styled) { apply_donut_style(); styled = true; } + + ImGuizmo::BeginFrame(); // the Properties panel draws the scene gizmo + + draw_menu_bar(ctx); + + // Full-viewport dock space with a pass-through centre: panels dock to the + // edges while the render shows through the middle. Sits under the menu bar. + // ImGui owns the id (stable per viewport) and persists the layout in imgui.ini. + const ImGuiID dockspace_id = ImGui::DockSpaceOverViewport( + 0, ImGui::GetMainViewport(), ImGuiDockNodeFlags_PassthruCentralNode); + + // On the first frame, seed a default layout unless imgui.ini restored one + // (a restored dock tree has splits/windows, so the root node isn't empty). + if (m_first_frame) { - public: - auto name() const -> const char* override { return "Setup"; } - auto view() const -> View override { return View::None; } - auto on_ui(const UIContext& ctx) -> void override - { - auto& s = SettingsManager::get_settings(); + m_first_frame = false; + ImGuiDockNode* node = ImGui::DockBuilderGetNode(dockspace_id); + if (node == nullptr || node->IsEmpty()) + m_rebuild_layout = true; + } + if (m_rebuild_layout) + { + build_layout(dockspace_id); + m_rebuild_layout = false; + } - if (ImGui::CollapsingHeader("Display", ImGuiTreeNodeFlags_DefaultOpen)) - { - const bool fs = ctx.actions.is_fullscreen(); - int cw = 0, ch = 0; ctx.actions.get_window_size(cw, ch); + if (m_show_outliner) panel_outliner(ctx); + if (m_show_properties) panel_properties(ctx); + if (m_show_black_hole) panel_black_hole(ctx); + if (m_show_settings) panel_settings(ctx); + if (m_show_export) panel_export(ctx); + if (m_show_stats) panel_stats(ctx); - // Resolution presets (windowed only). - struct Res { const char* label; int w, h; }; - static const Res kRes[] = { - { "1280 x 720", 1280, 720 }, { "1600 x 900", 1600, 900 }, - { "1920 x 1080", 1920, 1080 }, { "2560 x 1440", 2560, 1440 }, - }; - char cur_label[32]; std::snprintf(cur_label, sizeof(cur_label), "%d x %d", cw, ch); - ImGui::BeginDisabled(fs); - if (ImGui::BeginCombo("Resolution", cur_label)) - { - for (const auto& r : kRes) - if (ImGui::Selectable(r.label, cw == r.w && ch == r.h)) - ctx.actions.set_resolution(r.w, r.h); - ImGui::EndCombo(); - } - ImGui::EndDisabled(); + return m_mode == Mode::Scene ? View::Scene : View::BlackHole; + } - bool fs_v = fs; - if (ImGui::Checkbox("Fullscreen", &fs_v)) ctx.actions.set_fullscreen(fs_v); + auto UILayer::draw_menu_bar(const UIContext& ctx) -> void + { + if (!ImGui::BeginMainMenuBar()) return; - if (ImGui::Checkbox("V-Sync", &s.graphics.v_sync_enabled)) - { - ctx.actions.set_vsync(s.graphics.v_sync_enabled); - SettingsManager::save_settings(); - } + if (ImGui::BeginMenu("View")) + { + ImGui::TextDisabled("Centre viewport"); + ImGui::Separator(); + if (ImGui::MenuItem("Scene", nullptr, m_mode == Mode::Scene)) m_mode = Mode::Scene; + if (ImGui::MenuItem("Simulation", nullptr, m_mode == Mode::Simulation)) m_mode = Mode::Simulation; + ImGui::EndMenu(); + } - ImGui::BeginDisabled(s.graphics.v_sync_enabled); - if (ImGui::SliderInt("Frame cap", &s.simulation.target_fps, 30, 240, "%d FPS")) - SettingsManager::save_settings(); - ImGui::EndDisabled(); - if (s.graphics.v_sync_enabled) - ImGui::TextDisabled("Frame cap applies only with V-Sync off."); + if (ImGui::BeginMenu("Window")) + { + ImGui::MenuItem(kOutliner, nullptr, &m_show_outliner); + ImGui::MenuItem(kProperties, nullptr, &m_show_properties); + ImGui::MenuItem(kBlackHole, nullptr, &m_show_black_hole); + ImGui::MenuItem(kSettings, nullptr, &m_show_settings); + ImGui::MenuItem(kExport, nullptr, &m_show_export); + ImGui::MenuItem(kStats, nullptr, &m_show_stats); + ImGui::EndMenu(); + } - if (ImGui::SliderFloat("UI scale", &m_ui_scale, 0.8f, 1.6f, "%.2fx")) - ctx.actions.set_ui_scale(m_ui_scale); - } + if (ImGui::BeginMenu("Layout")) + { + ImGui::TextDisabled("Default layouts"); + ImGui::Separator(); + if (ImGui::MenuItem("Simulation", nullptr, m_layout == Layout::Simulation)) apply_layout(Layout::Simulation); + if (ImGui::MenuItem("Scene editing", nullptr, m_layout == Layout::SceneEditing)) apply_layout(Layout::SceneEditing); + if (ImGui::MenuItem("Export", nullptr, m_layout == Layout::Export)) apply_layout(Layout::Export); + ImGui::Separator(); + if (ImGui::MenuItem("Reset current layout")) m_rebuild_layout = true; + ImGui::EndMenu(); + } - if (ImGui::CollapsingHeader("Renderer", ImGuiTreeNodeFlags_DefaultOpen)) - { - const char* apis[] = { "OpenGL", "Vulkan" }; - int cur = (s.graphics.render_api == "Vulkan") ? 1 : 0; - if (ImGui::Combo("Graphics API", &cur, apis, 2)) - { - s.graphics.render_api = apis[cur]; - SettingsManager::save_settings(); - } - const char* running = (RendererAPI::get_api() == RendererAPI::API::Vulkan) ? "Vulkan" : "OpenGL"; - if (s.graphics.render_api != running) - ImGui::TextColored(ImVec4(0.98f, 0.62f, 0.20f, 1.0f), "Restart to apply (%s running)", running); - else - ImGui::TextDisabled("Active backend: %s (%s)", running, ctx.device_name.c_str()); - } + // Right-aligned mode + FPS readout. + char status[64]; + std::snprintf(status, sizeof(status), "%s %.0f FPS", + m_mode == Mode::Scene ? "Scene" : "Simulation", ImGui::GetIO().Framerate); + float tw = ImGui::CalcTextSize(status).x; + ImGui::SameLine(ImGui::GetWindowWidth() - tw - 16.0f); + ImGui::TextDisabled("%s", status); - if (ImGui::CollapsingHeader("Overlay", ImGuiTreeNodeFlags_DefaultOpen)) - { - bool dirty = false; - dirty |= ImGui::Checkbox("Show FPS", &s.graphics.show_fps); - dirty |= ImGui::Checkbox("Show frame time", &s.graphics.show_performance_metrics); - if (dirty) SettingsManager::save_settings(); - } + ImGui::EndMainMenuBar(); + } - if (ImGui::CollapsingHeader("Environment", ImGuiTreeNodeFlags_DefaultOpen)) - { - auto& hdri = HDRIManager::get(); - std::string preview = hdri.get_hdri_name(ctx.scene.hdri_path); - if (ImGui::BeginCombo("Starfield", preview.c_str())) - { - for (const auto& path : hdri.get_available_hdri()) - { - bool selected = (path == ctx.scene.hdri_path); - if (ImGui::Selectable(hdri.get_hdri_name(path).c_str(), selected)) - ctx.scene.hdri_path = path; // RenderPath rebuilds the cubemap next frame - if (selected) ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - ImGui::TextDisabled("Switching rebuilds the cubemap (brief pause)."); - } - } - private: - float m_ui_scale = 1.0f; - }; + // Rebuild the dock tree for the current layout. Windows named but not currently + // visible still get a dock home here, so toggling them on lands them sensibly. + auto UILayer::build_layout(unsigned int dockspace_id) -> void + { + ImGui::DockBuilderRemoveNode(dockspace_id); + // DockSpace is a private flag, PassthruCentralNode a public one; combine as + // int to avoid the C++20 mixed-enum bitwise-or warning. + ImGui::DockBuilderAddNode(dockspace_id, + (int)ImGuiDockNodeFlags_DockSpace | (int)ImGuiDockNodeFlags_PassthruCentralNode); + ImGui::DockBuilderSetNodeSize(dockspace_id, ImGui::GetMainViewport()->WorkSize); + + ImGuiID centre = dockspace_id; - // Scene: place and edit objects - class SceneWorkspace : public Workspace + switch (m_layout) + { + case Layout::SceneEditing: + { + ImGuiID left = 0, left_bottom = 0, right = 0; + ImGui::DockBuilderSplitNode(centre, ImGuiDir_Left, 0.24f, &left, ¢re); + ImGui::DockBuilderSplitNode(left, ImGuiDir_Down, 0.50f, &left_bottom, &left); + ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.24f, &right, ¢re); + ImGui::DockBuilderDockWindow(kOutliner, left); + ImGui::DockBuilderDockWindow(kProperties, left_bottom); + ImGui::DockBuilderDockWindow(kBlackHole, right); + ImGui::DockBuilderDockWindow(kSettings, right); + ImGui::DockBuilderDockWindow(kExport, right); + ImGui::DockBuilderDockWindow(kStats, right); + break; + } + case Layout::Export: + { + ImGuiID right = 0, right_bottom = 0; + ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.28f, &right, ¢re); + ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.50f, &right_bottom, &right); + ImGui::DockBuilderDockWindow(kExport, right); + ImGui::DockBuilderDockWindow(kOutliner, right); + ImGui::DockBuilderDockWindow(kProperties, right); + ImGui::DockBuilderDockWindow(kBlackHole, right_bottom); + ImGui::DockBuilderDockWindow(kSettings, right_bottom); + ImGui::DockBuilderDockWindow(kStats, right_bottom); + break; + } + case Layout::Simulation: + default: + { + ImGuiID right = 0, right_bottom = 0; + ImGui::DockBuilderSplitNode(centre, ImGuiDir_Right, 0.24f, &right, ¢re); + ImGui::DockBuilderSplitNode(right, ImGuiDir_Down, 0.30f, &right_bottom, &right); + ImGui::DockBuilderDockWindow(kBlackHole, right); + ImGui::DockBuilderDockWindow(kSettings, right); + ImGui::DockBuilderDockWindow(kExport, right); + ImGui::DockBuilderDockWindow(kOutliner, right); + ImGui::DockBuilderDockWindow(kProperties, right); + ImGui::DockBuilderDockWindow(kStats, right_bottom); + break; + } + } + + ImGui::DockBuilderFinish(dockspace_id); + } + + // Apply a preset: set the centre view, choose which panels are visible, and + // request a dock-tree rebuild next frame. + auto UILayer::apply_layout(Layout layout) -> void + { + m_layout = layout; + m_rebuild_layout = true; + switch (layout) + { + case Layout::SceneEditing: + m_mode = Mode::Scene; + m_show_outliner = true; m_show_properties = true; + m_show_black_hole = false; m_show_settings = false; m_show_export = false; + m_show_stats = true; + break; + case Layout::Export: + m_mode = Mode::Simulation; + m_show_export = true; m_show_black_hole = true; + m_show_outliner = false; m_show_properties = false; m_show_settings = false; + m_show_stats = true; + break; + case Layout::Simulation: + default: + m_mode = Mode::Simulation; + m_show_black_hole = true; + m_show_outliner = false; m_show_properties = false; + m_show_settings = false; m_show_export = false; + m_show_stats = true; + break; + } + } + + auto UILayer::panel_outliner(const UIContext& ctx) -> void + { + if (ImGui::Begin(kOutliner, &m_show_outliner)) { - public: - auto name() const -> const char* override { return "Scene"; } - auto view() const -> View override { return View::Scene; } - auto on_ui(const UIContext& ctx) -> void override + Scene& sc = ctx.scene; + auto& objs = sc.objects; + if (sc.selected_object >= (int)objs.size()) sc.selected_object = (int)objs.size() - 1; + + if (ImGui::Button("Add sphere") && objs.size() < 64) + { + SceneObject o; + o.position = glm::vec3(((int)objs.size() % 5) * 5.0f - 10.0f, 2.0f, ((int)objs.size() / 5) * 5.0f); + o.radius = 1.5f; + o.color = glm::vec3(0.35f + 0.12f * (objs.size() % 5), 0.55f, 0.9f - 0.12f * (objs.size() % 4)); + objs.push_back(o); + sc.selected_object = (int)objs.size() - 1; + } + ImGui::SameLine(); + ImGui::BeginDisabled(objs.empty()); + if (ImGui::Button("Delete") && !objs.empty()) { - Scene& sc = ctx.scene; - ImGui::TextDisabled("Drag to orbit, scroll to zoom. HDRI skybox behind."); - auto& objs = sc.objects; + objs.erase(objs.begin() + sc.selected_object); if (sc.selected_object >= (int)objs.size()) sc.selected_object = (int)objs.size() - 1; + } + ImGui::EndDisabled(); - ImGui::Text("Spheres (%d)", (int)objs.size()); - if (ImGui::Button("Add") && objs.size() < 64) - { - SceneObject o; - o.position = glm::vec3(((int)objs.size() % 5) * 5.0f - 10.0f, 2.0f, ((int)objs.size() / 5) * 5.0f); - o.radius = 1.5f; - o.color = glm::vec3(0.35f + 0.12f * (objs.size() % 5), 0.55f, 0.9f - 0.12f * (objs.size() % 4)); - objs.push_back(o); - sc.selected_object = (int)objs.size() - 1; - } - ImGui::SameLine(); - if (ImGui::Button("Delete") && !objs.empty()) - { - objs.erase(objs.begin() + sc.selected_object); - if (sc.selected_object >= (int)objs.size()) sc.selected_object = (int)objs.size() - 1; - } + ImGui::SameLine(); + ImGui::TextDisabled("%d / 64", (int)objs.size()); + ImGui::Separator(); - ImGui::BeginChild("obj_list", ImVec2(0, 120), true); - for (int i = 0; i < (int)objs.size(); ++i) - { - std::string label = "Sphere " + std::to_string(i); - if (ImGui::Selectable(label.c_str(), sc.selected_object == i)) sc.selected_object = i; - } - ImGui::EndChild(); + ImGui::BeginChild("obj_list"); + for (int i = 0; i < (int)objs.size(); ++i) + { + std::string label = "Sphere " + std::to_string(i); + if (ImGui::Selectable(label.c_str(), sc.selected_object == i)) sc.selected_object = i; + } + if (objs.empty()) ImGui::TextDisabled("No spheres. Add one above."); + ImGui::EndChild(); + } + ImGui::End(); + } - if (sc.selected_object >= 0 && sc.selected_object < (int)objs.size()) - { - SceneObject& o = objs[sc.selected_object]; - ImGui::DragFloat3("Position", &o.position.x, 0.1f); - ImGui::DragFloat("Radius", &o.radius, 0.05f, 0.1f, 20.0f); - ImGui::ColorEdit3("Color", &o.color.x); + auto UILayer::panel_properties(const UIContext& ctx) -> void + { + if (ImGui::Begin(kProperties, &m_show_properties)) + { + Scene& sc = ctx.scene; + auto& objs = sc.objects; + if (objs.empty() || sc.selected_object < 0 || sc.selected_object >= (int)objs.size()) + { + ImGui::TextDisabled("Select a sphere in the Outliner."); + } + else + { + SceneObject& o = objs[sc.selected_object]; + ImGui::Text("Sphere %d", sc.selected_object); + ImGui::Separator(); + ImGui::DragFloat3("Position", &o.position.x, 0.1f); + ImGui::DragFloat("Radius", &o.radius, 0.05f, 0.1f, 20.0f); + ImGui::ColorEdit3("Color", &o.color.x); - ImGui::Spacing(); - if (ImGui::RadioButton("Move", m_op == ImGuizmo::TRANSLATE)) m_op = ImGuizmo::TRANSLATE; - ImGui::SameLine(); - if (ImGui::RadioButton("Scale", m_op == ImGuizmo::SCALE)) m_op = ImGuizmo::SCALE; - ImGui::SameLine(); ImGui::TextDisabled("(drag the handles in the viewport)"); + ImGui::Spacing(); + if (ImGui::RadioButton("Move", m_gizmo_op == 0)) m_gizmo_op = 0; + ImGui::SameLine(); + if (ImGui::RadioButton("Scale", m_gizmo_op == 1)) m_gizmo_op = 1; - // Viewport gizmo for the selected sphere. Uses the scene camera - // (its projection was set for this frame by the Application) and - // draws into the background draw list so it sits over the render - // but under the panels. + if (m_mode == Mode::Scene) + { + // Viewport gizmo for the selected sphere, using the scene camera + // (its projection was set for this frame by the Application). Drawn + // into the background list so it sits over the render, under panels. ImGuizmo::SetOrthographic(false); ImGuizmo::SetDrawlist(ImGui::GetBackgroundDrawList()); ImGuiIO& io = ImGui::GetIO(); @@ -218,175 +325,223 @@ namespace Donut glm::mat4 proj = sc.scene_camera.get_projection_matrix(); glm::mat4 xform = glm::translate(glm::mat4(1.0f), o.position) * glm::scale(glm::mat4(1.0f), glm::vec3(o.radius)); - if (ImGuizmo::Manipulate(&view[0][0], &proj[0][0], m_op, ImGuizmo::WORLD, &xform[0][0])) + ImGuizmo::OPERATION op = (m_gizmo_op == 1) ? ImGuizmo::SCALE : ImGuizmo::TRANSLATE; + if (ImGuizmo::Manipulate(&view[0][0], &proj[0][0], op, ImGuizmo::WORLD, &xform[0][0])) { float t[3], r[3], s[3]; ImGuizmo::DecomposeMatrixToComponents(&xform[0][0], t, r, s); o.position = glm::vec3(t[0], t[1], t[2]); o.radius = std::max(0.05f, (s[0] + s[1] + s[2]) / 3.0f); } + ImGui::TextDisabled("Drag the handles in the viewport."); + } + else + { + ImGui::TextDisabled("Switch View to Scene to drag the gizmo."); } } - private: - ImGuizmo::OPERATION m_op = ImGuizmo::TRANSLATE; - }; + } + ImGui::End(); + } - // Simulation: camera + accretion disk + quality - class SimulationWorkspace : public Workspace + auto UILayer::panel_black_hole(const UIContext& ctx) -> void + { + if (ImGui::Begin(kBlackHole, &m_show_black_hole)) { - public: - auto name() const -> const char* override { return "Simulation"; } - auto view() const -> View override { return View::BlackHole; } - auto on_ui(const UIContext& ctx) -> void override - { - Scene& sc = ctx.scene; - BlackHoleParams& bh = sc.black_hole; + Scene& sc = ctx.scene; + BlackHoleParams& bh = sc.black_hole; - if (ImGui::CollapsingHeader("Camera", ImGuiTreeNodeFlags_DefaultOpen)) - { - bool ff = sc.is_free_fly(); - if (ImGui::RadioButton("Orbital", !ff)) sc.set_free_fly(false); - ImGui::SameLine(); - if (ImGui::RadioButton("Free-fly", ff)) sc.set_free_fly(true); - ImGui::SameLine(); - if (ImGui::Button("Reset view")) sc.reset_sim_camera(); - ImGui::SliderFloat("FOV", &bh.fov_degrees, 20.0f, 90.0f, "%.0f\xc2\xb0"); - ImGui::TextDisabled(ff ? "WASD move | Q/E down-up | Shift boost | drag look" - : "Drag to orbit | scroll to zoom"); - } + if (ImGui::CollapsingHeader("Camera", ImGuiTreeNodeFlags_DefaultOpen)) + { + bool ff = sc.is_free_fly(); + if (ImGui::RadioButton("Orbital", !ff)) sc.set_free_fly(false); + ImGui::SameLine(); + if (ImGui::RadioButton("Free-fly", ff)) sc.set_free_fly(true); + ImGui::SameLine(); + if (ImGui::Button("Reset view")) sc.reset_sim_camera(); + ImGui::SliderFloat("FOV", &bh.fov_degrees, 20.0f, 90.0f, "%.0f\xc2\xb0"); + ImGui::TextDisabled(ff ? "WASD move | Q/E down-up | Shift boost | drag look" + : "Drag to orbit | scroll to zoom"); + } - if (ImGui::CollapsingHeader("Accretion disk", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::SliderFloat("Temperature", &bh.temperature, 2000.0f, 15000.0f, "%.0f K"); - ImGui::SliderFloat("Brightness", &bh.brightness, 0.0f, 3.0f, "%.2f"); - ImGui::SliderFloat("Inner radius", &bh.disk_inner_rs, 3.0f, 12.0f, "%.1f r_s"); - if (bh.disk_outer_rs < bh.disk_inner_rs + 0.5f) bh.disk_outer_rs = bh.disk_inner_rs + 0.5f; - ImGui::SliderFloat("Outer radius", &bh.disk_outer_rs, bh.disk_inner_rs + 0.5f, 25.0f, "%.1f r_s"); - ImGui::SliderFloat("Turbulence", &bh.turbulence, 0.0f, 2.0f, "%.2f"); - ImGui::TextDisabled("Inner edge is the ISCO (3 r_s). Novikov-Thorne profile."); - } + if (ImGui::CollapsingHeader("Accretion disk", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::SliderFloat("Temperature", &bh.temperature, 2000.0f, 15000.0f, "%.0f K"); + ImGui::SliderFloat("Brightness", &bh.brightness, 0.0f, 3.0f, "%.2f"); + ImGui::SliderFloat("Inner radius", &bh.disk_inner_rs, 3.0f, 12.0f, "%.1f r_s"); + if (bh.disk_outer_rs < bh.disk_inner_rs + 0.5f) bh.disk_outer_rs = bh.disk_inner_rs + 0.5f; + ImGui::SliderFloat("Outer radius", &bh.disk_outer_rs, bh.disk_inner_rs + 0.5f, 25.0f, "%.1f r_s"); + ImGui::SliderFloat("Turbulence", &bh.turbulence, 0.0f, 2.0f, "%.2f"); + ImGui::TextDisabled("Inner edge is the ISCO (3 r_s). Novikov-Thorne profile."); + } - if (ImGui::CollapsingHeader("Quality")) - { - ImGui::SliderInt("Integration steps", &bh.quality_steps, 2000, 15000); - ImGui::TextDisabled("Deeper lensing at higher cost. Settled frame is 4x supersampled."); - } + if (ImGui::CollapsingHeader("Quality")) + { + ImGui::SliderInt("Integration steps", &bh.quality_steps, 2000, 15000); + ImGui::TextDisabled("Deeper lensing at higher cost. Settled frame is 4x supersampled."); } - }; + } + ImGui::End(); + } - // Export: pick what to render out (not wired up yet) - class ExportWorkspace : public Workspace + auto UILayer::panel_settings(const UIContext& ctx) -> void + { + if (ImGui::Begin(kSettings, &m_show_settings)) { - public: - auto name() const -> const char* override { return "Export"; } - auto view() const -> View override { return View::None; } - auto on_ui(const UIContext& ctx) -> void override + auto& s = SettingsManager::get_settings(); + + if (ImGui::CollapsingHeader("Display", ImGuiTreeNodeFlags_DefaultOpen)) { - ImGui::TextDisabled("Render the black hole + observables to disk for analysis."); - ImGui::Spacing(); - if (ImGui::CollapsingHeader("Channels", ImGuiTreeNodeFlags_DefaultOpen)) - { - ImGui::Checkbox("Colour (tone-mapped)", &m_color); - ImGui::Checkbox("Redshift (g)", &m_redshift); - ImGui::Checkbox("Emission temperature", &m_temperature); - ImGui::Checkbox("Impact parameter", &m_impact); - ImGui::TextDisabled("Redshift & temperature are disk-only; impact is whole-frame (false-colour)."); - } - if (ImGui::CollapsingHeader("Output", ImGuiTreeNodeFlags_DefaultOpen)) + const bool fs = ctx.actions.is_fullscreen(); + int cw = 0, ch = 0; ctx.actions.get_window_size(cw, ch); + + struct Res { const char* label; int w, h; }; + static const Res kRes[] = { + { "1280 x 720", 1280, 720 }, { "1600 x 900", 1600, 900 }, + { "1920 x 1080", 1920, 1080 }, { "2560 x 1440", 2560, 1440 }, + }; + char cur_label[32]; std::snprintf(cur_label, sizeof(cur_label), "%d x %d", cw, ch); + ImGui::BeginDisabled(fs); + if (ImGui::BeginCombo("Resolution", cur_label)) { - ImGui::Combo("Resolution", &m_resolution, "1280 x 720\0" "1920 x 1080\0" "2560 x 1440\0" "3840 x 2160\0"); - ImGui::Combo("Format", &m_format, "Display image (PNG)\0" "Raw float image (PFM)\0" "Raw values (CSV)\0"); - if (m_format == 0) ImGui::TextDisabled("Tone-mapped colour / false-coloured observables."); - else ImGui::TextDisabled("Actual physical values: g, temperature (K), impact (r_s)."); + for (const auto& r : kRes) + if (ImGui::Selectable(r.label, cw == r.w && ch == r.h)) + ctx.actions.set_resolution(r.w, r.h); + ImGui::EndCombo(); } + ImGui::EndDisabled(); - ImGui::Spacing(); - const bool any = m_color || m_redshift || m_temperature || m_impact; - ImGui::BeginDisabled(!any); - if (ImGui::Button("Export frame", ImVec2(-1.0f, 0.0f))) + bool fs_v = fs; + if (ImGui::Checkbox("Fullscreen", &fs_v)) ctx.actions.set_fullscreen(fs_v); + + if (ImGui::Checkbox("V-Sync", &s.graphics.v_sync_enabled)) { - static const int dims[][2] = { {1280,720}, {1920,1080}, {2560,1440}, {3840,2160} }; - ExportConfig cfg; - cfg.color = m_color; cfg.redshift = m_redshift; - cfg.temperature = m_temperature; cfg.impact = m_impact; - cfg.width = dims[m_resolution][0]; cfg.height = dims[m_resolution][1]; - cfg.format = static_cast<ExportFormat>(m_format); - m_written = ctx.actions.export_frame(cfg); - m_reported = true; + ctx.actions.set_vsync(s.graphics.v_sync_enabled); + SettingsManager::save_settings(); } + + ImGui::BeginDisabled(s.graphics.v_sync_enabled); + if (ImGui::SliderInt("Frame cap", &s.simulation.target_fps, 30, 240, "%d FPS")) + SettingsManager::save_settings(); ImGui::EndDisabled(); + if (s.graphics.v_sync_enabled) + ImGui::TextDisabled("Frame cap applies only with V-Sync off."); + + if (ImGui::SliderFloat("UI scale", &m_ui_scale, 0.8f, 1.6f, "%.2fx")) + ctx.actions.set_ui_scale(m_ui_scale); + } - if (m_reported) + if (ImGui::CollapsingHeader("Renderer", ImGuiTreeNodeFlags_DefaultOpen)) + { + const char* apis[] = { "OpenGL", "Vulkan" }; + int cur = (s.graphics.render_api == "Vulkan") ? 1 : 0; + if (ImGui::Combo("Graphics API", &cur, apis, 2)) { - if (m_written > 0) - ImGui::TextColored(ImVec4(0.55f, 0.85f, 0.45f, 1.0f), "Wrote %d PNG(s) to exports/", m_written); - else - ImGui::TextColored(ImVec4(0.95f, 0.45f, 0.35f, 1.0f), "Export failed (see log)"); + s.graphics.render_api = apis[cur]; + SettingsManager::save_settings(); } + const char* running = (RendererAPI::get_api() == RendererAPI::API::Vulkan) ? "Vulkan" : "OpenGL"; + if (s.graphics.render_api != running) + ImGui::TextColored(ImVec4(0.98f, 0.62f, 0.20f, 1.0f), "Restart to apply (%s running)", running); + else + ImGui::TextDisabled("Active backend: %s (%s)", running, ctx.device_name.c_str()); } - private: - bool m_color = true, m_redshift = false, m_temperature = false, m_impact = false; - int m_resolution = 1, m_format = 0; - int m_written = 0; bool m_reported = false; - }; - } - UILayer::UILayer() - { - m_workspaces.push_back(create_scope<SetupWorkspace>()); - m_workspaces.push_back(create_scope<SceneWorkspace>()); - m_workspaces.push_back(create_scope<SimulationWorkspace>()); - m_workspaces.push_back(create_scope<ExportWorkspace>()); + if (ImGui::CollapsingHeader("Overlay", ImGuiTreeNodeFlags_DefaultOpen)) + { + bool dirty = false; + dirty |= ImGui::Checkbox("Show FPS", &s.graphics.show_fps); + dirty |= ImGui::Checkbox("Show frame time", &s.graphics.show_performance_metrics); + if (dirty) SettingsManager::save_settings(); + } - // Open on Simulation so the black hole greets the user on launch. - for (int i = 0; i < (int)m_workspaces.size(); ++i) - if (std::string(m_workspaces[i]->name()) == "Simulation") { m_landing = i; m_active = i; } + if (ImGui::CollapsingHeader("Environment", ImGuiTreeNodeFlags_DefaultOpen)) + { + auto& hdri = HDRIManager::get(); + std::string preview = hdri.get_hdri_name(ctx.scene.hdri_path); + if (ImGui::BeginCombo("Starfield", preview.c_str())) + { + for (const auto& path : hdri.get_available_hdri()) + { + bool selected = (path == ctx.scene.hdri_path); + if (ImGui::Selectable(hdri.get_hdri_name(path).c_str(), selected)) + ctx.scene.hdri_path = path; // RenderPath rebuilds the cubemap next frame + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + ImGui::TextDisabled("Switching rebuilds the cubemap (brief pause)."); + } + } + ImGui::End(); } - auto UILayer::draw(const UIContext& ctx) -> View + auto UILayer::panel_export(const UIContext& ctx) -> void { - static bool styled = false; - if (!styled) { apply_donut_style(); styled = true; } - - ImGuizmo::BeginFrame(); // gizmos are drawn by the Scene workspace - - // Full-viewport dock space with a pass-through centre, so the panel docks to - // any edge while the render shows through the middle. - ImGui::DockSpaceOverViewport(0, ImGui::GetMainViewport(), ImGuiDockNodeFlags_PassthruCentralNode); + if (ImGui::Begin(kExport, &m_show_export)) + { + ImGui::TextDisabled("Render the black hole + observables to disk."); + ImGui::Spacing(); + if (ImGui::CollapsingHeader("Channels", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::Checkbox("Colour (tone-mapped)", &m_exp_color); + ImGui::Checkbox("Redshift (g)", &m_exp_redshift); + ImGui::Checkbox("Emission temperature", &m_exp_temperature); + ImGui::Checkbox("Impact parameter", &m_exp_impact); + ImGui::TextDisabled("Redshift & temperature are disk-only; impact is whole-frame."); + } + if (ImGui::CollapsingHeader("Output", ImGuiTreeNodeFlags_DefaultOpen)) + { + ImGui::Combo("Resolution", &m_exp_resolution, "1280 x 720\0" "1920 x 1080\0" "2560 x 1440\0" "3840 x 2160\0"); + ImGui::Combo("Format", &m_exp_format, "Display image (PNG)\0" "Raw float image (PFM)\0" "Raw values (CSV)\0"); + if (m_exp_format == 0) ImGui::TextDisabled("Tone-mapped colour / false-coloured observables."); + else ImGui::TextDisabled("Actual physical values: g, temperature (K), impact (r_s)."); + } - ImGui::SetNextWindowSize(ImVec2(360, 620), ImGuiCond_FirstUseEver); - ImGui::Begin("Donut"); + ImGui::Spacing(); + const bool any = m_exp_color || m_exp_redshift || m_exp_temperature || m_exp_impact; + ImGui::BeginDisabled(!any); + if (ImGui::Button("Export frame", ImVec2(-1.0f, 0.0f))) + { + static const int dims[][2] = { {1280,720}, {1920,1080}, {2560,1440}, {3840,2160} }; + ExportConfig cfg; + cfg.color = m_exp_color; cfg.redshift = m_exp_redshift; + cfg.temperature = m_exp_temperature; cfg.impact = m_exp_impact; + cfg.width = dims[m_exp_resolution][0]; cfg.height = dims[m_exp_resolution][1]; + cfg.format = static_cast<ExportFormat>(m_exp_format); + m_exp_written = ctx.actions.export_frame(cfg); + m_exp_reported = true; + } + ImGui::EndDisabled(); - ImGuiIO& io = ImGui::GetIO(); - ImGui::TextDisabled("%s", ctx.device_name.empty() ? "GPU" : ctx.device_name.c_str()); - const bool show_fps = SettingsManager::get_show_fps(); - const bool show_ms = SettingsManager::get_show_performance_metrics(); - if (show_fps) ImGui::Text("%.0f FPS", io.Framerate); - if (show_ms) - { - if (show_fps) ImGui::SameLine(); - ImGui::TextDisabled("%.1f ms/frame", io.Framerate > 0 ? 1000.0f / io.Framerate : 0.0f); + if (m_exp_reported) + { + if (m_exp_written > 0) + ImGui::TextColored(ImVec4(0.55f, 0.85f, 0.45f, 1.0f), "Wrote %d file(s) to exports/", m_exp_written); + else + ImGui::TextColored(ImVec4(0.95f, 0.45f, 0.35f, 1.0f), "Export failed (see log)"); + } } - ImGui::Spacing(); + ImGui::End(); + } - if (ImGui::BeginTabBar("workspaces")) + auto UILayer::panel_stats(const UIContext& ctx) -> void + { + if (ImGui::Begin(kStats, &m_show_stats)) { - for (int i = 0; i < (int)m_workspaces.size(); ++i) + ImGuiIO& io = ImGui::GetIO(); + ImGui::TextDisabled("%s", ctx.device_name.empty() ? "GPU" : ctx.device_name.c_str()); + const bool show_fps = SettingsManager::get_show_fps(); + const bool show_ms = SettingsManager::get_show_performance_metrics(); + if (show_fps) ImGui::Text("%.0f FPS", io.Framerate); + if (show_ms) { - ImGuiTabItemFlags flags = (!m_landed && i == m_landing) ? ImGuiTabItemFlags_SetSelected : 0; - if (ImGui::BeginTabItem(m_workspaces[i]->name(), nullptr, flags)) - { - m_active = i; - ImGui::Spacing(); - m_workspaces[i]->on_ui(ctx); - ImGui::EndTabItem(); - } + if (show_fps) ImGui::SameLine(); + ImGui::TextDisabled("%.1f ms/frame", io.Framerate > 0 ? 1000.0f / io.Framerate : 0.0f); } - ImGui::EndTabBar(); - m_landed = true; + if (!show_fps && !show_ms) + ImGui::TextDisabled("Enable FPS / frame time in Settings > Overlay."); } ImGui::End(); - - return m_workspaces[m_active]->view(); } } diff --git a/src/ui/ui_layer.h b/src/ui/ui_layer.h index 67c9d8f..15b5ff8 100644 --- a/src/ui/ui_layer.h +++ b/src/ui/ui_layer.h @@ -1,27 +1,61 @@ #pragma once -#include "workspace.h" +#include "workspace.h" // AppActions, UIContext #include "rendering/view.h" -#include "core/memory.h" - -#include <vector> namespace Donut { - // The Dorico-style tab strip. Owns the workspaces (Setup / Scene / Simulation - // / Export); each frame it draws the dock space, the shared header, the tab - // bar, and delegates the active tab's panels. Returns the active workspace's - // View so the Application knows what the RenderPath should draw. + // The application's docking shell. Draws a main menu bar and a full-viewport + // dock space with a pass-through centre (the live 3D render shows through the + // middle), then a set of dockable tool panels. Ships a few default layouts + // built with the DockBuilder API and lets the user rearrange, hide, or restore + // panels like a normal desktop app. draw() returns the View the RenderPath + // should draw this frame. class UILayer { public: - UILayer(); + UILayer() = default; auto draw(const UIContext& ctx) -> View; private: - std::vector<Scope<Workspace>> m_workspaces; - int m_active = 0; - int m_landing = 0; // tab selected on the first frame - bool m_landed = false; + // Which 3D view fills the pass-through centre. + enum class Mode { Scene, Simulation }; + // A named default arrangement of the panels. + enum class Layout { Simulation, SceneEditing, Export }; + + auto draw_menu_bar(const UIContext& ctx) -> void; + auto build_layout(unsigned int dockspace_id) -> void; // DockBuilder tree + auto apply_layout(Layout layout) -> void; // preset: view + panels + + auto panel_outliner(const UIContext& ctx) -> void; + auto panel_properties(const UIContext& ctx) -> void; + auto panel_black_hole(const UIContext& ctx) -> void; + auto panel_settings(const UIContext& ctx) -> void; + auto panel_export(const UIContext& ctx) -> void; + auto panel_stats(const UIContext& ctx) -> void; + + Mode m_mode = Mode::Simulation; + Layout m_layout = Layout::Simulation; + bool m_rebuild_layout = false; // rebuild the dock tree next frame + bool m_first_frame = true; // seed the default layout once, if no saved one + + // Panel visibility (defaults match the Simulation layout the app opens in). + bool m_show_outliner = false; + bool m_show_properties = false; + bool m_show_black_hole = true; + bool m_show_settings = false; + bool m_show_export = false; + bool m_show_stats = true; + + // Properties-panel gizmo op (0 = translate, 1 = scale); cast in the .cpp. + int m_gizmo_op = 0; + + // Settings panel. + float m_ui_scale = 1.0f; + + // Export panel. + bool m_exp_color = true, m_exp_redshift = false, m_exp_temperature = false, m_exp_impact = false; + int m_exp_resolution = 1, m_exp_format = 0; + int m_exp_written = 0; bool m_exp_reported = false; }; } diff --git a/src/ui/workspace.h b/src/ui/workspace.h index d86614a..16dff2a 100644 --- a/src/ui/workspace.h +++ b/src/ui/workspace.h @@ -1,6 +1,5 @@ #pragma once -#include "rendering/view.h" #include "rendering/export_config.h" #include <string> @@ -8,7 +7,7 @@ namespace Donut { class Scene; - // App-level actions a workspace can request (window/device changes it can't do + // App-level actions the UI can request (window/device changes it can't do // itself). The Application implements this — it owns the window and device. struct AppActions { @@ -19,29 +18,17 @@ namespace Donut virtual auto set_ui_scale(float scale) -> void = 0; virtual auto get_window_size(int& w, int& h) const -> void = 0; virtual auto is_fullscreen() const -> bool = 0; - // Renders the requested channels off-screen and writes PNGs; returns the - // number of files written (0 on failure). + // Renders the requested channels off-screen and writes files; returns the + // number written (0 on failure). virtual auto export_frame(const ExportConfig& cfg) -> int = 0; }; - // What a workspace gets each frame to build its panels. `scene` is mutable — - // workspaces edit the document through it; `actions` performs app-level changes. + // What the UI panels get each frame. `scene` is mutable — panels edit the + // document through it; `actions` performs app-level changes. struct UIContext { Scene& scene; std::string device_name; AppActions& actions; }; - - // A Dorico-style workspace: one tab that owns its side panels and declares - // which viewport View it shows. Switching tabs switches both. Concrete - // workspaces (Setup / Scene / Simulation / Export) live in ui_layer.cpp. - class Workspace - { - public: - virtual ~Workspace() = default; - virtual auto name() const -> const char* = 0; - virtual auto view() const -> View = 0; - virtual auto on_ui(const UIContext& ctx) -> void = 0; - }; } |
