/
Mr.Stalin
/
FOnline-Engine
Обзор
Документация
Войти
/
Mr.Stalin
/
FOnline-Engine
Код
Пакеты
0
Релизы
0
Аналитика
Безопасность
master
Source/Tools/ParticleBaker.cpp
977 строк
43 KB
cvet
Particles upgrade (#194)
27 июл 2026, 20:55
Не верифицирован
27 июл 2026, 20:55
0e6a422
Код
Авторство
О чём код?
// __________ ___ ______ _ // / ____/ __ \____ / (_)___ ___ / ____/___ ____ _(_)___ ___ // / /_ / / / / __ \/ / / __ \/ _ \ / __/ / __ \/ __ `/ / __ \/ _ ` // / __/ / /_/ / / / / / / / / / __/ / /___/ / / / /_/ / / / / / __/ // /_/ \____/_/ /_/_/_/_/ /_/\___/ /_____/_/ /_/\__, /_/_/ /_/\___/ // /____/ // FOnline Engine // https://fonline.ru // https://github.com/cvet/fonline // // MIT License // // Copyright (c) 2006 - 2026, Anton Tsvetinskiy aka cvet <cvet@tut.by> // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. // #include "ParticleBaker.h" #include "EffekseerCompiler.h" #include "EffekseerExtension.h" #include "SparkExtension.h" #if FO_SPARK_PARTICLES || FO_EFFEKSEER_PARTICLES FO_DISABLE_WARNINGS_PUSH() #if FO_EFFEKSEER_PARTICLES #include "Effekseer.h" #endif #if FO_SPARK_PARTICLES #include "SPARK.h" #endif FO_DISABLE_WARNINGS_POP() #endif FO_BEGIN_NAMESPACE #if FO_SPARK_PARTICLES static void ValidateSparkTexturePaths(const File& particle_file, const SPK::Ref<SPK::System>& system) { FO_STACK_TRACE_ENTRY(); std::filesystem::path particle_dir {fs_make_path(strex(particle_file.GetPath()).extract_dir().normalize_path_slashes())}; for (size_t group_index = 0; group_index < system->getNbGroups(); group_index++) { const SPK::Ref<SPK::Group>& group = system->getGroup(group_index); const SPK::Ref<SPK::Renderer>& renderer = group->getRenderer(); if (!renderer || !SPK::FO::IsSparkQuadRenderer(*renderer)) { continue; } SPK::FO::SparkQuadRendererData renderer_data = SPK::FO::GetSparkQuadRendererData(*renderer); string texture_path {renderer_data.TextureName}; if (texture_path.empty()) { continue; } if (texture_path.find_first_of("\t\r\n") != string::npos) { throw ParticleBakerException("SPARK particle has an invalid texture path", particle_file.GetPath(), texture_path); } string normalized_path = strex(texture_path).normalize_path_slashes(); bool has_drive_prefix = normalized_path.size() >= 2 && normalized_path[1] == ':' && ((normalized_path[0] >= 'A' && normalized_path[0] <= 'Z') || (normalized_path[0] >= 'a' && normalized_path[0] <= 'z')); std::filesystem::path relative_path {fs_make_path(normalized_path)}; if (normalized_path.starts_with('/') || has_drive_prefix || relative_path.is_absolute()) { throw ParticleBakerException("SPARK particle texture path must be relative", particle_file.GetPath(), texture_path); } std::filesystem::path resolved_path = (particle_dir / relative_path).lexically_normal(); auto first_component = resolved_path.begin(); if (resolved_path.empty() || resolved_path.is_absolute() || (first_component != resolved_path.end() && *first_component == "..")) { throw ParticleBakerException("SPARK particle texture path escapes its resource source", particle_file.GetPath(), texture_path, particle_file.GetDataSource()->GetPackName()); } } } #endif #if FO_EFFEKSEER_PARTICLES static constexpr string_view EffekseerDependencyCacheHeader = "FONLINE_EFFEKSEER_DEPENDENCIES_V5\n"; static auto GetEffekseerDependencyCachePath(const BakingContext& context, string_view output_path) -> string { FO_STACK_TRACE_ENTRY(); if (context.Settings->BakeOutput.empty()) { return {}; } return strex(context.Settings->BakeOutput).combine_path(BAKER_CACHE_DIR).combine_path("Effekseer").combine_path(context.PackName).combine_path(strex("{}.deps", output_path)); } static auto ParseEffekseerDependencySnapshot(string_view snapshot) -> optional<vector<string>> { FO_STACK_TRACE_ENTRY(); if (!snapshot.starts_with(EffekseerDependencyCacheHeader)) { return std::nullopt; } vector<string> paths; size_t project_line_end = snapshot.find('\n', EffekseerDependencyCacheHeader.size()); if (project_line_end == string::npos) { return std::nullopt; } string_view project_line = snapshot.substr(EffekseerDependencyCacheHeader.size(), project_line_end - EffekseerDependencyCacheHeader.size()); size_t project_first_tab = project_line.find('\t'); size_t project_second_tab = project_first_tab != string::npos ? project_line.find('\t', project_first_tab + 1) : string::npos; if (project_first_tab == 0 || project_second_tab == string::npos || project_line.find('\t', project_second_tab + 1) != string::npos) { return std::nullopt; } string_view project_path = project_line.substr(0, project_first_tab); if (!std::filesystem::path {fs_make_path(project_path)}.is_absolute()) { return std::nullopt; } size_t line_start = project_line_end + 1; while (line_start < snapshot.size()) { size_t line_end = snapshot.find('\n', line_start); string_view line = snapshot.substr(line_start, line_end != string::npos ? line_end - line_start : snapshot.size() - line_start); if (!line.empty()) { size_t first_tab = line.find('\t'); size_t second_tab = first_tab != string::npos ? line.find('\t', first_tab + 1) : string::npos; if (first_tab == 0 || second_tab == string::npos || line.find('\t', second_tab + 1) != string::npos) { return std::nullopt; } string path {line.substr(0, first_tab)}; if (!std::filesystem::path {fs_make_path(path)}.is_absolute()) { return std::nullopt; } paths.emplace_back(std::move(path)); } if (line_end == string::npos) { break; } line_start = line_end + 1; } return paths; } static auto BuildEffekseerDependencySnapshot(string_view project_path, size_t project_size, uint64_t project_write_time, const vector<string>& dependency_paths, uint64_t& max_write_time) -> string { FO_STACK_TRACE_ENTRY(); string snapshot {EffekseerDependencyCacheHeader}; snapshot += strex("{}\t{}\t{}\n", project_path, project_size, project_write_time); max_write_time = project_write_time; for (const string& dependency_path : dependency_paths) { optional<size_t> dependency_size = fs_file_size(dependency_path); uint64_t dependency_write_time = fs_last_write_time(dependency_path); if (dependency_size && dependency_write_time != 0) { snapshot += strex("{}\t{}\t{}\n", dependency_path, *dependency_size, dependency_write_time); max_write_time = std::max(max_write_time, dependency_write_time); } else { snapshot += strex("{}\t-\t-\n", dependency_path); } } return snapshot; } static auto TryGetCachedEffekseerDependencyWriteTime(const BakingContext& context, const FileHeader& project_file, string_view output_path) -> optional<uint64_t> { FO_STACK_TRACE_ENTRY(); uint64_t source_write_time = project_file.GetWriteTime(); string cache_path = GetEffekseerDependencyCachePath(context, output_path); if (cache_path.empty()) { return source_write_time; } if (!project_file.GetDataSource()->IsDiskDir()) { return source_write_time; } string project_path = fs_resolve_path(project_file.GetDiskPath()); optional<string> cached_snapshot = fs_read_file(cache_path); optional<vector<string>> dependency_paths = cached_snapshot ? ParseEffekseerDependencySnapshot(*cached_snapshot) : std::nullopt; uint64_t dependency_write_time = 0; optional<string> current_snapshot = dependency_paths ? optional<string> {BuildEffekseerDependencySnapshot(project_path, project_file.GetSize(), source_write_time, *dependency_paths, dependency_write_time)} : std::nullopt; if (cached_snapshot && current_snapshot && *cached_snapshot == *current_snapshot) { return dependency_write_time; } return std::nullopt; } static auto ResolveEffekseerDependencyPaths(const File& project_file, const vector<string>& compiler_dependencies) -> vector<string> { FO_STACK_TRACE_ENTRY(); vector<string> resolved_paths; string project_path = fs_resolve_path(project_file.GetDiskPath()); string source_root_path = fs_resolve_path(project_file.GetDataSource()->GetPackName()); std::filesystem::path project_dir = std::filesystem::path {fs_make_path(project_path)}.parent_path(); std::filesystem::path source_root = std::filesystem::path {fs_make_path(source_root_path)}.lexically_normal(); for (string dependency_path : compiler_dependencies) { if (dependency_path.empty()) { continue; } if (dependency_path.find_first_of("\t\r\n") != string::npos) { throw ParticleBakerException("Effekseer compiler produced an invalid dependency path", project_path, dependency_path); } std::filesystem::path relative_path {fs_make_path(strex(dependency_path).normalize_path_slashes())}; if (relative_path.is_absolute()) { throw ParticleBakerException("Effekseer project dependency path must be relative", project_path, dependency_path); } std::filesystem::path resolved_path = (project_dir / relative_path).lexically_normal(); std::filesystem::path source_relative_path = resolved_path.lexically_relative(source_root); auto first_component = source_relative_path.begin(); if (source_relative_path.empty() || source_relative_path.is_absolute() || (first_component != source_relative_path.end() && *first_component == "..")) { throw ParticleBakerException("Effekseer project dependency escapes its directory resource source", project_file.GetPath(), dependency_path, source_root_path); } resolved_paths.emplace_back(fs_path_to_string(resolved_path)); } std::ranges::sort(resolved_paths); auto unique_end = std::ranges::unique(resolved_paths).begin(); resolved_paths.erase(unique_end, resolved_paths.end()); return resolved_paths; } static auto RefreshEffekseerDependencySnapshot(const BakingContext& context, const File& project_file, string_view output_path) -> uint64_t { FO_STACK_TRACE_ENTRY(); string project_path = fs_resolve_path(project_file.GetDiskPath()); vector<string> compiler_dependencies; try { compiler_dependencies = GetEffekseerProjectDependencies(project_path, project_file.GetDataSpan()); } catch (const EffekseerCompilerException& ex) { throw ParticleBakerException("Effekseer project dependency scan failed", project_file.GetPath(), ex.what()); } vector<string> dependency_paths = ResolveEffekseerDependencyPaths(project_file, compiler_dependencies); uint64_t dependency_write_time = 0; string dependency_snapshot = BuildEffekseerDependencySnapshot(project_path, project_file.GetSize(), project_file.GetWriteTime(), dependency_paths, dependency_write_time); string cache_path = GetEffekseerDependencyCachePath(context, output_path); if (!cache_path.empty() && !fs_write_file(cache_path, dependency_snapshot)) { throw ParticleBakerException("Failed to refresh Effekseer dependency cache", output_path, cache_path); } if (!context.Settings->BakeOutput.empty()) { string baked_output_path = strex(context.Settings->BakeOutput).combine_path(context.PackName).combine_path(output_path).str(); if (fs_exists(baked_output_path) && !fs_remove_file(baked_output_path)) { throw ParticleBakerException("Failed to invalidate stale Effekseer particle", output_path, baked_output_path); } } return dependency_write_time; } static void ValidateEffekseerRuntimeBinary(string_view path, const_span<uint8_t> file_data) { FO_STACK_TRACE_ENTRY(); InitializeEffekseerMemory(); constexpr size_t magic_size = 4; if (file_data.size() < magic_size) { throw ParticleBakerException("Effekseer compiler produced a truncated particle", path); } string_view actual_magic {ptr<const uint8_t> {file_data.data()}.reinterpret_as<char>().get(), magic_size}; if (actual_magic != "SKFE") { throw ParticleBakerException("Effekseer compiler produced invalid particle magic", path, actual_magic); } if (file_data.size() > numeric_cast<size_t>(std::numeric_limits<int32_t>::max())) { throw ParticleBakerException("Effekseer compiler produced an oversized particle", path, file_data.size()); } Effekseer::SettingRef setting = Effekseer::Setting::Create(); Effekseer::EffectRef effect = Effekseer::Effect::Create(setting, file_data.data(), numeric_cast<int32_t>(file_data.size()), 1.0f, u""); if (!effect) { throw ParticleBakerException("Effekseer core rejected compiled particle", path); } } #endif ParticleBaker::ParticleBaker(shared_ptr<BakingContext> ctx) : BaseBaker(std::move(ctx), NAME) #if FO_SPARK_PARTICLES , _sparkContext {SafeAlloc::MakeUnique<SPK::SPKContext>()} #endif { FO_STACK_TRACE_ENTRY(); #if FO_SPARK_PARTICLES SPK::FO::EnsureSparkParticleObjectsRegistered(*_sparkContext); #endif } ParticleBaker::~ParticleBaker() { FO_STACK_TRACE_ENTRY(); } void ParticleBaker::BakeFiles(const FileCollection& files, string_view target_path) const { FO_STACK_TRACE_ENTRY(); vector<File> spark_files; vector<File> effekseer_files; string target_ext = target_path.empty() ? string {} : strex(target_path).get_file_extension(); if (target_path.empty()) { for (const auto& file_header : files) { string ext = strex(file_header.GetPath()).get_file_extension(); #if FO_SPARK_PARTICLES if (ext == "spk") { throw ParticleBakerException("Authored SPARK particles must use the text .spark format", file_header.GetPath()); } if (ext == "spark") { string output_path = strex(file_header.GetPath()).change_file_extension("spk"); if (!_context->BakeChecker || _context->BakeChecker(output_path, file_header.GetWriteTime())) { spark_files.emplace_back(File::Load(file_header)); } continue; } #endif #if FO_EFFEKSEER_PARTICLES if (ext == "efk") { throw ParticleBakerException("Authored Effekseer particles must use the text .efkproj format", file_header.GetPath()); } if (ext == "efkproj") { string output_path = strex(file_header.GetPath()).change_file_extension("efk"); optional<uint64_t> dependency_write_time = TryGetCachedEffekseerDependencyWriteTime(*_context, file_header, output_path); if (dependency_write_time) { if (!_context->BakeChecker || _context->BakeChecker(output_path, *dependency_write_time)) { effekseer_files.emplace_back(File::Load(file_header)); } } else { File file = File::Load(file_header); uint64_t fresh_dependency_write_time = RefreshEffekseerDependencySnapshot(*_context, file, output_path); if (!_context->BakeChecker || _context->BakeChecker(output_path, fresh_dependency_write_time)) { effekseer_files.emplace_back(std::move(file)); } } } #endif ignore_unused(ext); } } else { #if FO_SPARK_PARTICLES if (target_ext == "spk") { string source_path = strex(target_path).change_file_extension("spark"); if (files.FindFileByPath(target_path)) { throw ParticleBakerException("Authored SPARK particles must use the text .spark format", source_path); } auto file = files.FindFileByPath(source_path); if (file && (!_context->BakeChecker || _context->BakeChecker(target_path, file.GetWriteTime()))) { spark_files.emplace_back(std::move(file)); } } #endif #if FO_EFFEKSEER_PARTICLES if (target_ext == "efk") { string source_path = strex(target_path).change_file_extension("efkproj"); string output_path = strex(source_path).change_file_extension("efk"); if (files.FindFileByPath(output_path)) { throw ParticleBakerException("Authored Effekseer particles must use the text .efkproj format", source_path); } auto file = files.FindFileByPath(source_path); if (file) { optional<uint64_t> cached_dependency_write_time = TryGetCachedEffekseerDependencyWriteTime(*_context, file, output_path); uint64_t dependency_write_time = cached_dependency_write_time ? *cached_dependency_write_time : RefreshEffekseerDependencySnapshot(*_context, file, output_path); if (!_context->BakeChecker || _context->BakeChecker(output_path, dependency_write_time)) { effekseer_files.emplace_back(std::move(file)); } } } #endif ignore_unused(target_ext); } #if FO_SPARK_PARTICLES for (const auto& file : spark_files) { BakeSparkFile(file); } #else ignore_unused(spark_files); #endif #if FO_EFFEKSEER_PARTICLES if (!effekseer_files.empty()) { BakeEffekseerFiles(effekseer_files); } #else ignore_unused(effekseer_files); #endif } #if FO_SPARK_PARTICLES // Bounds simulation tuning: step the effect at a fixed rate for several particle lifetimes so a continuous emitter // reaches steady state and a one-shot burst fully expands, capped so a pathologically long-lived effect cannot // stall the bake. static constexpr float32_t SPARK_BOUNDS_SIM_STEP = 0.05f; static constexpr float32_t SPARK_BOUNDS_LIFETIME_FACTOR = 3.0f; static constexpr float32_t SPARK_BOUNDS_MIN_DURATION = 1.0f; static constexpr size_t SPARK_BOUNDS_MAX_STEPS = 2000; void ParticleBaker::BakeSparkFile(const File& file) const { FO_STACK_TRACE_ENTRY(); string_view source_path = file.GetPath(); string output_path = strex(source_path).change_file_extension("spk"); // Load SPARK XML const_span<uint8_t> file_data = file.GetDataSpan(); auto system = _sparkContext->getIOManager().loadFromBuffer("xml", ptr<const uint8_t> {file_data.data()}.reinterpret_as<char>().get(), numeric_cast<unsigned>(file_data.size())); if (!system) { throw ParticleBakerException("Failed to load SPARK particle XML", source_path); } ValidateSparkTexturePaths(file, system); // Precompute the effect's extent by simulating a deterministic run of a throwaway copy, and bake it as the system // bounds. The runtime then frames an emitting instance from this static measurement instead of computing an AABB // every frame. Positions and the billboard radius are measured separately: the position box is transformed by the // emitter's world placement at runtime, while the quad radius is an absolute world length that must be added in // the view plane and never scaled or rotated with the model. Rendering is not needed to measure either, so no // renderer setup is performed here. { SPK::Ref<SPK::System> simulation = SPK::SPKObject::copy(system); // Measure in emitter-local space: identity transform so the box captures only the effect's own layout, not // an authored world placement. The runtime folds the actual bone/entity transform back in when framing. simulation->getTransform().reset(); simulation->initialize(); float32_t max_lifetime = 0.0f; vector<float32_t> group_quad_radii; group_quad_radii.reserve(simulation->getNbGroups()); for (size_t i = 0; i < simulation->getNbGroups(); i++) { const SPK::Ref<SPK::Group>& group = simulation->getGroup(i); max_lifetime = std::max(max_lifetime, group->getMaxLifeTime()); const SPK::Ref<SPK::Renderer>& renderer = group->getRenderer(); float32_t quad_radius = 0.0f; // Half-extent one particle's quad reaches in this group at unit scale, in world units. A SPARK quad is // sized from the group's graphical radius alone (Oriented3DRenderBehavior sizes the side/up vectors from // it, then multiplies by the renderer scale and the particle's PARAM_SCALE) and the system transform never // touches it, so this is an absolute length. The in-plane angle interpolator can turn the quad to any // orientation, so the corner distance - the half-diagonal - is the tight orientation-independent radius. if (renderer && renderer->isActive() && SPK::FO::IsSparkQuadRenderer(*renderer)) { SPK::FO::SparkQuadRendererData renderer_data = SPK::FO::GetSparkQuadRendererData(*renderer); quad_radius = group->getGraphicalRadius() * std::sqrt(renderer_data.ScaleX * renderer_data.ScaleX + renderer_data.ScaleY * renderer_data.ScaleY); } group_quad_radii.push_back(quad_radius); } float32_t sim_duration = max_lifetime * SPARK_BOUNDS_LIFETIME_FACTOR + SPARK_BOUNDS_MIN_DURATION; SPK::Vector3D bounds_min(std::numeric_limits<float>::max(), std::numeric_limits<float>::max(), std::numeric_limits<float>::max()); SPK::Vector3D bounds_max(-std::numeric_limits<float>::max(), -std::numeric_limits<float>::max(), -std::numeric_limits<float>::max()); float32_t billboard_radius = 0.0f; bool any_particles = false; float32_t elapsed = 0.0f; for (size_t step = 0; step < SPARK_BOUNDS_MAX_STEPS && elapsed < sim_duration; step++) { simulation->updateParticles(SPARK_BOUNDS_SIM_STEP); elapsed += SPARK_BOUNDS_SIM_STEP; for (size_t group_index = 0; group_index < simulation->getNbGroups(); group_index++) { const SPK::Ref<SPK::Group>& group = simulation->getGroup(group_index); float32_t group_quad_radius = group_quad_radii[group_index]; bool scale_enabled = group->isEnabled(SPK::PARAM_SCALE); for (SPK::ConstGroupIterator it(*group); !it.end(); ++it) { // A fully transparent particle draws nothing, so it must reserve no frame space. Effects commonly // reach their largest scale at the end of life, exactly where the authored colour graph has // already faded the particle out. if (it->getColor().a == 0) { continue; } bounds_min.setMin(it->position()); bounds_max.setMax(it->position()); float32_t particle_scale = scale_enabled ? it->getParamNC(SPK::PARAM_SCALE) : 1.0f; billboard_radius = std::max(billboard_radius, group_quad_radius * particle_scale); any_particles = true; } } } // Baked bounds are mandatory: a system that never shows a particle across its full simulated lifetime has no // measurable extent and cannot be framed at runtime, so treat it as broken content rather than baking an // empty box. if (!any_particles) { throw ParticleBakerException("SPARK particle system showed no visible particles while baking its bounds", source_path); } system->setBakedBounds(bounds_min, bounds_max, billboard_radius); } // Save to SPARK binary format ostringstream oss(std::ios::binary); if (!_sparkContext->getIOManager().save("spk", oss, system)) { throw ParticleBakerException("Failed to save SPARK particle binary", source_path); } string str = oss.str(); vector<uint8_t> binary(str.begin(), str.end()); _context->WriteData(output_path, binary); } #endif #if FO_EFFEKSEER_PARTICLES // Bounds simulation tuning: play the compiled effect on a headless CPU manager and union the world-space extent of // its drawn particles across a bounded window. A one-shot effect finishes early; the frame cap bounds a looping effect. static constexpr int32_t EFFEKSEER_BOUNDS_MAX_FRAMES = 600; static constexpr int32_t EFFEKSEER_BOUNDS_SIM_INSTANCES = 4000; static auto ToEffekseerUtf8(const char16_t* value) -> string { FO_STACK_TRACE_ENTRY(); if (value == nullptr) { return {}; } size_t source_length = std::char_traits<char16_t>::length(value); vector<char> result(source_length * 3 + 1); int32_t converted_length = Effekseer::ConvertUtf16ToUtf8(result.data(), numeric_cast<int32_t>(result.size()), value); return string(result.data(), numeric_cast<size_t>(converted_length)); } static auto ToEffekseerUtf16(string_view value) -> vector<char16_t> { FO_STACK_TRACE_ENTRY(); string source {value}; vector<char16_t> result(source.size() + 1); (void)Effekseer::ConvertUtf8ToUtf16(result.data(), numeric_cast<int32_t>(result.size()), source.c_str()); return result; } // Bounds simulation needs model geometry just like the runtime does. Keep the loader confined to the project's // directory resource source: the compiler dependency walk validates the same containment before simulation, and the // loader repeats it at the actual resource boundary so an unexpected model path cannot escape the pack. class EffekseerBoundsModelLoader final : public Effekseer::ModelLoader { public: EffekseerBoundsModelLoader(string source_root_path, string project_path) : _sourceRoot {std::filesystem::path {fs_make_path(fs_resolve_path(source_root_path))}.lexically_normal()}, _projectPath {std::move(project_path)} { FO_STACK_TRACE_ENTRY(); } auto Load(const char16_t* path) -> Effekseer::ModelRef override { FO_STACK_TRACE_ENTRY(); string model_path = ToEffekseerUtf8(path); if (model_path.empty() || model_path.find_first_of("\t\r\n") != string::npos) { throw ParticleBakerException("Effekseer model dependency has an invalid path", _projectPath, model_path); } string normalized_path = strex(model_path).normalize_path_slashes(); bool has_drive_prefix = normalized_path.size() >= 2 && normalized_path[1] == ':' && ((normalized_path[0] >= 'A' && normalized_path[0] <= 'Z') || (normalized_path[0] >= 'a' && normalized_path[0] <= 'z')); std::filesystem::path relative_path {fs_make_path(normalized_path)}; if (normalized_path.starts_with('/') || has_drive_prefix || relative_path.is_absolute()) { throw ParticleBakerException("Effekseer model dependency path must be relative", _projectPath, model_path); } std::filesystem::path resolved_path = (_sourceRoot / relative_path).lexically_normal(); std::filesystem::path source_relative_path = resolved_path.lexically_relative(_sourceRoot); auto first_component = source_relative_path.begin(); if (source_relative_path.empty() || source_relative_path.is_absolute() || (first_component != source_relative_path.end() && *first_component == "..")) { throw ParticleBakerException("Effekseer model dependency escapes its directory resource source", _projectPath, model_path, fs_path_to_string(_sourceRoot)); } // The lexical check rejects ".." traversal. Resolve existing symlinks/junctions as well before opening the // file, otherwise a path which looks internal could redirect the model loader outside the resource source. std::error_code canonical_error; std::filesystem::path canonical_source_root = std::filesystem::weakly_canonical(_sourceRoot, canonical_error); if (canonical_error) { throw ParticleBakerException("Effekseer model resource source could not be resolved", _projectPath, fs_path_to_string(_sourceRoot), canonical_error.message()); } canonical_error.clear(); std::filesystem::path canonical_resolved_path = std::filesystem::weakly_canonical(resolved_path, canonical_error); if (canonical_error) { throw ParticleBakerException("Effekseer model dependency path could not be resolved", _projectPath, model_path, fs_path_to_string(resolved_path), canonical_error.message()); } std::filesystem::path canonical_relative_path = canonical_resolved_path.lexically_relative(canonical_source_root); auto canonical_first_component = canonical_relative_path.begin(); if (canonical_relative_path.empty() || canonical_relative_path.is_absolute() || (canonical_first_component != canonical_relative_path.end() && *canonical_first_component == "..")) { throw ParticleBakerException("Effekseer model dependency resolves outside its directory resource source", _projectPath, model_path, fs_path_to_string(canonical_source_root)); } resolved_path = std::move(canonical_resolved_path); string resolved_path_string = fs_path_to_string(resolved_path); optional<string> data = fs_read_file(resolved_path_string); if (!data) { throw ParticleBakerException("Effekseer model dependency is missing", _projectPath, model_path, resolved_path_string); } if (data->empty() || data->size() > numeric_cast<size_t>(std::numeric_limits<int32_t>::max())) { throw ParticleBakerException("Effekseer model dependency has an unusable size", _projectPath, model_path, data->size()); } const_span<uint8_t> model_data {reinterpret_cast<const uint8_t*>(data->data()), data->size()}; if (optional<string> error = ValidateEffekseerModelPayload(model_data)) { throw ParticleBakerException("Effekseer model dependency is invalid", _projectPath, model_path, *error); } return Effekseer::MakeRefPtr<Effekseer::Model>(model_data.data(), numeric_cast<int32_t>(model_data.size())); } private: std::filesystem::path _sourceRoot; string _projectPath; }; // Accumulates the world-space positions of the particles drawn during the bounds simulation. Kept in our own code // (fed by the collecting renderers below through Effekseer's public renderer interface) so no Effekseer core change is // needed to read the instance transforms. class EffekseerBoundsCollector final { public: void Include(const Effekseer::SIMD::Vec3f& position, float32_t billboard_radius) { // Non-finite instance geometry cannot be drawn - the runtime renderers reject an effect that emits it - so it // must not enter the measurement either, where it would poison the whole baked extent. if (!std::isfinite(position.GetX()) || !std::isfinite(position.GetY()) || !std::isfinite(position.GetZ()) || !std::isfinite(billboard_radius)) { return; } _min.x = std::min(_min.x, position.GetX()); _min.y = std::min(_min.y, position.GetY()); _min.z = std::min(_min.z, position.GetZ()); _max.x = std::max(_max.x, position.GetX()); _max.y = std::max(_max.y, position.GetY()); _max.z = std::max(_max.z, position.GetZ()); _billboardRadius = std::max(_billboardRadius, billboard_radius); _hasBounds = true; } [[nodiscard]] auto HasBounds() const -> bool { return _hasBounds; } [[nodiscard]] auto GetMin() const -> vec3 { return _min; } [[nodiscard]] auto GetMax() const -> vec3 { return _max; } [[nodiscard]] auto GetBillboardRadius() const -> float32_t { return _billboardRadius; } private: vec3 _min {std::numeric_limits<float>::max(), std::numeric_limits<float>::max(), std::numeric_limits<float>::max()}; vec3 _max {-std::numeric_limits<float>::max(), -std::numeric_limits<float>::max(), -std::numeric_limits<float>::max()}; float32_t _billboardRadius {}; bool _hasBounds {}; }; // Half-extent of one drawn instance in its own local space, read from the same instance parameters our renderers build // their vertices from. Every drawable family reports one; a node type with no shape of its own falls through to the // zero overload and contributes positions alone. template<typename TInstanceParameter> static auto GetEffekseerInstanceLocalExtent(const TInstanceParameter& instance) -> float32_t { FO_NO_STACK_TRACE_ENTRY(); ignore_unused(instance); return 0.0f; } static auto GetEffekseerInstanceLocalExtent(const Effekseer::SpriteRenderer::InstanceParameter& instance) -> float32_t { FO_NO_STACK_TRACE_ENTRY(); float32_t extent = 0.0f; for (const Effekseer::SIMD::Vec2f& position : instance.Positions) { extent = std::max(extent, std::hypot(position.GetX(), position.GetY())); } return extent; } static auto GetEffekseerInstanceLocalExtent(const Effekseer::RingRenderer::InstanceParameter& instance) -> float32_t { FO_NO_STACK_TRACE_ENTRY(); float32_t outer = std::hypot(instance.OuterLocation.GetX(), instance.OuterLocation.GetY()); float32_t inner = std::hypot(instance.InnerLocation.GetX(), instance.InnerLocation.GetY()); return std::max(outer, inner); } // A ribbon spreads from its centre line to the two edge offsets. Positions[2] and [3] are read only by the spline path, // which the runtime rejects, and the emitter leaves them uninitialised - so they must stay out of the measurement. static auto GetEffekseerInstanceLocalExtent(const Effekseer::RibbonRenderer::InstanceParameter& instance) -> float32_t { FO_NO_STACK_TRACE_ENTRY(); return std::max(std::abs(instance.Positions[0]), std::abs(instance.Positions[1])); } // A track cross-section is centred on its instance and reaches half its width to either side; which of the three widths // applies depends on where along the trail the instance sits, so the measurement takes the largest of them. static auto GetEffekseerInstanceLocalExtent(const Effekseer::TrackRenderer::InstanceParameter& instance) -> float32_t { FO_NO_STACK_TRACE_ENTRY(); return std::max({std::abs(instance.SizeFor), std::abs(instance.SizeMiddle), std::abs(instance.SizeBack)}) * 0.5f; } // A model node's shape lives in its mesh rather than in the instance, so its extent is the furthest vertex of the // furthest frame - measured once per node, since every instance draws the same mesh. static auto GetEffekseerNodeLocalExtent(const Effekseer::ModelRenderer::NodeParameter& parameter) -> float32_t { FO_NO_STACK_TRACE_ENTRY(); if (parameter.EffectPointer == nullptr || parameter.ModelIndex < 0 || parameter.ModelIndex >= parameter.EffectPointer->GetModelCount()) { return 0.0f; } Effekseer::ModelRef model = parameter.EffectPointer->GetModel(parameter.ModelIndex); if (!model) { return 0.0f; } float32_t extent = 0.0f; for (int32_t frame = 0; frame < model->GetFrameCount(); frame++) { size_t vertex_count = numeric_cast<size_t>(model->GetVertexCount(frame)); if (vertex_count == 0) { continue; } const_span<Effekseer::Model::Vertex> vertices {model->GetVertexes(frame), vertex_count}; for (const Effekseer::Model::Vertex& vertex : vertices) { extent = std::max(extent, std::sqrt(vertex.Position.X * vertex.Position.X + vertex.Position.Y * vertex.Position.Y + vertex.Position.Z * vertex.Position.Z)); } } return extent; } template<typename TNodeParameter> static auto GetEffekseerNodeLocalExtent(const TNodeParameter& parameter) -> float32_t { FO_NO_STACK_TRACE_ENTRY(); ignore_unused(parameter); return 0.0f; } // A minimal renderer that discards geometry and only records each drawn particle's world position (its SRTMatrix43 // translation, which every renderer family exposes) plus the world-space half-extent of its shape. Instantiated for // every family - sprite, ribbon, ring, track, and model - so any effect contributes to the bounds. The body carries no // stack-trace marker because it is an inline template method. template<typename TRenderer> class EffekseerBoundsRenderer final : public TRenderer { public: explicit EffekseerBoundsRenderer(ptr<EffekseerBoundsCollector> collector) : _collector {collector} { } void Rendering(const typename TRenderer::NodeParameter& parameter, const typename TRenderer::InstanceParameter& instance, void* user_data) override { ignore_unused(user_data); // The local extent is expressed in the instance's own space, so stretch it by the largest axis scale of the // instance transform to get an orientation-independent world radius. Effekseer::SIMD::Vec3f scale = instance.SRTMatrix43.GetScale(); float32_t max_axis_scale = std::max({std::abs(scale.GetX()), std::abs(scale.GetY()), std::abs(scale.GetZ())}); float32_t local_extent = std::max(GetEffekseerInstanceLocalExtent(instance), GetEffekseerNodeLocalExtent(parameter)); _collector->Include(instance.SRTMatrix43.GetTranslation(), local_extent * max_axis_scale); } private: ptr<EffekseerBoundsCollector> _collector; }; // Precompute an Effekseer effect's maximal world-space extent by simulating it and collecting the particle positions // through our own renderers, so the runtime frames an emitting instance from a static box (like the SPARK baked // bounds) instead of measuring live particles every frame. static void SimulateEffekseerBounds(string_view source_path, string_view source_root_path, const_span<uint8_t> binary, vec3& out_min, vec3& out_max, float32_t& out_billboard_radius) { FO_STACK_TRACE_ENTRY(); // The collector outlives the manager (declared first, destroyed last) so the renderers' borrow stays valid for the // manager's whole lifetime. EffekseerBoundsCollector collector; Effekseer::ManagerRef manager = Effekseer::Manager::Create(EFFEKSEER_BOUNDS_SIM_INSTANCES); if (!manager) { throw ParticleBakerException("Failed to create an Effekseer manager for bounds simulation", source_path); } Effekseer::SettingRef setting = Effekseer::Setting::Create(); setting->SetCoordinateSystem(Effekseer::CoordinateSystem::RH); setting->SetModelLoader(Effekseer::MakeRefPtr<EffekseerBoundsModelLoader>(string {source_root_path}, string {source_path})); manager->SetSetting(setting); ptr<EffekseerBoundsCollector> collector_ptr {&collector}; manager->SetSpriteRenderer(Effekseer::MakeRefPtr<EffekseerBoundsRenderer<Effekseer::SpriteRenderer>>(collector_ptr)); manager->SetRibbonRenderer(Effekseer::MakeRefPtr<EffekseerBoundsRenderer<Effekseer::RibbonRenderer>>(collector_ptr)); manager->SetRingRenderer(Effekseer::MakeRefPtr<EffekseerBoundsRenderer<Effekseer::RingRenderer>>(collector_ptr)); manager->SetTrackRenderer(Effekseer::MakeRefPtr<EffekseerBoundsRenderer<Effekseer::TrackRenderer>>(collector_ptr)); manager->SetModelRenderer(Effekseer::MakeRefPtr<EffekseerBoundsRenderer<Effekseer::ModelRenderer>>(collector_ptr)); string material_path = strex(source_path).extract_dir().format_path().str(); vector<char16_t> material_path_utf16 = ToEffekseerUtf16(material_path); Effekseer::EffectRef effect = Effekseer::Effect::Create(manager, binary.data(), numeric_cast<int32_t>(binary.size()), 1.0f, material_path_utf16.data()); if (!effect) { throw ParticleBakerException("Effekseer bounds simulation could not load the compiled effect", source_path); } Effekseer::Handle handle = manager->Play(effect, 0.0f, 0.0f, 0.0f); if (handle < 0) { throw ParticleBakerException("Effekseer bounds simulation could not play the effect", source_path); } // Draw every frame with culling disabled so the collecting renderers receive every live particle regardless of // camera; the collector reads the camera-independent world transform, so the camera only needs to be finite. Effekseer::Manager::DrawParameter draw_parameter; draw_parameter.CameraCullingMask = manager->GetCameraCullingMaskToShowAllEffects(); draw_parameter.CameraPosition = {0.0f, 0.0f, 1.0f}; draw_parameter.CameraFrontDirection = {0.0f, 0.0f, -1.0f}; for (int32_t frame = 0; frame < EFFEKSEER_BOUNDS_MAX_FRAMES && manager->Exists(handle); frame++) { manager->BeginUpdate(); manager->UpdateHandle(handle, 1.0f); manager->EndUpdate(); if (manager->Exists(handle)) { manager->DrawHandle(handle, draw_parameter); } } // Bounds are mandatory, so a box is always produced. An effect that draws nothing across the whole window (a // logic-only, GPU-particle, or otherwise non-renderable coverage sample) has no measurable extent, so it gets a // degenerate box at the origin and reserves no frame space at runtime. if (collector.HasBounds()) { out_min = collector.GetMin(); out_max = collector.GetMax(); out_billboard_radius = collector.GetBillboardRadius(); } else { out_min = vec3 {}; out_max = vec3 {}; out_billboard_radius = 0.0f; } } void ParticleBaker::BakeEffekseerFiles(const_span<File> files) const { FO_STACK_TRACE_ENTRY(); FO_VERIFY_AND_THROW(!files.empty(), "Effekseer compiler received an empty project list"); for (const File& file : files) { if (!file.GetDataSource()->IsDiskDir()) { throw ParticleBakerException("Effekseer text projects can only be compiled from a directory resource source", file.GetPath(), file.GetDataSource()->GetPackName()); } string project_path = fs_resolve_path(file.GetDiskPath()); string output_path = strex(file.GetPath()).change_file_extension("efk"); EffekseerCompilerOutput compiled; try { compiled = CompileEffekseerProject(project_path, file.GetDataSpan()); } catch (const EffekseerCompilerException& ex) { throw ParticleBakerException("Effekseer project compiler failed", file.GetPath(), ex.what()); } ValidateEffekseerRuntimeBinary(output_path, compiled.Binary); vector<string> dependency_paths = ResolveEffekseerDependencyPaths(file, compiled.Dependencies); // Precompute the bounds from the pure Effekseer payload, then append them as a trailer so the runtime frames // the effect from a static box. Validation above ran on the untrailered binary. vec3 bounds_min; vec3 bounds_max; float32_t billboard_radius; SimulateEffekseerBounds(output_path, file.GetDataSource()->GetPackName(), compiled.Binary, bounds_min, bounds_max, billboard_radius); AppendEffekseerBoundsTrailer(compiled.Binary, bounds_min, bounds_max, billboard_radius); uint64_t dependency_write_time = 0; string dependency_snapshot = BuildEffekseerDependencySnapshot(project_path, file.GetSize(), file.GetWriteTime(), dependency_paths, dependency_write_time); _context->WriteData(output_path, compiled.Binary); string cache_path = GetEffekseerDependencyCachePath(*_context, output_path); if (!cache_path.empty() && !fs_write_file(cache_path, dependency_snapshot)) { throw ParticleBakerException("Failed to write Effekseer dependency cache", output_path, cache_path); } } } #endif FO_END_NAMESPACE