M2.5: ShaderMaterial + texture resolution + fog; multi-skeleton verified
- m2_material: Metin2-style ShaderMaterial. blend_mix shader (opaque /
alpha-blend / alpha-test-cutout via `mode` uniform) + blend_add shader
(additive glow). modulate(tex, COORD) * COLOR, diffuse_lambert.
- Metin2Model: material_mode prop ("metin2" ShaderMaterial | "standard");
_resolve_texture does filename-convention + case-insensitive dir scan of
*.dds (face/hair keyword slots, stem match, fallback); set_surface_texture
override; guess_blend heuristic (hair->cutout, cape->alpha, effect->add).
Texture cache. Fixed opaque path to NOT alpha-discard (was hiding bodies).
- harness: AABB-based camera framing, engine depth fog (MTGODOT_FOG=1),
MTGODOT_ANIM_T fixed-pose capture (needs explicit anim reload first),
MTGODOT_AUTOSHOT batch screenshot.
Verified: warrior 5/5 surfaces textured under the shader (t=9 dance pose,
lit, fogged). assassin 17/17 textured via dir scan. shaman_lord (gr2 v7)
renders. Screenshots test/golden/m25-*.png, m2c-t*.png.
Gap (M2.5 gate not closed): libgr2 POD API exposes no material/texture-name/
blend data, so multi-slot -> texture/blend mapping is heuristic only. Needs
a libgr2 material API (gr2_material.cpp walking the type tree) — same gap as
xrender-poc M1 T5. Documented in docs/GODOT-POC-PLAN.md §M2.5 T2.5.6.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WaHYEY9rwLWt21PULiYjeJ
@@ -19,16 +19,22 @@
|
|||||||
|
|
||||||
## 现状
|
## 现状
|
||||||
|
|
||||||
- [~] **M0'** — 脚手架(macOS)。本机已验证:
|
- [x] **M0'** — 脚手架。GDExtension 加载 + `Metin2Model` 注册 + `libgr2` 链接可调用
|
||||||
- **T0.1** GDExtension 加载、`Metin2Model` 注册成功
|
+ Godot 4.7 工程经 Metal Forward+ 出画。T0.4 实际 `export` 待装 4.7.1 导出模板。
|
||||||
(`[mtgodot] Metin2Model registered OK`)。
|
- [x] **M1** — 静态渲染。`gr2` → `Skeleton3D` + `ArrayMesh` + `Skin` + 材质。
|
||||||
- **T0.2** `libgr2` 链接进扩展且可调用:`probe_gr2(warrior_cheongrin.gr2)
|
`warrior_cheongrin`(75 骨 / 5 表面 / 3324 顶点)正立、朝 +Z、贴图正确
|
||||||
→ gr2 OK: format_version=6 sections=8`。
|
(`test/golden/m1-bindpose.png`)。多骨架:`warrior_lord`(v6,74)、`assassin`(v6,95,17 表面)、
|
||||||
- **T0.3** Godot 4.7 工程(orbit 相机 + `DirectionalLight3D` 阴影 +
|
`shaman_lord`(**v7**,93) 均正确加载渲染。
|
||||||
`WorldEnvironment` procedural sky + 占位 cube)经 Metal Forward+ 出画
|
- [x] **M2** — 骨骼动画。`Metin2AnimPlayer` `_process` 里 `gr2::sample_pose`(跨文件)
|
||||||
(`test/golden/m0-scaffold.png`)。
|
→ `Skeleton3D.set_bone_global_pose`,Godot GPU 蒙皮。`dance01`(28.3s / 74 track)
|
||||||
- **T0.4** `export_presets.cfg`(macOS)就位;实际 `export` 待安装 4.7.1
|
播放,`selfcheck` 25 采样 0 NaN,t=2/9/16/23 姿势各异(`test/golden/m2c-*.png`)。
|
||||||
导出模板(`godot --headless --install-export-templates`)。
|
- [~] **M2.5** — 材质与观感保真。`ShaderMaterial`(modulate tex×COLOR×light、
|
||||||
|
opaque/alpha/alpha-test/add 四模式)+ 目录扫描贴图解析 + 方向光阴影 + procedural sky
|
||||||
|
+ 引擎雾(`test/golden/m25-*.png`)。**门禁未闭**:多材质槽 → 贴图/混合的精确映射需
|
||||||
|
给 libgr2 加材质 API(见 `docs/GODOT-POC-PLAN.md` §M2.5 T2.5.6 差距清单)。
|
||||||
|
- [ ] **中期评审** — 待 M2.5 门禁 + 对拍报告
|
||||||
|
|
||||||
|
对拍(与 `xrender-poc` bgfx demo / oracle 逐帧)尚未接线,是中期评审前的下一步。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -294,5 +294,17 @@ mtgodot-poc/
|
|||||||
## 选型变更记录
|
## 选型变更记录
|
||||||
|
|
||||||
- 2026-08-29 · 范围收敛为 **Phase 1 = macOS only**;新增 **M2.5 材质与观感保真** 作为 Phase 1 核心门禁;动画 A/B 决策推迟到 Phase 2;Godot 版本钉死 4.7.1 / godot-cpp 4.7。
|
- 2026-08-29 · 范围收敛为 **Phase 1 = macOS only**;新增 **M2.5 材质与观感保真** 作为 Phase 1 核心门禁;动画 A/B 决策推迟到 Phase 2;Godot 版本钉死 4.7.1 / godot-cpp 4.7。
|
||||||
- *(待 M1 T1.3 填写:贴图走 `.dds` 原生导入 还是运行时 `dds_loader`)*
|
- 2026-08-29 · **M1 T1.3**:贴图走 **运行时 `dds_loader`**(`src/dxt.cpp`,端口自 xrender-poc)。理由:Godot 4 的 `Image.load()` 不支持 `.dds`,且资产从 `res://` 外的绝对路径加载(`.gr2` 走 `.gdignore` 同理)。DXT1/3/5 + BGRA8,level 0 + `generate_mipmaps()`。
|
||||||
- *(待 M2.5 T2.5.6 填写:材质无法 1:1 还原项清单)*
|
- 2026-08-29 · **动画方案**:确认走 **B 方案** —— `_process` 里 `gr2::sample_pose` → `Skeleton3D.set_bone_global_pose(i, gr2_to_godot(world[i]))`,Godot GPU 蒙皮 `skin_matrix = global_pose(i) * bind_pose(i)` 恒等于 gr2 deformer 矩阵(`gr2_bridge.h` 有推导)。A 方案(烘 `Animation`)仍推迟到 Phase 2 按多角色 CPU 采样决定。
|
||||||
|
- 2026-08-29 · **坐标/单位**:`gr2_to_godot()` = gr2 行主序 Mat4 的 4×4 转置 → Godot `Transform3D`;Z-up cm → Y-up m(`rotate(-90°,X)` + `scale(0.01)`)放在 `Metin2Model` 节点自身 transform(`make_conv`)。warrior/warrior_lord/assassin/shaman_lord 实测 `flip_z=false`、`flip_winding=false`、`unit_scale=0.01` 正立、朝 +Z、无镜像。
|
||||||
|
|
||||||
|
### M2.5 T2.5.6 — 材质无法 1:1 还原 / 待办项清单
|
||||||
|
|
||||||
|
| 项 | 现状 | 影响 | 出路 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **libgr2 POD API 不暴露材质数据** —— 只有 `material_count`/`texture_count`,无材质名、无贴图文件名、无 texture-stage op、无 `D3DBLEND_*`/alpha-test 标志 | 贴图靠**文件名约定 + 目录大小写不敏感扫描**(`_resolve_texture`);blend 靠**表面名关键字启发式**(`guess_blend`:hair→alpha-test、cape/skirt→alpha、effect/glow→add,其余 opaque) | warrior 5/5、assassin 17/17、shaman 1/1 表面命中贴图,但**多材质槽 → 具体贴图/混合的精确映射做不到**;换皮/多贴图变体、精确 alpha-test 阈值、MODULATE2X/ADDSIGNED 等 stage op 无法复现 | **需要给 libgr2 加材质 API**(新文件 `gr2_material.cpp`,走类型树取 `Materials[].Name` + `Maps[].Texture.FromFileName` + shader/blend hint)。这也正是 xrender-poc M1 T5「多 stage 材质对拍待 oracle」的同一缺口,两条 route 共用修法。 |
|
||||||
|
| **顶点色** | `gr2::Vertex` POD 无 color 字段;shader 已留 `COLOR` 通道,默认白 | Metin2 PC 模型基本不用顶点色,影响小;特效/地形会用 | libgr2 若暴露则 `build_mesh` 补 `ARRAY_COLOR` |
|
||||||
|
| **`TriGroup.material_index` 未接线** | libgr2 已解析每个三角组的 material_index,但 `build_mesh` 目前一个 gr2 mesh = 一个 surface(未按 material_index 再拆分) | 单 mesh 内多材质的模型会用同一张贴图 | `build_mesh` 按 `tri_groups` 的 material_index 分 surface(有材质 API 后一起做) |
|
||||||
|
| **ShaderMaterial 静态 `Shader` 退出泄漏** | `shader_for()` 用 function-static `Ref<Shader>`,Godot 退出时报 "1 shader never freed" | 仅退出时一条 warning,无功能影响 | 改成每材质自持 Shader,或注册 cleanup |
|
||||||
|
| **alpha 排序 corner case** | 半透明/additive 用 `render_priority` 粗排,未做逐三角/OIT | 多层半透明部件可能穿插 | 记录,不追求 100%(Phase 2 M2.5 打磨) |
|
||||||
|
| **法线未做逆转置** | 走 Godot GPU 蒙皮,其法线处理与 Granny runtime 的「上 3×3 不逆转置」近似不完全一致 | 非均匀缩放骨骼下法线略有差异;warrior 类资产无明显问题 | Phase 2 逐帧对拍时量化 |
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ add_library(mtgodot SHARED
|
|||||||
src/metin2_model.cpp
|
src/metin2_model.cpp
|
||||||
src/metin2_anim.cpp
|
src/metin2_anim.cpp
|
||||||
src/gr2_bridge.cpp
|
src/gr2_bridge.cpp
|
||||||
|
src/m2_material.cpp
|
||||||
src/dxt.cpp
|
src/dxt.cpp
|
||||||
)
|
)
|
||||||
target_compile_features(mtgodot PRIVATE cxx_std_20)
|
target_compile_features(mtgodot PRIVATE cxx_std_20)
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
#include "m2_material.h"
|
||||||
|
|
||||||
|
#include <godot_cpp/classes/shader.hpp>
|
||||||
|
#include <godot_cpp/classes/shader_material.hpp>
|
||||||
|
#include <godot_cpp/classes/texture2d.hpp>
|
||||||
|
#include <godot_cpp/variant/color.hpp>
|
||||||
|
|
||||||
|
using namespace godot;
|
||||||
|
|
||||||
|
namespace mtgodot {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
// blend_mix shader: covers opaque (mode 0, alpha-scissor) and alpha (mode 1).
|
||||||
|
const char *SRC_MIX = R"(shader_type spatial;
|
||||||
|
render_mode blend_mix, depth_draw_opaque, cull_disabled, diffuse_lambert, specular_disabled;
|
||||||
|
|
||||||
|
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
|
||||||
|
uniform bool use_texture = true;
|
||||||
|
uniform vec4 modulate : source_color = vec4(1.0);
|
||||||
|
uniform float alpha_scissor : hint_range(0.0, 1.0) = 0.5;
|
||||||
|
uniform int mode = 0; // 0 opaque | 1 alpha-blend | 2 alpha-test (cutout)
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
|
||||||
|
c *= modulate * COLOR;
|
||||||
|
if (mode == 2 && c.a < alpha_scissor) {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
ALBEDO = c.rgb;
|
||||||
|
ALPHA = (mode == 1) ? c.a : 1.0;
|
||||||
|
}
|
||||||
|
)";
|
||||||
|
|
||||||
|
// blend_add shader: additive glow (effects, enchant overlays).
|
||||||
|
const char *SRC_ADD = R"(shader_type spatial;
|
||||||
|
render_mode blend_add, depth_draw_opaque, depth_test_disabled, cull_disabled, unshaded;
|
||||||
|
|
||||||
|
uniform sampler2D albedo_tex : source_color, filter_linear_mipmap, repeat_enable;
|
||||||
|
uniform bool use_texture = true;
|
||||||
|
uniform vec4 modulate : source_color = vec4(1.0);
|
||||||
|
|
||||||
|
void fragment() {
|
||||||
|
vec4 c = use_texture ? texture(albedo_tex, UV) : vec4(1.0);
|
||||||
|
c *= modulate * COLOR;
|
||||||
|
ALBEDO = c.rgb * c.a;
|
||||||
|
ALPHA = 1.0;
|
||||||
|
}
|
||||||
|
)";
|
||||||
|
|
||||||
|
Ref<Shader> shader_for(bool additive) {
|
||||||
|
static Ref<Shader> s_mix;
|
||||||
|
static Ref<Shader> s_add;
|
||||||
|
Ref<Shader> &slot = additive ? s_add : s_mix;
|
||||||
|
if (slot.is_null()) {
|
||||||
|
slot.instantiate();
|
||||||
|
slot->set_code(additive ? SRC_ADD : SRC_MIX);
|
||||||
|
}
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
Ref<ShaderMaterial> make_material(const MaterialDesc &d) {
|
||||||
|
Ref<ShaderMaterial> m;
|
||||||
|
m.instantiate();
|
||||||
|
|
||||||
|
const bool additive = (d.blend == BlendMode::Add);
|
||||||
|
m->set_shader(shader_for(additive));
|
||||||
|
|
||||||
|
const bool has_tex = d.albedo.is_valid();
|
||||||
|
m->set_shader_parameter("use_texture", has_tex);
|
||||||
|
if (has_tex) {
|
||||||
|
m->set_shader_parameter("albedo_tex", d.albedo);
|
||||||
|
}
|
||||||
|
m->set_shader_parameter("modulate", has_tex ? Color(1, 1, 1, 1) : Color(0.8, 0.8, 0.82, 1));
|
||||||
|
|
||||||
|
if (!additive) {
|
||||||
|
m->set_shader_parameter("alpha_scissor", d.alpha_scissor);
|
||||||
|
int mode = 0;
|
||||||
|
if (d.blend == BlendMode::Alpha) {
|
||||||
|
mode = 1;
|
||||||
|
} else if (d.blend == BlendMode::AlphaTest) {
|
||||||
|
mode = 2;
|
||||||
|
}
|
||||||
|
m->set_shader_parameter("mode", mode);
|
||||||
|
}
|
||||||
|
if (d.blend == BlendMode::Alpha) {
|
||||||
|
m->set_render_priority(1);
|
||||||
|
} else if (additive) {
|
||||||
|
m->set_render_priority(2);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace mtgodot
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#pragma once
|
||||||
|
// m2_material — Metin2-style forward material as a Godot ShaderMaterial.
|
||||||
|
//
|
||||||
|
// M2.5 infrastructure. The gr2 fixed-function texture-stage / blend data is not
|
||||||
|
// yet exposed by libgr2's POD API (see docs/GODOT-POC-PLAN.md §M2.5 gap list),
|
||||||
|
// so callers pick the blend mode heuristically for now. The shader itself
|
||||||
|
// already covers the common Metin2 cases: modulate(tex, vertex_color) + light,
|
||||||
|
// alpha-test, alpha-blend, additive.
|
||||||
|
|
||||||
|
#include <godot_cpp/classes/ref.hpp>
|
||||||
|
|
||||||
|
namespace godot {
|
||||||
|
class ShaderMaterial;
|
||||||
|
class Texture2D;
|
||||||
|
} // namespace godot
|
||||||
|
|
||||||
|
namespace mtgodot {
|
||||||
|
|
||||||
|
enum class BlendMode {
|
||||||
|
Opaque, // blend_mix, ALPHA=1, no discard
|
||||||
|
Alpha, // blend_mix, sorted, uses texture alpha
|
||||||
|
AlphaTest, // blend_mix, discard below alpha_scissor (cutout)
|
||||||
|
Add, // blend_add (glow / effects)
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MaterialDesc {
|
||||||
|
godot::Ref<godot::Texture2D> albedo; // null -> flat white
|
||||||
|
BlendMode blend = BlendMode::Opaque;
|
||||||
|
float alpha_scissor = 0.5f;
|
||||||
|
bool two_sided = true; // winding not yet verified per-model
|
||||||
|
};
|
||||||
|
|
||||||
|
godot::Ref<godot::ShaderMaterial> make_material(const MaterialDesc &d);
|
||||||
|
|
||||||
|
} // namespace mtgodot
|
||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
#include "dxt.h"
|
#include "dxt.h"
|
||||||
#include "gr2_bridge.h"
|
#include "gr2_bridge.h"
|
||||||
|
#include "m2_material.h"
|
||||||
|
|
||||||
#include <godot_cpp/classes/array_mesh.hpp>
|
#include <godot_cpp/classes/array_mesh.hpp>
|
||||||
#include <godot_cpp/classes/base_material3d.hpp>
|
#include <godot_cpp/classes/base_material3d.hpp>
|
||||||
|
#include <godot_cpp/classes/dir_access.hpp>
|
||||||
#include <godot_cpp/classes/file_access.hpp>
|
#include <godot_cpp/classes/file_access.hpp>
|
||||||
#include <godot_cpp/classes/image.hpp>
|
#include <godot_cpp/classes/image.hpp>
|
||||||
#include <godot_cpp/classes/image_texture.hpp>
|
#include <godot_cpp/classes/image_texture.hpp>
|
||||||
#include <godot_cpp/classes/mesh_instance3d.hpp>
|
#include <godot_cpp/classes/mesh_instance3d.hpp>
|
||||||
|
#include <godot_cpp/classes/shader_material.hpp>
|
||||||
#include <godot_cpp/classes/skeleton3d.hpp>
|
#include <godot_cpp/classes/skeleton3d.hpp>
|
||||||
#include <godot_cpp/classes/skin.hpp>
|
#include <godot_cpp/classes/skin.hpp>
|
||||||
#include <godot_cpp/classes/standard_material3d.hpp>
|
#include <godot_cpp/classes/standard_material3d.hpp>
|
||||||
@@ -38,6 +41,9 @@ void Metin2Model::_bind_methods() {
|
|||||||
ClassDB::bind_method(D_METHOD("get_flip_z"), &Metin2Model::get_flip_z);
|
ClassDB::bind_method(D_METHOD("get_flip_z"), &Metin2Model::get_flip_z);
|
||||||
ClassDB::bind_method(D_METHOD("set_flip_winding", "v"), &Metin2Model::set_flip_winding);
|
ClassDB::bind_method(D_METHOD("set_flip_winding", "v"), &Metin2Model::set_flip_winding);
|
||||||
ClassDB::bind_method(D_METHOD("get_flip_winding"), &Metin2Model::get_flip_winding);
|
ClassDB::bind_method(D_METHOD("get_flip_winding"), &Metin2Model::get_flip_winding);
|
||||||
|
ClassDB::bind_method(D_METHOD("set_material_mode", "m"), &Metin2Model::set_material_mode);
|
||||||
|
ClassDB::bind_method(D_METHOD("get_material_mode"), &Metin2Model::get_material_mode);
|
||||||
|
ClassDB::bind_method(D_METHOD("set_surface_texture", "surface", "path"), &Metin2Model::set_surface_texture);
|
||||||
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
|
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
|
||||||
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
|
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
|
||||||
ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2);
|
ClassDB::bind_method(D_METHOD("probe_gr2", "path"), &Metin2Model::probe_gr2);
|
||||||
@@ -50,6 +56,28 @@ void Metin2Model::_bind_methods() {
|
|||||||
"set_unit_scale", "get_unit_scale");
|
"set_unit_scale", "get_unit_scale");
|
||||||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_z"), "set_flip_z", "get_flip_z");
|
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_z"), "set_flip_z", "get_flip_z");
|
||||||
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_winding"), "set_flip_winding", "get_flip_winding");
|
ADD_PROPERTY(PropertyInfo(Variant::BOOL, "flip_winding"), "set_flip_winding", "get_flip_winding");
|
||||||
|
ADD_PROPERTY(PropertyInfo(Variant::STRING, "material_mode", PROPERTY_HINT_ENUM, "metin2,standard"),
|
||||||
|
"set_material_mode", "get_material_mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
void Metin2Model::set_material_mode(const String &m) {
|
||||||
|
material_mode = m;
|
||||||
|
if (is_inside_tree()) {
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void Metin2Model::set_surface_texture(int surface, const String &path) {
|
||||||
|
if (surface < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
while (surface_tex_override.size() <= surface) {
|
||||||
|
surface_tex_override.push_back(String());
|
||||||
|
}
|
||||||
|
surface_tex_override[surface] = path;
|
||||||
|
if (is_inside_tree()) {
|
||||||
|
_apply_materials();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Metin2Model::set_gr2_path(const String &p) {
|
void Metin2Model::set_gr2_path(const String &p) {
|
||||||
@@ -173,58 +201,165 @@ String Metin2Model::_guess_texture_dir() const {
|
|||||||
return gr2_path.get_base_dir();
|
return gr2_path.get_base_dir();
|
||||||
}
|
}
|
||||||
|
|
||||||
void Metin2Model::_apply_materials() {
|
Ref<godot::ImageTexture> Metin2Model::_load_dds(const String &path) {
|
||||||
if (!mi || !file || !*file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const gr2::FileInfo &fi = (*file)->file_info();
|
|
||||||
const String dir = _guess_texture_dir();
|
|
||||||
const String stem = gr2_path.get_file().get_basename();
|
|
||||||
|
|
||||||
// Cache: texture path -> ImageTexture.
|
|
||||||
auto load_tex = [&](const String &path) -> Ref<ImageTexture> {
|
|
||||||
Ref<ImageTexture> t;
|
Ref<ImageTexture> t;
|
||||||
if (!FileAccess::file_exists(path)) {
|
if (tex_cache.has(path)) {
|
||||||
return t;
|
return tex_cache[path];
|
||||||
}
|
}
|
||||||
|
if (FileAccess::file_exists(path)) {
|
||||||
mtgodot::Image dds = mtgodot::load_dds_path(path.utf8().get_data());
|
mtgodot::Image dds = mtgodot::load_dds_path(path.utf8().get_data());
|
||||||
if (!dds.ok()) {
|
if (dds.ok()) {
|
||||||
return t;
|
|
||||||
}
|
|
||||||
PackedByteArray bytes;
|
PackedByteArray bytes;
|
||||||
bytes.resize((int)dds.rgba.size());
|
bytes.resize((int)dds.rgba.size());
|
||||||
memcpy(bytes.ptrw(), dds.rgba.data(), dds.rgba.size());
|
memcpy(bytes.ptrw(), dds.rgba.data(), dds.rgba.size());
|
||||||
Ref<godot::Image> img = godot::Image::create_from_data(
|
Ref<godot::Image> img = godot::Image::create_from_data(
|
||||||
dds.w, dds.h, false, godot::Image::FORMAT_RGBA8, bytes);
|
dds.w, dds.h, false, godot::Image::FORMAT_RGBA8, bytes);
|
||||||
if (img.is_null()) {
|
if (img.is_valid()) {
|
||||||
return t;
|
|
||||||
}
|
|
||||||
img->generate_mipmaps();
|
img->generate_mipmaps();
|
||||||
t = ImageTexture::create_from_image(img);
|
t = ImageTexture::create_from_image(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tex_cache[path] = t;
|
||||||
return t;
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort texture pick for a surface. libgr2's POD API gives no material or
|
||||||
|
// texture names yet (see GODOT-POC-PLAN.md §M2.5 gap list), so this is filename
|
||||||
|
// convention + a case-insensitive directory scan.
|
||||||
|
Ref<godot::ImageTexture> Metin2Model::_resolve_texture(const String &dir, const String &stem,
|
||||||
|
const String &surface_name, const PackedStringArray &dds_files) {
|
||||||
|
String sn = surface_name.to_lower();
|
||||||
|
|
||||||
|
auto scan = [&](const String &needle) -> Ref<ImageTexture> {
|
||||||
|
for (int i = 0; i < dds_files.size(); ++i) {
|
||||||
|
if (dds_files[i].to_lower().find(needle) != -1) {
|
||||||
|
Ref<ImageTexture> t = _load_dds(dir.path_join(dds_files[i]));
|
||||||
|
if (t.is_valid()) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ref<ImageTexture>();
|
||||||
};
|
};
|
||||||
|
|
||||||
Ref<ImageTexture> tex_main = load_tex(dir.path_join(stem + String(".dds")));
|
// 1. surface-name keyword slots
|
||||||
Ref<ImageTexture> tex_face = load_tex(dir.path_join(stem + String("_face.dds")));
|
if (sn.find("face") != -1) {
|
||||||
if (tex_face.is_null()) {
|
Ref<ImageTexture> t = _load_dds(dir.path_join(stem + String("_face.dds")));
|
||||||
tex_face = load_tex(dir.path_join(String("warrior_face.dds")));
|
if (t.is_null()) {
|
||||||
|
t = scan("face");
|
||||||
|
}
|
||||||
|
if (t.is_valid()) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sn.find("hair") != -1) {
|
||||||
|
Ref<ImageTexture> t = _load_dds(dir.path_join(stem + String("_hair.dds")));
|
||||||
|
if (t.is_null()) {
|
||||||
|
t = scan("hair");
|
||||||
|
}
|
||||||
|
if (t.is_valid()) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. surface name used verbatim as a texture basename
|
||||||
|
if (!sn.is_empty()) {
|
||||||
|
Ref<ImageTexture> t = _load_dds(dir.path_join(surface_name + String(".dds")));
|
||||||
|
if (t.is_valid()) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. exact stem
|
||||||
|
Ref<ImageTexture> t = _load_dds(dir.path_join(stem + String(".dds")));
|
||||||
|
if (t.is_valid()) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
// 4. any dds whose name contains the stem, then any non-face/hair dds
|
||||||
|
t = scan(stem);
|
||||||
|
if (t.is_valid()) {
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < dds_files.size(); ++i) {
|
||||||
|
String f = dds_files[i].to_lower();
|
||||||
|
if (f.find("face") == -1 && f.find("hair") == -1 && f.find("_lod") == -1) {
|
||||||
|
Ref<ImageTexture> any = _load_dds(dir.path_join(dds_files[i]));
|
||||||
|
if (any.is_valid()) {
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Ref<ImageTexture>();
|
||||||
|
}
|
||||||
|
|
||||||
|
static mtgodot::BlendMode guess_blend(const String &surface_name) {
|
||||||
|
String sn = surface_name.to_lower();
|
||||||
|
if (sn.find("effect") != -1 || sn.find("glow") != -1 || sn.find("aura") != -1) {
|
||||||
|
return mtgodot::BlendMode::Add;
|
||||||
|
}
|
||||||
|
if (sn.find("hair") != -1) {
|
||||||
|
return mtgodot::BlendMode::AlphaTest; // Metin2 hair = alpha-test cutout
|
||||||
|
}
|
||||||
|
if (sn.find("cloak") != -1 || sn.find("cape") != -1 ||
|
||||||
|
sn.find("skirt") != -1 || sn.find("ribbon") != -1) {
|
||||||
|
return mtgodot::BlendMode::Alpha;
|
||||||
|
}
|
||||||
|
return mtgodot::BlendMode::Opaque;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Metin2Model::_apply_materials() {
|
||||||
|
if (!mi || !file || !*file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const String dir = _guess_texture_dir();
|
||||||
|
const String stem = gr2_path.get_file().get_basename();
|
||||||
|
|
||||||
|
PackedStringArray dds_files;
|
||||||
|
{
|
||||||
|
Ref<DirAccess> da = DirAccess::open(dir);
|
||||||
|
if (da.is_valid()) {
|
||||||
|
da->list_dir_begin();
|
||||||
|
for (String f = da->get_next(); !f.is_empty(); f = da->get_next()) {
|
||||||
|
if (!da->current_is_dir() && f.to_lower().ends_with(".dds")) {
|
||||||
|
dds_files.push_back(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
da->list_dir_end();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ref<ArrayMesh> mesh = mi->get_mesh();
|
Ref<ArrayMesh> mesh = mi->get_mesh();
|
||||||
|
int textured = 0;
|
||||||
for (int s = 0; s < mesh->get_surface_count(); ++s) {
|
for (int s = 0; s < mesh->get_surface_count(); ++s) {
|
||||||
String sname = mesh->surface_get_name(s).to_lower();
|
const String sname = mesh->surface_get_name(s);
|
||||||
Ref<ImageTexture> tex = (sname.find("face") != -1 && tex_face.is_valid()) ? tex_face : tex_main;
|
Ref<ImageTexture> tex;
|
||||||
|
if (s < surface_tex_override.size() && !String(surface_tex_override[s]).is_empty()) {
|
||||||
|
tex = _load_dds(surface_tex_override[s]);
|
||||||
|
} else {
|
||||||
|
tex = _resolve_texture(dir, stem, sname, dds_files);
|
||||||
|
}
|
||||||
|
if (tex.is_valid()) {
|
||||||
|
++textured;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (material_mode == String("standard")) {
|
||||||
Ref<StandardMaterial3D> mat;
|
Ref<StandardMaterial3D> mat;
|
||||||
mat.instantiate();
|
mat.instantiate();
|
||||||
mat->set_cull_mode(BaseMaterial3D::CULL_DISABLED); // M1: winding unverified
|
mat->set_cull_mode(BaseMaterial3D::CULL_DISABLED);
|
||||||
if (tex.is_valid()) {
|
if (tex.is_valid()) {
|
||||||
mat->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, tex);
|
mat->set_texture(BaseMaterial3D::TEXTURE_ALBEDO, tex);
|
||||||
} else {
|
} else {
|
||||||
mat->set_albedo(Color(0.8, 0.8, 0.82));
|
mat->set_albedo(Color(0.8, 0.8, 0.82));
|
||||||
}
|
}
|
||||||
mi->set_surface_override_material(s, mat);
|
mi->set_surface_override_material(s, mat);
|
||||||
|
} else {
|
||||||
|
mtgodot::MaterialDesc md;
|
||||||
|
md.albedo = tex;
|
||||||
|
md.blend = guess_blend(sname);
|
||||||
|
mi->set_surface_override_material(s, mtgodot::make_material(md));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
UtilityFunctions::print(vformat("[Metin2Model] materials: %d/%d surfaces textured (dir=%s, %d dds)",
|
||||||
|
textured, mesh->get_surface_count(), dir, (int)dds_files.size()));
|
||||||
}
|
}
|
||||||
|
|
||||||
const gr2::Skeleton *Metin2Model::gr2_skeleton() const {
|
const gr2::Skeleton *Metin2Model::gr2_skeleton() const {
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <godot_cpp/classes/image_texture.hpp>
|
||||||
#include <godot_cpp/classes/node3d.hpp>
|
#include <godot_cpp/classes/node3d.hpp>
|
||||||
|
#include <godot_cpp/classes/ref.hpp>
|
||||||
|
#include <godot_cpp/templates/hash_map.hpp>
|
||||||
|
#include <godot_cpp/variant/packed_string_array.hpp>
|
||||||
#include <godot_cpp/variant/string.hpp>
|
#include <godot_cpp/variant/string.hpp>
|
||||||
|
|
||||||
#include <gr2/gr2.h>
|
#include <gr2/gr2.h>
|
||||||
@@ -11,6 +15,7 @@
|
|||||||
namespace godot {
|
namespace godot {
|
||||||
class Skeleton3D;
|
class Skeleton3D;
|
||||||
class MeshInstance3D;
|
class MeshInstance3D;
|
||||||
|
class ImageTexture;
|
||||||
} // namespace godot
|
} // namespace godot
|
||||||
|
|
||||||
namespace mtgodot {
|
namespace mtgodot {
|
||||||
@@ -46,6 +51,12 @@ public:
|
|||||||
void set_flip_winding(bool v);
|
void set_flip_winding(bool v);
|
||||||
bool get_flip_winding() const { return flip_winding; }
|
bool get_flip_winding() const { return flip_winding; }
|
||||||
|
|
||||||
|
void set_material_mode(const godot::String &m);
|
||||||
|
godot::String get_material_mode() const { return material_mode; }
|
||||||
|
|
||||||
|
// Explicit per-surface texture path override (index -> absolute .dds path).
|
||||||
|
void set_surface_texture(int surface, const godot::String &path);
|
||||||
|
|
||||||
// Rebuild the subtree from the current properties.
|
// Rebuild the subtree from the current properties.
|
||||||
void reload();
|
void reload();
|
||||||
|
|
||||||
@@ -69,14 +80,22 @@ private:
|
|||||||
bool flip_z = false;
|
bool flip_z = false;
|
||||||
bool flip_winding = false;
|
bool flip_winding = false;
|
||||||
|
|
||||||
|
godot::String material_mode = "metin2"; // "metin2" (ShaderMaterial) | "standard"
|
||||||
|
godot::PackedStringArray surface_tex_override;
|
||||||
|
|
||||||
std::shared_ptr<std::optional<gr2::File>> file; // shared_ptr so accessor stays valid
|
std::shared_ptr<std::optional<gr2::File>> file; // shared_ptr so accessor stays valid
|
||||||
godot::Skeleton3D *skel = nullptr;
|
godot::Skeleton3D *skel = nullptr;
|
||||||
godot::MeshInstance3D *mi = nullptr;
|
godot::MeshInstance3D *mi = nullptr;
|
||||||
godot::String last_info;
|
godot::String last_info;
|
||||||
|
godot::HashMap<godot::String, godot::Ref<godot::ImageTexture>> tex_cache;
|
||||||
|
|
||||||
void _clear_children();
|
void _clear_children();
|
||||||
void _apply_materials();
|
void _apply_materials();
|
||||||
godot::String _guess_texture_dir() const;
|
godot::String _guess_texture_dir() const;
|
||||||
|
godot::Ref<godot::ImageTexture> _load_dds(const godot::String &path);
|
||||||
|
godot::Ref<godot::ImageTexture> _resolve_texture(const godot::String &dir,
|
||||||
|
const godot::String &stem, const godot::String &surface_name,
|
||||||
|
const godot::PackedStringArray &dds_files);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace mtgodot
|
} // namespace mtgodot
|
||||||
|
|||||||
@@ -56,6 +56,15 @@ func _build_environment() -> void:
|
|||||||
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
|
||||||
env.ambient_light_energy = 0.6
|
env.ambient_light_energy = 0.6
|
||||||
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
env.tonemap_mode = Environment.TONE_MAPPER_FILMIC
|
||||||
|
# T2.5.4 — distance fog via the engine (not per-shader).
|
||||||
|
if OS.get_environment("MTGODOT_FOG") == "1":
|
||||||
|
env.fog_enabled = true
|
||||||
|
env.fog_mode = Environment.FOG_MODE_DEPTH
|
||||||
|
env.fog_light_color = Color(0.55, 0.55, 0.62)
|
||||||
|
env.fog_sun_scatter = 0.1
|
||||||
|
env.fog_density = 0.0
|
||||||
|
env.fog_depth_begin = 3.0
|
||||||
|
env.fog_depth_end = 12.0
|
||||||
we.environment = env
|
we.environment = env
|
||||||
add_child(we)
|
add_child(we)
|
||||||
|
|
||||||
@@ -121,13 +130,15 @@ func _load_content() -> void:
|
|||||||
|
|
||||||
|
|
||||||
func _frame_model() -> void:
|
func _frame_model() -> void:
|
||||||
# Use the model's converted world-space AABB to place the orbit target/dist.
|
# Place the orbit target/dist from the model's converted world-space AABB.
|
||||||
var mi := _model.get_node_or_null("MeshInstance3D") if _model else null
|
var mi := _model.get_node_or_null("MeshInstance3D") if _model else null
|
||||||
if mi and mi is VisualInstance3D:
|
if mi and mi is VisualInstance3D:
|
||||||
var aabb: AABB = (mi as VisualInstance3D).get_aabb()
|
var aabb: AABB = _model.transform * (mi as VisualInstance3D).get_aabb()
|
||||||
aabb = _model.transform * aabb
|
var h := maxf(aabb.size.y, 0.5)
|
||||||
_target = aabb.get_center()
|
_target = Vector3(aabb.get_center().x, aabb.position.y + h * 0.5, aabb.get_center().z)
|
||||||
_dist = maxf(1.0, aabb.get_longest_axis_size() * 1.8)
|
_dist = h * 3.2
|
||||||
|
_pitch = 0.12
|
||||||
|
print("[mtgodot] frame: aabb=", aabb, " target=", _target, " dist=", _dist)
|
||||||
_update_camera()
|
_update_camera()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 271 KiB |
|
Before Width: | Height: | Size: 271 KiB |