From e661b9f1c73ca02326e13142c476c2d362739b5e Mon Sep 17 00:00:00 2001 From: Hirek193 Date: Thu, 8 Oct 2026 18:52:51 +0200 Subject: [PATCH] feat(editor): -generate-nodebank-previews mode: png previews of the node bank models, or of the entries given with -entries, drawn offscreen and without a window by the full renderer: square, -resolution 1024 by default, the model in perspective from the front, side and above, fitted to the image less -margin pixels (64 by default) on white background, lit, with materials and with self shadows unless -no-shadows; saved in textures/previews/nodebank under the path of the model with the skin; software rendering of mesa when the graphics driver gives no gl 3.3 or with -software --- CMakeLists.txt | 1 + EU07.cpp | 9 + nodebank_previews_standalone.cpp | 452 +++++++++++++++++++++++++++++++ rendering/opengl33renderer.cpp | 412 +++++++++++++++++++++++++++- rendering/opengl33renderer.h | 24 ++ rendering/renderer.h | 4 + 6 files changed, 900 insertions(+), 2 deletions(-) create mode 100644 nodebank_previews_standalone.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 1613f0a7..f5fe1dec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -112,6 +112,7 @@ set(SOURCES "EU07.cpp" "export_e3d_standalone.cpp" "export_btf_standalone.cpp" +"nodebank_previews_standalone.cpp" "world/Event.cpp" "world/EvLaunch.cpp" "utilities/Float3d.cpp" diff --git a/EU07.cpp b/EU07.cpp index 4fd8b8b1..e0361829 100644 --- a/EU07.cpp +++ b/EU07.cpp @@ -34,6 +34,7 @@ Stele, firleju, szociu, hunter, ZiomalCl, OLI_EU and others void export_e3d_standalone(std::string in, std::string out, int flags, bool dynamic); int export_btf_standalone(std::string in, std::string out); +int generate_nodebank_previews(int Argc, char *Argv[]); #include #include @@ -99,6 +100,14 @@ int main(int argc, char *argv[]) int dynamic = std::stoi(std::string(argv[5])); export_e3d_standalone(in, out, flags, dynamic); } + // standalone generation of preview images of the node bank models: -generate-nodebank-previews [options] + else if (argc >= 2 && std::string(argv[1]) == "-generate-nodebank-previews") + { + auto const result{generate_nodebank_previews(argc, argv)}; + fflush(stdout); + fflush(stderr); + std::_Exit(result); + } // standalone conversion of text terrain file to binary format: -btf -s [-o ] else if (argc >= 4 && std::string(argv[1]) == "-btf") { diff --git a/nodebank_previews_standalone.cpp b/nodebank_previews_standalone.cpp new file mode 100644 index 00000000..8a171689 --- /dev/null +++ b/nodebank_previews_standalone.cpp @@ -0,0 +1,452 @@ +#include "stdafx.h" + +#include "model/AnimModel.h" +#include "rendering/renderer.h" +#include "scene/scenenode.h" +#include "utilities/Globals.h" +#include "utilities/Logs.h" +#include "utilities/parser.h" +#include "utilities/utilities.h" +#include +#include + +// standalone generation of preview images of the models of the node bank of the scenery editor: +// -generate-nodebank-previews [-entries "node ... endmodel" ...] [-resolution ] [-margin ] [-no-shadows] [-software] +// each image is a square png on white background, saved in textures/previews/nodebank under the path of the model + +namespace +{ + +std::string const preview_folder{"textures/previews/nodebank/"}; + +struct preview_settings +{ + std::vector entries; // node definitions to draw. the whole node bank if empty + int resolution{1024}; + int margin{64}; + bool shadows{true}; + bool software{false}; +}; + +// prints a line on the console the generator was started from, and puts it in the log +void report(std::string const &Text, bool const Error = false) +{ + std::fprintf((Error ? stderr : stdout), "%s\n", Text.c_str()); + std::fflush(Error ? stderr : stdout); + if (Error) + { + // goes to the log as well + ErrorLog(Text); + } + else + { + WriteLog(Text); + } +} + +bool parse_number(char const *Text, int &Value) +{ + try + { + std::size_t used{0}; + auto const value{std::stoi(Text, &used)}; + if (Text[used] != '\0') + { + return false; + } + Value = value; + return true; + } + catch (std::exception const &) + { + return false; + } +} + +bool parse_arguments(int const Argc, char *Argv[], preview_settings &Settings) +{ + for (int i = 2; i < Argc; ++i) + { + std::string const token{Argv[i]}; + if (token == "-entries") + { + while ((i + 1 < Argc) && (Argv[i + 1][0] != '-')) + { + Settings.entries.emplace_back(Argv[++i]); + } + } + else if (token == "-resolution") + { + if ((i + 1 >= Argc) || (false == parse_number(Argv[++i], Settings.resolution)) || (Settings.resolution < 16)) + { + report("-resolution needs a number of pixels, 16 or more", true); + return false; + } + } + else if (token == "-margin") + { + if ((i + 1 >= Argc) || (false == parse_number(Argv[++i], Settings.margin)) || (Settings.margin < 0)) + { + report("-margin needs a number of pixels, 0 or more", true); + return false; + } + } + else if (token == "-no-shadows") + { + Settings.shadows = false; + } + else if (token == "-software") + { + Settings.software = true; + } + else + { + report("unknown option " + token, true); + return false; + } + } + if (Settings.margin * 2 >= Settings.resolution) + { + report("the margin leaves no room for the model at this resolution", true); + return false; + } + return true; +} + +// node definitions of the node bank, read the way the node bank panel of the editor reads them +std::vector nodebank_entries() +{ + std::vector entries; + std::ifstream file("nodebank.txt", std::ios_base::in | std::ios_base::binary); + std::string line; + while (std::getline(file, line)) + { + if (line.size() < 4) + { + continue; + } + auto const nodestart{line.find("node")}; + if (nodestart == std::string::npos) + { + // group header + continue; + } + auto entry{line.substr(nodestart)}; + while ((false == entry.empty()) && ((entry.back() == '\r') || (entry.back() == '\n'))) + { + entry.pop_back(); + } + entries.emplace_back(entry); + } + return entries; +} + +// creates the hidden window with the gl context for the renderer. on a computer without a graphics driver able to provide +// gl 3.3 the software rasterizer of mesa can take over: on linux mesa picks it by itself, on windows its opengl32.dll +// (with the libraries it needs) is looked for in the mesa folder +GLFWwindow *create_context(bool const Software) +{ + if (Software) + { +#ifdef _WIN32 + auto const folder{std::filesystem::absolute("mesa")}; + ::SetDllDirectoryW(folder.wstring().c_str()); +#else + ::setenv("LIBGL_ALWAYS_SOFTWARE", "1", 1); +#endif + } + if (glfwInit() == GLFW_FALSE) + { + return nullptr; + } + glfwWindowHint(GLFW_VISIBLE, GLFW_FALSE); + glfwWindowHint(GLFW_CLIENT_API, GLFW_OPENGL_API); + glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE); + glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GLFW_TRUE); + glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); + glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3); + if (Global.gfx_gldebug) + glfwWindowHint(GLFW_OPENGL_DEBUG_CONTEXT, GLFW_TRUE); + auto *window{glfwCreateWindow(64, 64, "eu07 previews", nullptr, nullptr)}; + if (window == nullptr) + { + glfwTerminate(); + return nullptr; + } + glfwMakeContextCurrent(window); + if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress)) + { + glfwDestroyWindow(window); + glfwTerminate(); + return nullptr; + } + return window; +} + +// characters which can be safely used in a file name +std::string file_name_part(std::string Text) +{ + for (auto &character : Text) + { + auto const c{static_cast(character)}; + if ((false == std::isalnum(c)) && (c != '_') && (c != '-') && (c != '.') && (c != '+') && (c != '(') && (c != ')')) + { + character = '_'; + } + } + if ((Text.empty()) || (Text == ".") || (Text == "..")) + { + Text = "_"; + } + return Text; +} + +// path of the image of the node with specified model and skin: the path of the model in the preview folder, the skin added to the name +std::string preview_path(std::string Model, std::string const &Skin) +{ + replace_slashes(Model); + erase_extension(Model); + std::string path; + std::size_t start{0}; + while (start <= Model.size()) + { + auto end{Model.find('/', start)}; + if (end == std::string::npos) + { + end = Model.size(); + } + if (end > start) + { + if (false == path.empty()) + { + path += '/'; + } + path += file_name_part(Model.substr(start, end - start)); + } + start = end + 1; + } + if ((false == Skin.empty()) && (Skin != "none")) + { + auto skin{Skin}; + replace_slashes(skin); + erase_extension(skin); + std::replace(skin.begin(), skin.end(), '/', '_'); + path += '@' + file_name_part(skin); + } + return preview_folder + path; +} + +bool save_png(std::string const &Path, std::vector const &Image, int const Size) +{ + std::error_code error; + std::filesystem::create_directories(std::filesystem::path(Path).parent_path(), error); + png_image png; + std::memset(&png, 0, sizeof(png_image)); + png.version = PNG_IMAGE_VERSION; + png.width = Size; + png.height = Size; + png.format = PNG_FORMAT_RGB; + return (png_image_write_to_file(&png, Path.c_str(), 0, Image.data(), Size * 3, nullptr) != 0); +} + +// draws the preview of a single node bank entry. returns: true on success +bool generate_preview(std::string const &Definition, preview_settings const &Settings, std::set &Saved, std::string &Outcome) +{ + // a line of the node bank can be given as it is, with the label in front of the definition + auto const nodestart{Definition.find("node")}; + if (nodestart == std::string::npos) + { + Outcome = "not a node definition"; + return false; + } + auto const entry{Definition.substr(nodestart)}; + cParser parser(entry, cParser::buffer_TEXT); + parser.getTokens(); // node + scene::node_data nodedata; + parser.getTokens(4); + parser >> nodedata.range_max >> nodedata.range_min >> nodedata.name >> nodedata.type; + if (nodedata.name == "none") + { + nodedata.name.clear(); + } + if (nodedata.type != "model") + { + Outcome = "skipped, node of type " + nodedata.type + " isn't a model"; + return false; + } + // placement in the scenery is of no use here, the rotation however is a part of the look of the node + glm::dvec3 location; + glm::vec3 rotation{0.f}; + parser.getTokens(4); + parser >> location.x >> location.y >> location.z >> rotation.y; + // NOTE: instances are kept, as the rest of the simulator assumes scenery nodes live until the exit + auto *instance = new TAnimModel(nodedata); + instance->Angles(rotation); + instance->Load(&parser, false); + if (instance->pModel == nullptr) + { + Outcome = "model not found"; + return false; + } + instance->location(glm::dvec3{0.0}); + + // the model and skin, which give the name of the image, are the first two tokens after the rotation + cParser names(entry, cParser::buffer_TEXT); + names.getTokens(9, false); + auto const model{names.getToken()}; + auto const skin{names.getToken(false)}; + auto path{preview_path(model, skin)}; + if (Saved.contains(path)) + { + // another entry with the same model and skin, but other lights or angles + auto variant{2}; + while (Saved.contains(path + "~" + std::to_string(variant))) + { + ++variant; + } + path += "~" + std::to_string(variant); + } + path += ".png"; + + std::vector image; + if (false == GfxRenderer->Render_Preview(instance, Settings.resolution, Settings.margin, Settings.shadows, image)) + { + Outcome = "nothing to draw"; + return false; + } + if (false == save_png(path, image, Settings.resolution)) + { + Outcome = "failed to save " + path; + return false; + } + Saved.emplace(path.substr(0, path.size() - 4)); + Outcome = path; + return true; +} + +} // namespace + +int generate_nodebank_previews(int Argc, char *Argv[]) +{ +#ifdef _WIN32 + // the simulator is a windows application, so the console it was started from has to be joined to report progress + if (::AttachConsole(ATTACH_PARENT_PROCESS)) + { + FILE *stream{nullptr}; + freopen_s(&stream, "CONOUT$", "w", stdout); + freopen_s(&stream, "CONOUT$", "w", stderr); + } +#endif + // settings of the simulator, for the paths and the graphics options which aren't set for the previews below + auto const inipath{user_config_path("eu07.ini")}; + if (false == inipath.empty() && std::filesystem::exists(inipath)) + { + Global.LoadIniFile(inipath.string()); + } + else + { + Global.LoadIniFile("eu07.ini"); + } + // the progress goes to the console, the log keeps the details + Global.iWriteLogEnabled &= ~2; + std::thread logservice(LogService); + + auto const finish = [&](int const Result) { + // give the log service time to write out what's left + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + Global.applicationQuitOrder = true; + logservice.join(); + return Result; + }; + + preview_settings settings; + if (false == parse_arguments(Argc, Argv, settings)) + { + report("usage: " + std::string(Argv[0]) + " -generate-nodebank-previews [-entries \"node ... endmodel\" ...] [-resolution ] [-margin ] [-no-shadows] [-software]", true); + return finish(1); + } + + // the full renderer, drawing offscreen + Global.GfxRenderer = "default"; + Global.LegacyRenderer = false; + Global.NvRenderer = false; + Global.BasicRenderer = false; + Global.gfx_usegles = false; + Global.gfx_skippipeline = false; + Global.gfx_postfx_motionblur_enabled = false; + Global.gfx_postfx_chromaticaberration_enabled = false; + Global.gfx_postfx_ssao_enabled = false; + Global.gfx_envmap_enabled = false; + Global.gfx_shadowmap_enabled = settings.shadows; + Global.shadowtune.map_size = std::max(Global.shadowtune.map_size, 2048u); + Global.iMultisampling = 0; + Global.vr = false; + Global.extra_viewports.clear(); + Global.VSync = false; + Global.fb_size = {64, 64}; + Global.window_size = {64, 64}; + Global.gfx_framebuffer_width = 64; + Global.gfx_framebuffer_height = 64; + Global.gfx_texture_streaming = false; + Global.gfx_texture_releasedistance = 0.f; + // summer daylight, which picks the seasonal variants of models and materials and keeps the lamps off + Global.Season = "summer:"; + Global.Overcast = 0.f; + Global.fLuminance = 1.0; + + auto *window{create_context(settings.software)}; + if ((window == nullptr) && (false == settings.software)) + { + report("no gl 3.3 context from the graphics driver, trying the software renderer"); + settings.software = true; + window = create_context(true); + } + if (window == nullptr) + { +#ifdef _WIN32 + report("failed to create gl 3.3 context. for computers without a capable graphics driver put opengl32.dll of mesa (with the libraries it comes with) in the mesa folder", true); +#else + report("failed to create gl 3.3 context. without a display run it through xvfb-run", true); +#endif + return finish(1); + } + GfxRenderer = gfx_renderer_factory::get_instance()->create("modern"); + if ((!GfxRenderer) || (false == GfxRenderer->Init(window))) + { + report("failed to initialize the renderer", true); + return finish(1); + } + report(std::string("renderer: ") + reinterpret_cast(glGetString(GL_RENDERER))); + + auto const entries{settings.entries.empty() ? nodebank_entries() : settings.entries}; + if (entries.empty()) + { + report("no entries to draw, nodebank.txt is missing or empty", true); + return finish(1); + } + + std::set saved; + auto failures{0}; + for (std::size_t idx = 0; idx < entries.size(); ++idx) + { + std::string outcome; + auto success{false}; + try + { + success = generate_preview(entries[idx], settings, saved, outcome); + } + catch (std::exception const &Error) + { + // a broken entry shouldn't stop the others + outcome = Error.what(); + } + if (false == success) + { + ++failures; + } + report("[" + std::to_string(idx + 1) + "/" + std::to_string(entries.size()) + "] " + entries[idx] + (success ? " -> " : ": ") + outcome, (false == success)); + } + report(std::to_string(saved.size()) + " previews saved in " + preview_folder + (failures > 0 ? ", " + std::to_string(failures) + " entries failed" : "")); + + return finish(failures > 0 ? 1 : 0); +} diff --git a/rendering/opengl33renderer.cpp b/rendering/opengl33renderer.cpp index 36bf4572..7b9c434c 100644 --- a/rendering/opengl33renderer.cpp +++ b/rendering/opengl33renderer.cpp @@ -3982,7 +3982,7 @@ void opengl33_renderer::Render(TSubModel *Submodel) { glDebug("Render TSubModel"); - if ((Submodel->iVisible) && (TSubModel::fSquareDist >= Submodel->fSquareMinDist) && (TSubModel::fSquareDist < Submodel->fSquareMaxDist)) + if ((Submodel->iVisible) && (TSubModel::fSquareDist >= Submodel->fSquareMinDist) && (TSubModel::fSquareDist < Submodel->fSquareMaxDist) && (false == m_previewing || Submodel->fVisible > 0.f)) { glm::mat4 future_stack = model_ubs.future; @@ -4998,7 +4998,7 @@ void opengl33_renderer::Render_Alpha_text(TSubModel *Submodel) void opengl33_renderer::Render_Alpha(TSubModel *Submodel) { // renderowanie przezroczystych przez DL - if ((Submodel->iVisible) && (TSubModel::fSquareDist >= Submodel->fSquareMinDist) && (TSubModel::fSquareDist < Submodel->fSquareMaxDist)) + if ((Submodel->iVisible) && (TSubModel::fSquareDist >= Submodel->fSquareMinDist) && (TSubModel::fSquareDist < Submodel->fSquareMaxDist) && (false == m_previewing || Submodel->fVisible > 0.f)) { glm::mat4 future_stack = model_ubs.future; @@ -5549,6 +5549,414 @@ void opengl33_renderer::MakeScreenshot() screenshot_manager::make_screenshot(); } +// model previews + +namespace +{ +// direction from the previewed model to the camera. models are made facing -z (the front view of 3ds max), +// so the camera looks at their front, right side and top +glm::dvec3 const preview_viewdirection{-0.6123724, 0.5, -0.6123724}; +// vertical field of view of the preview camera, in degrees +float const preview_fieldofview{30.f}; +// brightness of the hdr background, past the white point of the tonemapping, so translucent parts blend with white +float const preview_background{16.f}; +} // namespace + +void opengl33_renderer::Preview_Bounds(TSubModel const *Submodel, glm::dmat4 const &Transform, glm::dvec3 &Min, glm::dvec3 &Max) const +{ + // same walk as the one of Render(TSubModel *) bar the range checks, which leave out parts only for the bounds to be smaller + for (auto const *submodel{Submodel}; submodel != nullptr; submodel = submodel->Next) + { + if ((submodel->iVisible == 0) || (submodel->fVisible <= 0.f)) + { + continue; + } + auto transform{Transform}; + if ((submodel->iFlags & 0xC000) && (submodel->fMatrix != nullptr)) + { + transform *= glm::dmat4{glm::make_mat4(submodel->fMatrix->readArray())}; + } + if ((submodel->eType < TP_ROTATOR) && (submodel->m_geometry.handle != null_handle)) + { + for (auto const &vertex : m_geometry.vertices(submodel->m_geometry.handle)) + { + auto const point{glm::dvec3{transform * glm::dvec4{glm::dvec3{vertex.position}, 1.0}}}; + Min = glm::min(Min, point); + Max = glm::max(Max, point); + } + } + Preview_Bounds(submodel->Child, transform, Min, Max); + } +} + +bool opengl33_renderer::Preview_Targets(int const Size) +{ + if (m_preview.size == Size) + { + return true; + } + m_preview = preview_targets(); + + m_preview.hdr_tex = std::make_unique(); + m_preview.hdr_tex->alloc_rendertarget(GL_RGBA16F, GL_RGBA, Size, Size, 1, 1, GL_CLAMP_TO_EDGE); + m_preview.hdr_rbds = std::make_unique(); + m_preview.hdr_rbds->alloc(GL_DEPTH32F_STENCIL8, Size, Size); + m_preview.hdr_fb = std::make_unique(); + m_preview.hdr_fb->attach(*m_preview.hdr_tex, GL_COLOR_ATTACHMENT0); + m_preview.hdr_fb->attach(*m_preview.hdr_rbds, GL_DEPTH_STENCIL_ATTACHMENT); + m_preview.hdr_fb->setup_drawing(1); + if (false == m_preview.hdr_fb->is_complete()) + { + ErrorLog("preview framebuffer setup failed"); + m_preview = preview_targets(); + return false; + } + // the tonemapping writes srgb values, either by itself or through the srgb target + m_preview.ldr_tex = std::make_unique(); + m_preview.ldr_tex->alloc_rendertarget((Global.gfx_shadergamma ? GL_RGBA8 : GL_SRGB8_ALPHA8), GL_RGBA, Size, Size, 1, 1, GL_CLAMP_TO_EDGE); + m_preview.ldr_fb = std::make_unique(); + m_preview.ldr_fb->attach(*m_preview.ldr_tex, GL_COLOR_ATTACHMENT0); + m_preview.ldr_fb->setup_drawing(1); + if (false == m_preview.ldr_fb->is_complete()) + { + ErrorLog("preview framebuffer setup failed"); + m_preview = preview_targets(); + return false; + } + gl::framebuffer::unbind(); + + m_preview.size = Size; + return true; +} + +void opengl33_renderer::Preview_Model(TAnimModel *Instance, float const Squaredistance, bool const Alpha) +{ + // the placement of Render(TAnimModel *), without its range and visibility tests + ::glPushMatrix(); + auto const position{Instance->location() - m_renderpass.pass_camera.position()}; + ::glTranslated(position.x, position.y, position.z); + auto const &angle{Instance->vAngle}; + if (angle.y != 0.0) + ::glRotated(angle.y, 0.f, 1.f, 0.f); + if (angle.x != 0.0) + ::glRotated(angle.x, 1.f, 0.f, 0.f); + if (angle.z != 0.0) + ::glRotated(angle.z, 0.f, 0.f, 1.f); + auto const &scale{Instance->Scale()}; + ::glScalef(scale.x, scale.y, scale.z); + if (Alpha) + Render_Alpha(Instance->pModel, Instance->Material(), Squaredistance); + else + Render(Instance->pModel, Instance->Material(), Squaredistance); + ::glPopMatrix(); +} + +void opengl33_renderer::Preview_Color(TAnimModel *Instance, float const Squaredistance, int const Size, glm::dvec3 const &Eye, glm::mat4 const &View, glm::mat4 const &Projection, bool const Shadows) +{ + m_renderpass.draw_mode = rendermode::color; + m_renderpass.pass_camera.position() = Eye; + m_renderpass.pass_camera.modelview() = View; + m_renderpass.pass_camera.projection() = Projection; + m_renderpass.viewport_camera.position() = Eye; + setup_matrices(); + + scene_ubs.projection = OpenGLMatrices.data(GL_PROJECTION); + scene_ubs.inv_view = glm::inverse(glm::mat4{glm::mat3{View}}); + scene_ubs.time = 0.f; + for (auto idx = 0; idx < m_shadowpass.size(); ++idx) + { + // everything is in the first cascade + scene_ubs.cascade_end[idx] = std::numeric_limits::max(); + } + scene_ubo->update(scene_ubs); + scene_ubo->bind_uniform(); + // the sun is the only light, and there's no fog + auto const &modelview{OpenGLMatrices.data(GL_MODELVIEW)}; + light_ubs.ambient = m_sunlight.ambient; + light_ubs.fog_color = glm::vec3{1.f}; + light_ubs.lights_count = 1; + light_ubs.lights[0].type = gl::light_element_ubs::DIR; + light_ubs.lights[0].dir = modelview * glm::vec4(m_sunlight.direction, 0.0f); + light_ubs.lights[0].color = m_sunlight.diffuse; + light_ubs.lights[0].ambient = 0.0f; + light_ubs.lights[0].intensity = 1.0f; + light_ubo->update(light_ubs); + model_ubs.fog_density = 0.f; + model_ubs.future = glm::mat4(); + model_ubs.emission = 0.f; + if (Shadows) + { + setup_shadow_bind_map(); + setup_shadow_color(colors::shadow); + } + else + { + setup_shadow_unbind_map(); + setup_shadow_color(colors::white); + } + setup_env_map(nullptr); + + m_preview.hdr_fb->bind(); + glViewport(0, 0, Size, Size); + glClearColor(preview_background, preview_background, preview_background, 1.f); + glClearStencil(0); + glStencilMask(0xFF); + m_preview.hdr_fb->clear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT); + if (!Global.gfx_usegles) + glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); + glEnable(GL_DEPTH_TEST); + // every fragment which made it past the alpha test marks the pixel as covered, translucent ones included + glEnable(GL_STENCIL_TEST); + glStencilFunc(GL_ALWAYS, 1, 0xFF); + glStencilOp(GL_KEEP, GL_KEEP, GL_REPLACE); + + setup_drawing(false); + Preview_Model(Instance, Squaredistance, false); + setup_drawing(true); + Preview_Model(Instance, Squaredistance, true); + setup_drawing(false); + + glDisable(GL_STENCIL_TEST); + setup_shadow_unbind_map(); +} + +bool opengl33_renderer::Render_Preview(TAnimModel *Instance, int const Size, int const Margin, bool const Shadows, std::vector &Image) +{ + if ((Instance == nullptr) || (Instance->pModel == nullptr) || (Size <= 0) || (Margin < 0) || (Margin * 2 >= Size)) + { + return false; + } + // the image is drawn at double size and scaled down, for antialiased edges which blend with the white background + GLint maxtexturesize{0}, maxrenderbuffersize{0}; + GLint maxviewport[2]{0, 0}; + glGetIntegerv(GL_MAX_TEXTURE_SIZE, &maxtexturesize); + glGetIntegerv(GL_MAX_RENDERBUFFER_SIZE, &maxrenderbuffersize); + glGetIntegerv(GL_MAX_VIEWPORT_DIMS, maxviewport); + auto const maxsize{std::min({maxtexturesize, maxrenderbuffersize, maxviewport[0], maxviewport[1]})}; + if (Size > maxsize) + { + ErrorLog("preview size " + std::to_string(Size) + " exceeds the limit of the graphics driver, " + std::to_string(maxsize)); + return false; + } + auto const supersampling{(Size * 2 <= maxsize ? 2 : 1)}; + auto const targetsize{Size * supersampling}; + if (false == Preview_Targets(targetsize)) + { + return false; + } + + glfwMakeContextCurrent(m_window); + opengl_texture::reset_unit_cache(); + gfx::opengl33_vaogeometrybank::reset(); + m_previewing = true; + // lights and visibility of the submodels, for this instance + Instance->RaAnimate(m_framestamp); + Instance->RaPrepare(); + + // bounding sphere of the model, which the camera is placed to fit in its view + glm::dvec3 boundsmin{std::numeric_limits::max()}; + glm::dvec3 boundsmax{std::numeric_limits::lowest()}; + Preview_Bounds(Instance->pModel->Root, glm::translate(glm::dmat4{1.0}, Instance->location()) * glm::dmat4{Instance->rotation_scale()}, boundsmin, boundsmax); + if (boundsmin.x > boundsmax.x) + { + m_previewing = false; + return false; + } + auto const centre{(boundsmin + boundsmax) * 0.5}; + auto const radius{std::max(0.01, glm::length(boundsmax - boundsmin) * 0.5)}; + auto const fieldofview{glm::radians(preview_fieldofview)}; + auto const distance{radius / std::sin(fieldofview * 0.5)}; + auto const eye{centre + glm::normalize(preview_viewdirection) * distance}; + glm::mat4 const view{glm::lookAt(eye, centre, glm::dvec3{0.0, 1.0, 0.0})}; + // the sphere is given some slack for parts which aren't where their vertices are, like billboards turning to the camera + auto const znear{static_cast(std::max(distance - radius * 1.5, distance * 0.05))}; + auto const zfar{static_cast(distance + radius * 1.5)}; + auto const projection{perspective_projection_raw(fieldofview, 1.f, znear, zfar)}; + // level of detail as seen from the camera, like in Render(TAnimModel *) + auto const squaredistance{static_cast(glm::length2(Instance->location() - eye) / Global.fDistanceFactor)}; + + // key light from above the left shoulder of the viewer, so the visible sides of the model differ in tone + auto const forward{glm::normalize(centre - eye)}; + auto const right{glm::normalize(glm::cross(forward, glm::dvec3{0.0, 1.0, 0.0}))}; + auto const tosun{glm::normalize(-forward * 0.4 + glm::dvec3{0.0, 1.0, 0.0} - right * 0.9)}; + m_sunlight.direction = glm::vec3{-tosun}; + m_sunlight.diffuse = glm::vec4{1.0f, 0.9686275f, 0.9411765f, 1.f}; + m_sunlight.ambient = glm::vec4{0.65f, 0.66f, 0.68f, 1.f}; + m_sunlight.factor = 1.f; + + auto const shadows{Shadows && Global.gfx_shadowmap_enabled && (m_shadow_fb != nullptr)}; + if (shadows) + { + // one map covering the whole model, seen from the sun + m_renderpass.draw_mode = rendermode::shadows; + m_renderpass.pass_camera.position() = centre; + m_renderpass.pass_camera.modelview() = glm::mat4{glm::lookAt(centre, centre + glm::dvec3{m_sunlight.direction}, glm::dvec3{0.0, 1.0, 0.0})}; + auto const extent{static_cast(radius * 1.5)}; + m_renderpass.pass_camera.projection() = ortho_projection(-extent, extent, -extent, extent, -extent, extent); + m_renderpass.viewport_camera.position() = eye; + setup_matrices(); + scene_ubs.projection = OpenGLMatrices.data(GL_PROJECTION); + scene_ubo->update(scene_ubs); + + glViewport(0, 0, m_shadowbuffersize, m_shadowbuffersize); + m_shadow_fb->attach(*m_shadow_tex, GL_DEPTH_ATTACHMENT, 0); + m_shadow_fb->clear(GL_DEPTH_BUFFER_BIT); + glEnable(GL_DEPTH_TEST); + setup_drawing(false); + Preview_Model(Instance, squaredistance, false); + m_shadow_fb->unbind(); + for (auto &pass : m_shadowpass) + { + pass = m_renderpass; + } + } + + // the view is cropped to the part of the image the model covers: a look from a bit further first, to find it, + // then a closer one at the full size, as thin parts can show up only there, to refine the crop to a pixel. + // the crop maps the area to the image less the margins + std::vector coverage; + auto const fitsize{targetsize}; + auto const area{1.0 - 2.0 * Margin / Size}; // half of the extent of the area for the model, in normalized device coordinates + glm::dvec2 cropcentre{0.0}; + auto cropscale{0.75}; + auto tight{false}; // whether the crop was fitted to the model + auto const cropped = [&]() { + return glm::mat4{glm::scale(glm::dmat4{1.0}, glm::dvec3{cropscale, cropscale, 1.0}) * glm::translate(glm::dmat4{1.0}, glm::dvec3{-cropcentre, 0.0})} * projection; + }; + for (auto attempt = 0; attempt < 6; ++attempt) + { + auto const passsize{(tight ? fitsize : std::min(fitsize, 512))}; + Preview_Color(Instance, squaredistance, passsize, eye, view, cropped(), shadows); + coverage.resize(static_cast(passsize) * passsize); + m_preview.hdr_fb->bind(); + glReadPixels(0, 0, passsize, passsize, GL_STENCIL_INDEX, GL_UNSIGNED_BYTE, coverage.data()); + + glm::ivec2 pixelmin{passsize, passsize}, pixelmax{-1, -1}; + for (auto y = 0; y < passsize; ++y) + { + auto const *row{coverage.data() + static_cast(y) * passsize}; + for (auto x = 0; x < passsize; ++x) + { + if (row[x] != 0) + { + pixelmin = glm::min(pixelmin, glm::ivec2{x, y}); + pixelmax = glm::max(pixelmax, glm::ivec2{x, y}); + } + } + } + if (pixelmax.x < 0) + { + if (attempt == 0) + { + // nothing to see + m_previewing = false; + m_renderpass.draw_mode = rendermode::none; + gl::framebuffer::unbind(); + return false; + } + // the crop missed the model, start over with the plain view + cropcentre = glm::dvec2{0.0}; + cropscale = 0.5; + tight = false; + continue; + } + // covered area in normalized device coordinates of the uncropped view + auto const tondc = [&](glm::ivec2 const &Pixel) { return (glm::dvec2{-1.0} + 2.0 * glm::dvec2{Pixel} / static_cast(passsize)) / cropscale + cropcentre; }; + auto const ndcmin{tondc(pixelmin)}; + auto const ndcmax{tondc(pixelmax + 1)}; + cropcentre = (ndcmin + ndcmax) * 0.5; + // a model fitted to the area keeps clear of the image edges, unless the margin is too thin to tell + auto const clipped{(pixelmin.x == 0) || (pixelmin.y == 0) || (pixelmax.x == passsize - 1) || (pixelmax.y == passsize - 1)}; + if (clipped && ((false == tight) || (Margin * passsize / Size >= 2))) + { + // part of the model is out of the view, look again from further away + cropscale *= 0.5; + tight = false; + continue; + } + cropscale = 2.0 * area / std::max(ndcmax.x - ndcmin.x, ndcmax.y - ndcmin.y); + if (tight) + { + // measured through a fitted crop, which makes it accurate to a pixel of the fitting pass + break; + } + tight = true; + } + + // the image itself + Preview_Color(Instance, squaredistance, targetsize, eye, view, cropped(), shadows); + glViewport(0, 0, targetsize, targetsize); + if (!Global.gfx_usegles && !Global.gfx_shadergamma) + glEnable(GL_FRAMEBUFFER_SRGB); + m_pfx_tonemapping->apply(*m_preview.hdr_tex, m_preview.ldr_fb.get()); + if (!Global.gfx_usegles && !Global.gfx_shadergamma) + glDisable(GL_FRAMEBUFFER_SRGB); + opengl_texture::reset_unit_cache(); + // the post processing binds its own vertex array, which the geometry banks don't know about + gfx::opengl33_vaogeometrybank::reset(); + + auto const targetpixels{static_cast(targetsize) * targetsize}; + std::vector colors(targetpixels * 4); + m_preview.ldr_fb->bind(); + glReadPixels(0, 0, targetsize, targetsize, GL_RGBA, GL_UNSIGNED_BYTE, colors.data()); + coverage.resize(targetpixels); + m_preview.hdr_fb->bind(); + glReadPixels(0, 0, targetsize, targetsize, GL_STENCIL_INDEX, GL_UNSIGNED_BYTE, coverage.data()); + gl::framebuffer::unbind(); + glDepthMask(GL_TRUE); + + m_previewing = false; + m_renderpass.draw_mode = rendermode::none; + + // uncovered pixels are white, the samples are averaged in linear space and the rows flipped to go from the top + static auto const tolinear{[]() { + std::array table; + for (auto idx = 0; idx < 256; ++idx) + { + auto const value{idx / 255.f}; + table[idx] = (value <= 0.04045f ? value / 12.92f : std::pow((value + 0.055f) / 1.055f, 2.4f)); + } + return table; + }()}; + auto const tosrgb = [](float const Value) { + auto const value{std::clamp(Value, 0.f, 1.f)}; + auto const encoded{(value <= 0.0031308f ? value * 12.92f : 1.055f * std::pow(value, 1.f / 2.4f) - 0.055f)}; + return static_cast(std::lround(encoded * 255.f)); + }; + auto const samplecount{static_cast(supersampling * supersampling)}; + Image.resize(static_cast(Size) * Size * 3); + for (auto y = 0; y < Size; ++y) + { + auto *output{Image.data() + static_cast(y) * Size * 3}; + auto const sourcey{(Size - 1 - y) * supersampling}; + for (auto x = 0; x < Size; ++x) + { + glm::vec3 color{0.f}; + for (auto sy = 0; sy < supersampling; ++sy) + { + for (auto sx = 0; sx < supersampling; ++sx) + { + auto const sample{static_cast(sourcey + sy) * targetsize + x * supersampling + sx}; + if (coverage[sample] != 0) + { + auto const *rgba{colors.data() + sample * 4}; + color += glm::vec3{tolinear[rgba[0]], tolinear[rgba[1]], tolinear[rgba[2]]}; + } + else + { + color += glm::vec3{1.f}; + } + } + } + color /= samplecount; + output[x * 3 + 0] = tosrgb(color.r); + output[x * 3 + 1] = tosrgb(color.g); + output[x * 3 + 2] = tosrgb(color.b); + } + } + return true; +} + void opengl33_renderer::Update_Lights(light_array &Lights) { glDebug("Update_Lights"); diff --git a/rendering/opengl33renderer.h b/rendering/opengl33renderer.h index 90142cd1..78684ae4 100644 --- a/rendering/opengl33renderer.h +++ b/rendering/opengl33renderer.h @@ -130,6 +130,7 @@ class opengl33_renderer : public gfx_renderer { std::string const & info_stats() const override; void MakeScreenshot() override; + bool Render_Preview( TAnimModel *Instance, int const Size, int const Margin, bool const Shadows, std::vector &Image ) override; @@ -334,6 +335,16 @@ class opengl33_renderer : public gfx_renderer { void draw_debug_ui(); + // model previews + // extends provided bounds with the geometry of the submodel and its siblings and children visible in a preview + void Preview_Bounds( TSubModel const *Submodel, glm::dmat4 const &Transform, glm::dvec3 &Min, glm::dvec3 &Max ) const; + // prepares the offscreen targets of model previews for square images of specified size + bool Preview_Targets( int const Size ); + // draws the instance on its own with the current render pass settings + void Preview_Model( TAnimModel *Instance, float const Squaredistance, bool const Alpha ); + // draws the lit instance into the hdr preview target, marking in its stencil the pixels covered by the model + void Preview_Color( TAnimModel *Instance, float const Squaredistance, int const Size, glm::dvec3 const &Eye, glm::mat4 const &View, glm::mat4 const &Projection, bool const Shadows ); + // members GLFWwindow *m_window{nullptr}; // main window gfx::geometrybank_manager m_geometry; @@ -547,6 +558,19 @@ class opengl33_renderer : public gfx_renderer { std::unique_ptr vr; bool debug_ui_active = false; + // offscreen targets of model previews + struct preview_targets + { + int size { 0 }; + std::unique_ptr hdr_tex; + std::unique_ptr hdr_rbds; // depth and stencil, the stencil marks pixels covered by the model + std::unique_ptr hdr_fb; + std::unique_ptr ldr_tex; + std::unique_ptr ldr_fb; + } m_preview; + // set while a preview is drawn: submodels faded out entirely are skipped, instead of being drawn invisible over the stencil + bool m_previewing { false }; + static bool renderer_register; class opengl33_imgui_renderer : public imgui_renderer diff --git a/rendering/renderer.h b/rendering/renderer.h index 225e26c0..e02959ff 100644 --- a/rendering/renderer.h +++ b/rendering/renderer.h @@ -93,6 +93,10 @@ public: // imgui renderer virtual class imgui_renderer *GetImguiRenderer() = 0; virtual void MakeScreenshot() = 0; + // draws the model of a scenery instance on its own into a square image of Size pixels on white background, the model + // seen in perspective from the front, side and above and filling the image less Margin pixels at each edge. + // Image receives rgb rows, top row first. returns false if the backend can't make previews or there's nothing to draw + virtual auto Render_Preview( TAnimModel *Instance, int const Size, int const Margin, bool const Shadows, std::vector &Image ) -> bool { return false; } }; class gfx_renderer_factory