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 | 5c8a5cd96ec369cdb5f61a31b4004c1f412cec09 (patch) | |
| tree | b7da1356a85b690d864f3f4d1b3772ba9f7c4777 | |
| parent | b7ec681e34c68b97976508fa26f8d5f4916de459 (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 | 6 | ||||
| -rw-r--r-- | assets/shaders/geodesic.slang | 134 | ||||
| -rw-r--r-- | assets/shaders/grid.slang | 26 | ||||
| -rw-r--r-- | assets/shaders/skybox.slang | 10 | ||||
| -rw-r--r-- | assets/shaders/sphere.slang | 60 | ||||
| -rw-r--r-- | assets/shaders/textured_quad.slang | 13 | ||||
| -rw-r--r-- | assets/shaders/vk_pipeline_test.slang | 6 | ||||
| -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 | 4 | ||||
| -rw-r--r-- | src/rendering/scene_renderer.cpp | 6 | ||||
| -rw-r--r-- | src/ui/ui_layer.cpp | 656 | ||||
| -rw-r--r-- | src/ui/ui_layer.h | 42 | ||||
| -rw-r--r-- | src/ui/workspace.h | 10 |
20 files changed, 606 insertions, 509 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..aaf07f6 100644 --- a/assets/shaders/equirect_to_cubemap.slang +++ b/assets/shaders/equirect_to_cubemap.slang @@ -1,5 +1,5 @@ -uniform float4x4 u_Projection; -uniform float4x4 u_View; +uniform float4x4 u_projection; +uniform float4x4 u_view; struct VSInput { float3 position : POSITION; }; struct VSOutput { float4 position : SV_Position; float3 worldPos : TEXCOORD0; }; @@ -9,7 +9,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 3229aee..4e11b4f 100644 --- a/assets/shaders/geodesic.slang +++ b/assets/shaders/geodesic.slang @@ -12,52 +12,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; - int rawOutput; + 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; + int u_raw_output; }; - -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; - float disk_r2; - float disk_num; - float thickness; - float disk_density; - float temperature; + float u_inner_radius; + float u_outer_radius; + float u_turbulence; + float u_thickness; + float u_brightness; + float u_temperature; }; 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; @@ -147,15 +139,15 @@ 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)); - 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); 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); - float Temit = disk.temperature * Tn; + float Temit = disk.u_temperature * Tn; float3 rhat = normalize(float3(P.x, 0.0, P.z)); float3 phiHat = normalize(cross(float3(0.0, 1.0, 0.0), rhat)); @@ -173,17 +165,17 @@ 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; - if (disk.disk_num > 0.0) + 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 @@ -229,15 +221,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; @@ -285,8 +277,8 @@ float CalculateAdaptiveStepSize(Ray ray, float baseStepSize) float step = 0.02 * max(ray.r - R_PHOTON, 0.0); 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); } @@ -296,13 +288,21 @@ float3 ACESFilm(float3 x) return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), 0.0, 1.0); } +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); +} + 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; @@ -314,11 +314,11 @@ float3 TracePixel(float2 texCoord, out float outG, out float outTemit, out bool bool hitDisk = false; float3 diskColor = float3(0.0); - 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) @@ -332,7 +332,7 @@ float3 TracePixel(float2 texCoord, out float outG, out float outTemit, out bool float3 newPos = float3(ray.x, ray.y, ray.z); { - 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) @@ -341,7 +341,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; @@ -356,7 +356,7 @@ float3 TracePixel(float2 texCoord, out float outG, out float outTemit, out bool if (ray.dr > 0.0 && ray.r > 50.0 * SagA_rs) break; } - 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); @@ -373,7 +373,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; } @@ -388,39 +388,39 @@ float3 TracePixel(float2 texCoord, out float outG, out float outTemit, out bool [shader("fragment")] float4 fragmentMain(VSOutput input) : SV_Target { - 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) + if (cam.u_output_channel == 3) { - 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 { 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); 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) + if (cam.u_raw_output != 0) 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 d90799e..b3c846f 100644 --- a/assets/shaders/grid.slang +++ b/assets/shaders/grid.slang @@ -1,13 +1,13 @@ -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; }; @@ -16,19 +16,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 b7e5de8..de400b8 100644 --- a/assets/shaders/skybox.slang +++ b/assets/shaders/skybox.slang @@ -1,9 +1,9 @@ -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; }; @@ -13,7 +13,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; return output; } diff --git a/assets/shaders/sphere.slang b/assets/shaders/sphere.slang index 256372d..5ff7c10 100644 --- a/assets/shaders/sphere.slang +++ b/assets/shaders/sphere.slang @@ -1,39 +1,29 @@ -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; } @@ -43,8 +33,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; @@ -53,18 +43,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..c0a70a5 100644 --- a/assets/shaders/textured_quad.slang +++ b/assets/shaders/textured_quad.slang @@ -1,14 +1,5 @@ -struct VSInput -{ - float2 position : POSITION; - float2 texCoord : TEXCOORD0; -}; - -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 57027c9..32326b6 100644 --- a/assets/shaders/vk_pipeline_test.slang +++ b/assets/shaders/vk_pipeline_test.slang @@ -1,8 +1,4 @@ -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 68e0e89..6391e05 100644 --- a/src/platform/opengl/opengl_device.cpp +++ b/src/platform/opengl/opengl_device.cpp @@ -116,11 +116,15 @@ namespace Donut::RHI { GLuint bi = glGetUniformBlockIndex(m_prog, r.name.c_str()); if (bi != GL_INVALID_INDEX) glUniformBlockBinding(m_prog, bi, r.binding); + else DONUT_WARN("GL RHI: shader '{}' has no uniform block '{}' (binding {})", + d.shader, r.name, r.binding); } else { 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 c2dd9d1..b2f1cce 100644 --- a/src/rendering/scene_renderer.cpp +++ b/src/rendering/scene_renderer.cpp @@ -63,7 +63,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; @@ -99,7 +99,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; @@ -122,7 +122,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 f44223d..811d7df 100644 --- a/src/ui/ui_layer.cpp +++ b/src/ui/ui_layer.cpp @@ -6,16 +6,23 @@ #include "rendering/render_api.h" #include <imgui.h> +#include <imgui_internal.h> #include <ImGuizmo.h> #include <glm/glm.hpp> #include <glm/gtc/matrix_transform.hpp> #include <algorithm> -#include <cstdlib> #include <cstdio> #include <string> namespace Donut { + 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"; + static auto apply_donut_style() -> void { ImGuiStyle& s = ImGui::GetStyle(); @@ -31,6 +38,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); @@ -52,156 +60,245 @@ 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); } - namespace + auto UILayer::draw(const UIContext& ctx) -> View { - class SetupWorkspace : public Workspace + static bool styled = false; + if (!styled) { apply_donut_style(); styled = true; } + + ImGuizmo::BeginFrame(); + + draw_menu_bar(ctx); + + const ImGuiID dockspace_id = ImGui::DockSpaceOverViewport( + 0, ImGui::GetMainViewport(), ImGuiDockNodeFlags_PassthruCentralNode); + + 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); - 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()); - } + 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; - if (selected) ImGui::SetItemDefaultFocus(); - } - ImGui::EndCombo(); - } - ImGui::TextDisabled("Switching rebuilds the cubemap (brief pause)."); - } - } - private: - float m_ui_scale = 1.0f; - }; + auto UILayer::build_layout(unsigned int dockspace_id) -> void + { + ImGui::DockBuilderRemoveNode(dockspace_id); + ImGui::DockBuilderAddNode(dockspace_id, + (int)ImGuiDockNodeFlags_DockSpace | (int)ImGuiDockNodeFlags_PassthruCentralNode); + ImGui::DockBuilderSetNodeSize(dockspace_id, ImGui::GetMainViewport()->WorkSize); + + ImGuiID centre = dockspace_id; - 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); + } + + 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; + if (m_mode == Mode::Scene) + { ImGuizmo::SetOrthographic(false); ImGuizmo::SetDrawlist(ImGui::GetBackgroundDrawList()); ImGuiIO& io = ImGui::GetIO(); @@ -210,170 +307,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(); + } - 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(); + } - 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(); + } - 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; + 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(); - - 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 5032c05..62b4104 100644 --- a/src/ui/ui_layer.h +++ b/src/ui/ui_layer.h @@ -2,22 +2,48 @@ #include "workspace.h" #include "rendering/view.h" -#include "core/memory.h" - -#include <vector> namespace Donut { 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; - bool m_landed = false; + enum class Mode { Scene, Simulation }; + enum class Layout { Simulation, SceneEditing, Export }; + + auto draw_menu_bar(const UIContext& ctx) -> void; + auto build_layout(unsigned int dockspace_id) -> void; + auto apply_layout(Layout layout) -> void; + + 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; + bool m_first_frame = true; + + 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; + + int m_gizmo_op = 0; + + float m_ui_scale = 1.0f; + + 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 608c829..e2f4412 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> @@ -26,13 +25,4 @@ namespace Donut std::string device_name; AppActions& actions; }; - - 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; - }; } |
