diff --git a/AGENTS.md b/AGENTS.md index ee7eb5c..6720cb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,8 @@ The project is split into static libraries: - **`cbt_scene`** — Base scene class - **`scenes_cube`** — Spinning cube scene implementation - **`scenes_sphere`** — Cube-to-sphere mapped mesh with diffuse lighting +- **`scenes_cornell_box`** — Classic Cornell Box with Lambert shading and + a ceiling area light ### CMake Module Path @@ -89,6 +91,7 @@ to the custom scripts instead of system-installed packages. - Use conventional commit prefixes (`feat:`, `fix:`, `docs:`, `chore:`, etc.) - Separate subject from body with a blank line +- Do **not** add a `Co-Authored-By` trailer or any agent/AI attribution Example: @@ -140,6 +143,8 @@ cuber/ cube.cpp # Cube scene implementation sphere.hpp # Cube-to-sphere mapped mesh sphere.cpp # Sphere scene implementation + cornell_box.hpp # Cornell Box scene + cornell_box.cpp # Cornell Box scene implementation deps/ # Custom Find*.cmake scripts Findfmt.cmake # fmt library ``` diff --git a/cbt/gfx.cpp b/cbt/gfx.cpp index 19064b7..c5c808e 100644 --- a/cbt/gfx.cpp +++ b/cbt/gfx.cpp @@ -34,6 +34,8 @@ struct pipeline::impl { auto build(pipeline_desc const& desc) -> bool; auto bind_texture(char const* sampler_name, std::uint32_t texture_id, std::uint32_t unit) const -> void; + auto bind_vec3(char const* name, glm::vec3 const& v) const -> void; + auto bind_float(char const* name, float v) const -> void; }; auto pipeline::impl::build(pipeline_desc const& desc) -> bool { @@ -105,6 +107,26 @@ auto pipeline::impl::build(pipeline_desc const& desc) -> bool { } +auto pipeline::impl::bind_vec3(char const* name, glm::vec3 const& v) const -> void { + if (!m_prog.valid()) return; + m_prog.use(); + GLint const loc = glGetUniformLocation(m_prog.id(), name); + if (loc != -1) { + glUniform3fv(loc, 1, glm::value_ptr(v)); + } + m_prog.unuse(); +} + +auto pipeline::impl::bind_float(char const* name, float v) const -> void { + if (!m_prog.valid()) return; + m_prog.use(); + GLint const loc = glGetUniformLocation(m_prog.id(), name); + if (loc != -1) { + glUniform1f(loc, v); + } + m_prog.unuse(); +} + auto pipeline::impl::bind_texture(char const* sampler_name, std::uint32_t texture_id, std::uint32_t unit) const -> void { if (!m_prog.valid()) { return; @@ -187,6 +209,18 @@ auto pipeline::bind_texture(char const* sampler_name, std::uint32_t texture_id, } } +auto pipeline::bind_vec3(char const* name, glm::vec3 const& v) const -> void { + if (m_impl) { + m_impl->bind_vec3(name, v); + } +} + +auto pipeline::bind_float(char const* name, float v) const -> void { + if (m_impl) { + m_impl->bind_float(name, v); + } +} + struct render_target::impl { GLuint m_fbo = 0; opengl::texture m_color{opengl::texture_target::_2d}; diff --git a/cbt/gfx.hpp b/cbt/gfx.hpp index 2dadfdc..c183768 100644 --- a/cbt/gfx.hpp +++ b/cbt/gfx.hpp @@ -50,6 +50,8 @@ public: auto valid() const -> bool; auto draw(glm::mat4 const& model, glm::mat4 const& view, glm::mat4 const& proj) const -> void; auto bind_texture(char const* sampler_name, std::uint32_t texture_id, std::uint32_t unit = 0) const -> void; + auto bind_vec3(char const* name, glm::vec3 const& v) const -> void; + auto bind_float(char const* name, float v) const -> void; private: struct impl; diff --git a/cbt/scene.cpp b/cbt/scene.cpp index b85fafe..447bf08 100644 --- a/cbt/scene.cpp +++ b/cbt/scene.cpp @@ -10,4 +10,6 @@ auto scene::update(float) -> void {} auto scene::render(int, int) -> void {} +auto scene::on_mouse_drag(double, double) -> void {} + } diff --git a/cbt/scene.hpp b/cbt/scene.hpp index 8d855f3..e3554d1 100644 --- a/cbt/scene.hpp +++ b/cbt/scene.hpp @@ -11,6 +11,7 @@ public: virtual auto init() -> bool; virtual auto update(float delta_time) -> void; virtual auto render(int width, int height) -> void; + virtual auto on_mouse_drag(double dx, double dy) -> void; }; } diff --git a/cuber.cpp b/cuber.cpp index 54d387a..f3221b1 100644 --- a/cuber.cpp +++ b/cuber.cpp @@ -14,10 +14,11 @@ #include "cbt/opengl/context.hpp" #include "scenes/cube.hpp" #include "scenes/sphere.hpp" +#include "scenes/cornell_box.hpp" auto main(int argc, char const* argv[]) -> int { float max_duration_seconds = 0.0f; - std::string scene_name = "cube"; // "cube", "sphere", or "2" (for compatibility with keys) + std::string scene_name = "cube"; // "cube", "sphere", "cornell_box", or "1"/"2"/"3" bool take_screenshot = false; for (int i = 1; i < argc; ++i) { @@ -26,11 +27,11 @@ auto main(int argc, char const* argv[]) -> int { fmt::print("Usage: {} [options]\n", argv[0]); fmt::print("Flags:\n"); fmt::print(" --duration Auto-terminate after N seconds (for testing/CI)\n"); - fmt::print(" --scene Select initial scene (default: cube)\n"); + fmt::print(" --scene Select initial scene (default: cube)\n"); fmt::print(" --screenshot Render one frame, save screenshot, and exit\n"); fmt::print("\nKeys (during runtime):\n"); fmt::print(" S Take screenshot (saved as screenshot.png)\n"); - fmt::print(" 1/2 Switch between cube/sphere scene\n"); + fmt::print(" 1/2/3 Switch between cube/sphere/cornell_box scene\n"); fmt::print(" Q Quit\n"); return 0; } @@ -65,15 +66,18 @@ auto main(int argc, char const* argv[]) -> int { ctx.set_size(width, height); }); - auto cube_scn = cbt::scenes::cube(); - auto sphere_scn = cbt::scenes::sphere(); + auto cube_scn = cbt::scenes::cube(); + auto sphere_scn = cbt::scenes::sphere(); + auto cornell_box_scn = cbt::scenes::cornell_box(); cbt::scene* active_scene = nullptr; - if (!cube_scn.init() || !sphere_scn.init()) { + if (!cube_scn.init() || !sphere_scn.init() || !cornell_box_scn.init()) { return 1; } if (scene_name == "sphere" || scene_name == "2") { active_scene = &sphere_scn; + } else if (scene_name == "cornell_box" || scene_name == "3") { + active_scene = &cornell_box_scn; } else { active_scene = &cube_scn; } @@ -103,6 +107,10 @@ auto main(int argc, char const* argv[]) -> int { }; // render loop + double mouse_x = 0.0, mouse_y = 0.0; + glfwGetCursorPos(win.raw(), &mouse_x, &mouse_y); + bool mouse_was_down = false; + auto prev = std::chrono::steady_clock::now(); while (!win.should_close()) { @@ -120,6 +128,19 @@ auto main(int argc, char const* argv[]) -> int { if (glfwGetKey(win.raw(), GLFW_KEY_2) == GLFW_PRESS) { active_scene = &sphere_scn; } + if (glfwGetKey(win.raw(), GLFW_KEY_3) == GLFW_PRESS) { + active_scene = &cornell_box_scn; + } + + double cur_x = 0.0, cur_y = 0.0; + glfwGetCursorPos(win.raw(), &cur_x, &cur_y); + bool const mouse_down = glfwGetMouseButton(win.raw(), GLFW_MOUSE_BUTTON_LEFT) == GLFW_PRESS; + if (mouse_down && mouse_was_down) { + active_scene->on_mouse_drag(cur_x - mouse_x, cur_y - mouse_y); + } + mouse_x = cur_x; + mouse_y = cur_y; + mouse_was_down = mouse_down; auto now = std::chrono::steady_clock::now(); auto dt = std::chrono::duration(now - prev).count(); diff --git a/res/preview.png b/res/preview.png index 8f545b9..7ae005a 100644 Binary files a/res/preview.png and b/res/preview.png differ diff --git a/scenes/CMakeLists.txt b/scenes/CMakeLists.txt index d6e1c75..1ea9452 100644 --- a/scenes/CMakeLists.txt +++ b/scenes/CMakeLists.txt @@ -12,6 +12,13 @@ target_compile_options(scenes_sphere PRIVATE ${BASE_OPTIONS}) target_compile_definitions(scenes_sphere PRIVATE ${BASE_DEFINITIONS}) target_link_libraries(scenes_sphere PUBLIC cbt_scene glm::glm) +add_library(scenes_cornell_box STATIC "cornell_box.cpp") +target_include_directories(scenes_cornell_box PRIVATE "." "..") +target_compile_features(scenes_cornell_box PRIVATE cxx_std_23) +target_compile_options(scenes_cornell_box PRIVATE ${BASE_OPTIONS}) +target_compile_definitions(scenes_cornell_box PRIVATE ${BASE_DEFINITIONS}) +target_link_libraries(scenes_cornell_box PUBLIC cbt_scene glm::glm) + # Convenience interface for all scenes add_library(scenes INTERFACE) -target_link_libraries(scenes INTERFACE scenes_cube scenes_sphere) +target_link_libraries(scenes INTERFACE scenes_cube scenes_sphere scenes_cornell_box) diff --git a/scenes/cornell_box.cpp b/scenes/cornell_box.cpp new file mode 100644 index 0000000..93a9a72 --- /dev/null +++ b/scenes/cornell_box.cpp @@ -0,0 +1,300 @@ +#include +#include +#include +#include +#include + +#include "glad/glad.h" +#include "glm/gtc/matrix_transform.hpp" + +#include "scenes/cornell_box.hpp" + +namespace cbt::scenes { + +auto cornell_box::init() -> bool { + return build_scene_pipeline() && build_light_pipeline() && build_post_pipeline(); +} + +auto cornell_box::update(float) -> void {} + +auto cornell_box::on_mouse_drag(double dx, double dy) -> void { + float const sensitivity = 0.3f; + m_yaw += float(dx) * sensitivity; + m_pitch += float(dy) * sensitivity; + m_pitch = glm::clamp(m_pitch, -80.0f, 80.0f); +} + +auto cornell_box::render(int width, int height) -> void { + m_rt.resize(width, height); + + glViewport(0, 0, width, height); + + // Offscreen pass + m_rt.bind(); + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + float const aspect = float(width) / float(height); + float const yaw_rad = glm::radians(m_yaw); + float const pitch_rad = glm::radians(m_pitch); + glm::vec3 const eye{ + m_radius * sinf(yaw_rad) * cosf(pitch_rad), + m_radius * sinf(pitch_rad), + m_radius * cosf(yaw_rad) * cosf(pitch_rad), + }; + auto proj = glm::perspective(glm::radians(40.0f), aspect, 0.1f, 20.0f); + auto view = glm::lookAt(eye, glm::vec3{0.0f}, glm::vec3{0.0f, 1.0f, 0.0f}); + + m_scene_pipeline.bind_vec3("u_light_pos", {0.0f, 0.9f, 0.0f}); + m_scene_pipeline.bind_vec3("u_light_color", {1.0f, 1.0f, 1.0f}); + m_scene_pipeline.draw(glm::mat4{1.0f}, view, proj); + + m_light_pipeline.draw(glm::mat4{1.0f}, view, proj); + m_rt.unbind(); + + // Screen pass + glViewport(0, 0, width, height); + glClearColor(0.0f, 0.0f, 0.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + m_post_pipeline.bind_texture("u_texture", m_rt.color_id(), 0); + m_post_pipeline.draw(glm::mat4{1.0f}, glm::mat4{1.0f}, glm::mat4{1.0f}); +} + +auto cornell_box::build_scene_pipeline() -> bool { + char const* vert_src = R"glsl( + #version 410 core + layout(location = 0) in vec3 a_pos; + layout(location = 1) in vec3 a_normal; + layout(location = 2) in vec3 a_color; + uniform mat4 u_model; + uniform mat4 u_view; + uniform mat4 u_proj; + out vec3 v_normal; + out vec3 v_world_pos; + out vec3 v_color; + void main() { + gl_Position = u_proj * u_view * u_model * vec4(a_pos, 1.0); + v_normal = a_normal; + v_world_pos = a_pos; + v_color = a_color; + } + )glsl"; + + char const* frag_src = R"glsl( + #version 410 core + in vec3 v_normal; + in vec3 v_world_pos; + in vec3 v_color; + uniform vec3 u_light_pos; + uniform vec3 u_light_color; + out vec4 frag_color; + void main() { + vec3 n = normalize(v_normal); + vec3 l = normalize(u_light_pos - v_world_pos); + float diff = max(dot(n, l), 0.0); + vec3 ambient = 0.12 * v_color; + vec3 diffuse = diff * 0.88 * v_color * u_light_color; + frag_color = vec4(ambient + diffuse, 1.0); + } + )glsl"; + + struct vertex { + glm::vec3 position; + glm::vec3 normal; + glm::vec3 color; + }; + + glm::vec3 const white{0.73f, 0.71f, 0.68f}; + glm::vec3 const red{0.65f, 0.05f, 0.05f}; + glm::vec3 const green{0.12f, 0.45f, 0.09f}; + + std::vector verts; + std::vector inds; + + auto add_quad = [&](glm::vec3 p0, glm::vec3 p1, glm::vec3 p2, glm::vec3 p3, + glm::vec3 normal, glm::vec3 color) { + auto base = static_cast(verts.size()); + verts.push_back({p0, normal, color}); + verts.push_back({p1, normal, color}); + verts.push_back({p2, normal, color}); + verts.push_back({p3, normal, color}); + inds.insert(inds.end(), {base, base + 1, base + 2, base, base + 2, base + 3}); + }; + + // Room walls + add_quad({-1,-1, 1}, { 1,-1, 1}, { 1,-1,-1}, {-1,-1,-1}, { 0, 1, 0}, white); // floor + add_quad({-1, 1,-1}, { 1, 1,-1}, { 1, 1, 1}, {-1, 1, 1}, { 0,-1, 0}, white); // ceiling + add_quad({-1,-1,-1}, { 1,-1,-1}, { 1, 1,-1}, {-1, 1,-1}, { 0, 0, 1}, white); // back + add_quad({-1,-1, 1}, {-1, 1, 1}, {-1, 1,-1}, {-1,-1,-1}, { 1, 0, 0}, red); // left + add_quad({ 1,-1,-1}, { 1, 1,-1}, { 1, 1, 1}, { 1,-1, 1}, {-1, 0, 0}, green); // right + + // Boxes: vertices are pre-transformed into world space so the model matrix + // stays identity and normals need no runtime adjustment. + auto add_box = [&](glm::vec3 center, glm::vec3 half, float y_deg, glm::vec3 color) { + float const rad = glm::radians(y_deg); + float const cy = cosf(rad); + float const sy = sinf(rad); + auto ry = [&](glm::vec3 v) -> glm::vec3 { + return {v.x * cy + v.z * sy, v.y, -v.x * sy + v.z * cy}; + }; + + glm::vec3 c[8] = { + center + ry({-half.x, -half.y, -half.z}), + center + ry({ half.x, -half.y, -half.z}), + center + ry({ half.x, half.y, -half.z}), + center + ry({-half.x, half.y, -half.z}), + center + ry({-half.x, -half.y, half.z}), + center + ry({ half.x, -half.y, half.z}), + center + ry({ half.x, half.y, half.z}), + center + ry({-half.x, half.y, half.z}), + }; + + struct face_def { int vi[4]; glm::vec3 n; }; + face_def const faces[6] = { + {{4, 5, 6, 7}, { 0, 0, 1}}, // +Z + {{1, 0, 3, 2}, { 0, 0, -1}}, // -Z + {{0, 4, 7, 3}, {-1, 0, 0}}, // -X + {{5, 1, 2, 6}, { 1, 0, 0}}, // +X + {{7, 6, 2, 3}, { 0, 1, 0}}, // +Y + {{0, 1, 5, 4}, { 0, -1, 0}}, // -Y + }; + + for (auto const& f : faces) { + glm::vec3 const normal = ry(f.n); + auto const base = static_cast(verts.size()); + for (int i = 0; i < 4; ++i) { + verts.push_back({c[f.vi[i]], normal, color}); + } + inds.insert(inds.end(), {base, base + 1, base + 2, base, base + 2, base + 3}); + } + }; + + // Proportions from the original Cornell Box paper (room = 555 units → [-1, 1]). + // Tall box: 165×330×165 mm, center at (185, 165, 169), rotated +18°. + add_box({-0.33f, -0.40f, -0.39f}, {0.30f, 0.60f, 0.30f}, 18.0f, white); + // Short box: 165×165×165 mm, center at (370, 82.5, 351), rotated -15°. + add_box({ 0.33f, -0.70f, 0.27f}, {0.30f, 0.30f, 0.30f}, -15.0f, white); + + gfx::pipeline_desc desc{ + .vertex_data = std::as_bytes(std::span{verts}), + .index_data = std::as_bytes(std::span{inds}), + .attributes = { + {.location = 0, .num_components = 3, .offset = 0}, + {.location = 1, .num_components = 3, .offset = 12}, + {.location = 2, .num_components = 3, .offset = 24}, + }, + .vertex_stride = sizeof(vertex), + .vertex_shader_src = vert_src, + .fragment_shader_src = frag_src, + .depth_test = true, + .primitive = gfx::primitive_type::triangles, + .index_type_ = gfx::index_type::uint32 + }; + + m_scene_pipeline = gfx::pipeline{desc}; + return m_scene_pipeline.valid(); +} + +auto cornell_box::build_light_pipeline() -> bool { + char const* vert_src = R"glsl( + #version 410 core + layout(location = 0) in vec3 a_pos; + uniform mat4 u_model; + uniform mat4 u_view; + uniform mat4 u_proj; + void main() { + gl_Position = u_proj * u_view * u_model * vec4(a_pos, 1.0); + } + )glsl"; + + char const* frag_src = R"glsl( + #version 410 core + out vec4 frag_color; + void main() { + frag_color = vec4(1.5, 1.4, 1.2, 1.0); + } + )glsl"; + + struct pos_vertex { glm::vec3 position; }; + + // Ceiling light panel inset slightly from y=1 to avoid z-fighting with ceiling. + std::array const light_verts = {{ + {{-0.35f, 0.995f, -0.35f}}, + {{ 0.35f, 0.995f, -0.35f}}, + {{ 0.35f, 0.995f, 0.35f}}, + {{-0.35f, 0.995f, 0.35f}}, + }}; + std::array const light_inds = {0, 1, 2, 0, 2, 3}; + + gfx::pipeline_desc desc{ + .vertex_data = std::as_bytes(std::span{light_verts}), + .index_data = std::as_bytes(std::span{light_inds}), + .attributes = { + {.location = 0, .num_components = 3, .offset = 0}, + }, + .vertex_stride = sizeof(pos_vertex), + .vertex_shader_src = vert_src, + .fragment_shader_src = frag_src, + .depth_test = true, + .primitive = gfx::primitive_type::triangles, + .index_type_ = gfx::index_type::uint32 + }; + + m_light_pipeline = gfx::pipeline{desc}; + return m_light_pipeline.valid(); +} + +auto cornell_box::build_post_pipeline() -> bool { + char const* post_vert = R"glsl( + #version 410 core + layout(location = 0) in vec2 a_pos; + layout(location = 1) in vec2 a_uv; + out vec2 v_uv; + void main() { + gl_Position = vec4(a_pos, 0.0, 1.0); + v_uv = a_uv; + } + )glsl"; + + char const* post_frag = R"glsl( + #version 410 core + in vec2 v_uv; + uniform sampler2D u_texture; + out vec4 frag_color; + void main() { + vec3 col = texture(u_texture, v_uv).rgb; + float vig = 1.0 - length(v_uv * 2.0 - 1.0) * 0.35; + frag_color = vec4(col * vig, 1.0); + } + )glsl"; + + struct fs_vertex { glm::vec2 pos; glm::vec2 uv; }; + + std::array const qverts = {{ + {{-1.0f, -1.0f}, {0.0f, 0.0f}}, + {{ 1.0f, -1.0f}, {1.0f, 0.0f}}, + {{ 1.0f, 1.0f}, {1.0f, 1.0f}}, + {{-1.0f, 1.0f}, {0.0f, 1.0f}}, + }}; + std::array const qinds = {0, 1, 2, 0, 2, 3}; + + gfx::pipeline_desc desc{ + .vertex_data = std::as_bytes(std::span{qverts}), + .index_data = std::as_bytes(std::span{qinds}), + .attributes = { + {.location = 0, .num_components = 2, .offset = 0}, + {.location = 1, .num_components = 2, .offset = 8}, + }, + .vertex_stride = sizeof(fs_vertex), + .vertex_shader_src = post_vert, + .fragment_shader_src = post_frag, + .depth_test = false, + .primitive = gfx::primitive_type::triangles, + .index_type_ = gfx::index_type::uint32 + }; + + m_post_pipeline = gfx::pipeline{desc}; + return m_post_pipeline.valid(); +} + +} diff --git a/scenes/cornell_box.hpp b/scenes/cornell_box.hpp new file mode 100644 index 0000000..0c1de93 --- /dev/null +++ b/scenes/cornell_box.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "glm/glm.hpp" + +#include "cbt/scene.hpp" +#include "cbt/gfx.hpp" + +namespace cbt::scenes { + +class cornell_box final : public scene { +public: + auto init() -> bool override; + auto update(float delta_time) -> void override; + auto render(int width, int height) -> void override; + auto on_mouse_drag(double dx, double dy) -> void override; + +private: + gfx::pipeline m_scene_pipeline; + gfx::pipeline m_light_pipeline; + gfx::pipeline m_post_pipeline; + gfx::render_target m_rt{0, 0}; + + float m_yaw = 0.0f; // degrees, horizontal orbit + float m_pitch = 0.0f; // degrees, vertical orbit + float m_radius = 4.0f; + + auto build_scene_pipeline() -> bool; + auto build_light_pipeline() -> bool; + auto build_post_pipeline() -> bool; +}; + +}