Implement playable Mac client and rendering validation
This commit is contained in:
+33
-11
@@ -67,7 +67,19 @@ void decode_dxt5_alpha(const uint8_t* blk, uint8_t out[16][4]) {
|
||||
}
|
||||
}
|
||||
|
||||
enum Fmt { F_NONE, F_DXT1, F_DXT3, F_DXT5, F_BGRA8 };
|
||||
enum Fmt { F_NONE, F_DXT1, F_DXT3, F_DXT5, F_RGB };
|
||||
|
||||
bool valid_mask(uint32_t mask, uint32_t bits) {
|
||||
if (!mask || (bits < 32 && (mask >> bits))) return false;
|
||||
while (!(mask & 1)) mask >>= 1;
|
||||
return (mask & (mask + 1)) == 0; // contiguous channel bits
|
||||
}
|
||||
|
||||
uint8_t channel(uint32_t pixel, uint32_t mask) {
|
||||
if (!mask) return 255;
|
||||
while (!(mask & 1)) { mask >>= 1; pixel >>= 1; }
|
||||
return uint8_t((uint64_t(pixel & mask) * 255 + mask / 2) / mask);
|
||||
}
|
||||
|
||||
Image decode(const uint8_t* d, size_t len) {
|
||||
Image img;
|
||||
@@ -85,8 +97,8 @@ Image decode(const uint8_t* d, size_t len) {
|
||||
if (!std::memcmp(fourcc, "DXT1", 4)) fmt = F_DXT1;
|
||||
else if (!std::memcmp(fourcc, "DXT3", 4)) fmt = F_DXT3;
|
||||
else if (!std::memcmp(fourcc, "DXT5", 4)) fmt = F_DXT5;
|
||||
} else if ((pf_flags & 0x40) && rgb_bitcount == 32) { // DDPF_RGB, 32bpp
|
||||
fmt = F_BGRA8; // Metin2 的非压缩 DDS 一般是 B8G8R8A8
|
||||
} else if ((pf_flags & 0x40) && (rgb_bitcount == 16 || rgb_bitcount == 24 || rgb_bitcount == 32)) {
|
||||
fmt = F_RGB;
|
||||
}
|
||||
if (fmt == F_NONE || w == 0 || h == 0 || w > 8192 || h > 8192) return img;
|
||||
|
||||
@@ -96,15 +108,25 @@ Image decode(const uint8_t* d, size_t len) {
|
||||
img.h = uint16_t(h);
|
||||
img.rgba.assign(size_t(w) * h * 4, 0);
|
||||
|
||||
if (fmt == F_BGRA8) {
|
||||
if (avail < size_t(w) * h * 4) { img = Image{}; return img; }
|
||||
for (size_t i = 0; i < size_t(w) * h; ++i) {
|
||||
img.rgba[i * 4 + 0] = src[i * 4 + 2];
|
||||
img.rgba[i * 4 + 1] = src[i * 4 + 1];
|
||||
img.rgba[i * 4 + 2] = src[i * 4 + 0];
|
||||
img.rgba[i * 4 + 3] = src[i * 4 + 3];
|
||||
if (fmt == F_RGB) {
|
||||
const uint32_t r = rd_le32(d + 92), g = rd_le32(d + 96), b = rd_le32(d + 100);
|
||||
const uint32_t a = (pf_flags & 1) ? rd_le32(d + 104) : 0;
|
||||
if (!valid_mask(r, rgb_bitcount) || !valid_mask(g, rgb_bitcount) || !valid_mask(b, rgb_bitcount) ||
|
||||
(a && !valid_mask(a, rgb_bitcount)) || ((pf_flags & 1) && !a) ||
|
||||
(r & g) || (r & b) || (g & b) || (a & (r | g | b))) return {};
|
||||
const size_t bytes = rgb_bitcount / 8, row_bytes = size_t(w) * bytes;
|
||||
const size_t pitch = (rd_le32(d + 8) & 8) ? rd_le32(d + 20) : row_bytes;
|
||||
if (pitch < row_bytes || avail < row_bytes || pitch > avail || (h - 1) > (avail - row_bytes) / pitch) return {};
|
||||
for (size_t y = 0; y < h; ++y) {
|
||||
for (size_t x = 0; x < w; ++x) {
|
||||
uint32_t pixel = 0;
|
||||
for (size_t k = 0; k < bytes; ++k) pixel |= uint32_t(src[y * pitch + x * bytes + k]) << (8 * k);
|
||||
auto* out = &img.rgba[(y * w + x) * 4];
|
||||
out[0] = channel(pixel, r); out[1] = channel(pixel, g);
|
||||
out[2] = channel(pixel, b); out[3] = channel(pixel, a);
|
||||
}
|
||||
}
|
||||
img.format = "BGRA8";
|
||||
img.format = rgb_bitcount == 32 && r == 0xff0000 && b == 0xff ? "BGRA8" : "RGB_MASKED";
|
||||
return img;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// dxt.{h,cpp} — ported verbatim from xrender-poc/engine/dxt.{h,cpp}
|
||||
// (namespace engine -> mtgodot). DDS DXT1/3/5 + BGRA8 -> RGBA8, level 0.
|
||||
// (namespace engine -> mtgodot). DDS DXT1/3/5 + masked 16/24/32-bit RGB -> RGBA8, level 0.
|
||||
// engine/dxt —— DDS(DXT1/3/5) → RGBA8 软解。见 docs/steps/M1-static-render.md T3
|
||||
// 自己写 ~150 行绕开 reuse/EterImageLib 的 windows.h 依赖(M1 风险表允许)。
|
||||
// 对拍时两侧都喂软解 RGBA,不被 GPU S3TC 的 bit 级差异污染。
|
||||
|
||||
+144
-28
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
#include <list>
|
||||
|
||||
#include <godot_cpp/classes/mesh_instance3d.hpp>
|
||||
#include <godot_cpp/classes/os.hpp>
|
||||
@@ -25,10 +26,102 @@ using namespace godot;
|
||||
|
||||
namespace mtgodot {
|
||||
|
||||
namespace {
|
||||
struct ClipEntry {
|
||||
String path;
|
||||
uint64_t modified;
|
||||
size_t bytes;
|
||||
std::shared_ptr<const gr2::File> file;
|
||||
};
|
||||
// Immutable decoded clips are shared, not playback time/pose/event cursors.
|
||||
// Main-thread animation loading only; no Godot objects survive in this cache.
|
||||
std::list<ClipEntry> clip_cache;
|
||||
size_t clip_bytes = 0;
|
||||
uint64_t clip_hits = 0, clip_misses = 0;
|
||||
constexpr size_t CLIP_LIMIT = 32, CLIP_BYTE_LIMIT = 64 * 1024 * 1024;
|
||||
struct MsaEntry {
|
||||
String path;
|
||||
uint64_t modified;
|
||||
fmt::Msa metadata;
|
||||
};
|
||||
std::list<MsaEntry> msa_cache;
|
||||
uint64_t msa_hits = 0, msa_misses = 0;
|
||||
// Keep events immutable in the cache; reload copies them into each actor.
|
||||
bool cached_msa(const String &path, fmt::Msa &metadata, std::string *error) {
|
||||
const uint64_t modified = FileAccess::get_modified_time(path);
|
||||
for (auto it = msa_cache.begin(); it != msa_cache.end(); ++it) {
|
||||
if (it->path != path) continue;
|
||||
if (it->modified == modified) {
|
||||
++msa_hits;
|
||||
metadata = it->metadata;
|
||||
msa_cache.splice(msa_cache.begin(), msa_cache, it);
|
||||
return true;
|
||||
}
|
||||
msa_cache.erase(it);
|
||||
break;
|
||||
}
|
||||
++msa_misses;
|
||||
if (!fmt::parse_msa_file(std::string(path.utf8().get_data()), metadata, error)) return false;
|
||||
if (msa_cache.size() >= 128) msa_cache.pop_back();
|
||||
msa_cache.push_front({path, modified, metadata});
|
||||
return true;
|
||||
}
|
||||
std::shared_ptr<const gr2::File> cached_clip(const String &path, gr2::LoadError *err) {
|
||||
uint64_t modified = FileAccess::get_modified_time(path);
|
||||
for (auto it = clip_cache.begin(); it != clip_cache.end(); ++it) {
|
||||
if (it->path != path) continue;
|
||||
if (it->modified == modified) {
|
||||
++clip_hits;
|
||||
clip_cache.splice(clip_cache.begin(), clip_cache, it);
|
||||
return clip_cache.front().file;
|
||||
}
|
||||
clip_bytes -= it->bytes;
|
||||
clip_cache.erase(it);
|
||||
break;
|
||||
}
|
||||
++clip_misses;
|
||||
auto loaded = gr2_from_file(path, err);
|
||||
if (!loaded) return {};
|
||||
size_t bytes = 0;
|
||||
for (size_t i = 0; i < loaded->sections().size(); ++i) bytes += loaded->section_bytes(i).size();
|
||||
auto file = std::make_shared<const gr2::File>(std::move(*loaded));
|
||||
if (bytes <= CLIP_BYTE_LIMIT) {
|
||||
while (!clip_cache.empty() && (clip_cache.size() >= CLIP_LIMIT || clip_bytes + bytes > CLIP_BYTE_LIMIT)) {
|
||||
clip_bytes -= clip_cache.back().bytes;
|
||||
clip_cache.pop_back();
|
||||
}
|
||||
clip_cache.push_front({path, modified, bytes, file});
|
||||
clip_bytes += bytes;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
Dictionary Metin2AnimPlayer::get_clip_cache_stats() {
|
||||
Dictionary d;
|
||||
d["hits"] = clip_hits;
|
||||
d["misses"] = clip_misses;
|
||||
d["entries"] = static_cast<int64_t>(clip_cache.size());
|
||||
d["section_bytes"] = static_cast<int64_t>(clip_bytes);
|
||||
d["msa_hits"] = msa_hits;
|
||||
d["msa_misses"] = msa_misses;
|
||||
d["msa_entries"] = static_cast<int64_t>(msa_cache.size());
|
||||
return d;
|
||||
}
|
||||
void Metin2AnimPlayer::clear_clip_cache() {
|
||||
clip_cache.clear();
|
||||
clip_bytes = 0;
|
||||
clip_hits = clip_misses = 0;
|
||||
msa_cache.clear();
|
||||
msa_hits = msa_misses = 0;
|
||||
}
|
||||
|
||||
Metin2AnimPlayer::Metin2AnimPlayer() {}
|
||||
Metin2AnimPlayer::~Metin2AnimPlayer() = default;
|
||||
|
||||
void Metin2AnimPlayer::_bind_methods() {
|
||||
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("get_clip_cache_stats"), &Metin2AnimPlayer::get_clip_cache_stats);
|
||||
ClassDB::bind_static_method("Metin2AnimPlayer", D_METHOD("clear_clip_cache"), &Metin2AnimPlayer::clear_clip_cache);
|
||||
ClassDB::bind_method(D_METHOD("set_anim_path", "path"), &Metin2AnimPlayer::set_anim_path);
|
||||
ClassDB::bind_method(D_METHOD("get_anim_path"), &Metin2AnimPlayer::get_anim_path);
|
||||
ClassDB::bind_method(D_METHOD("set_model_path", "path"), &Metin2AnimPlayer::set_model_path);
|
||||
@@ -49,6 +142,7 @@ void Metin2AnimPlayer::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("selfcheck", "samples"), &Metin2AnimPlayer::selfcheck, DEFVAL(24));
|
||||
ClassDB::bind_method(D_METHOD("get_accumulation"), &Metin2AnimPlayer::get_accumulation);
|
||||
ClassDB::bind_method(D_METHOD("get_events"), &Metin2AnimPlayer::get_events);
|
||||
ClassDB::bind_method(D_METHOD("get_effect_bone_pose", "bone"), &Metin2AnimPlayer::get_effect_bone_pose);
|
||||
ClassDB::bind_method(D_METHOD("get_loop_data"), &Metin2AnimPlayer::get_loop_data);
|
||||
ClassDB::bind_method(D_METHOD("get_motion_data"), &Metin2AnimPlayer::get_motion_data);
|
||||
ClassDB::bind_static_method("Metin2AnimPlayer",
|
||||
@@ -63,6 +157,7 @@ void Metin2AnimPlayer::_bind_methods() {
|
||||
PropertyInfo(Variant::STRING, "sound"),
|
||||
PropertyInfo(Variant::VECTOR3, "pos")));
|
||||
ADD_SIGNAL(MethodInfo("playback_finished"));
|
||||
ADD_SIGNAL(MethodInfo("motion_event_detailed", PropertyInfo(Variant::DICTIONARY, "event")));
|
||||
|
||||
ADD_PROPERTY(PropertyInfo(Variant::STRING, "anim_path", PROPERTY_HINT_GLOBAL_FILE, "*.gr2,*.msa"),
|
||||
"set_anim_path", "get_anim_path");
|
||||
@@ -132,17 +227,11 @@ static String strip_dpath(const String &raw) {
|
||||
return k >= 0 ? p.substr(k) : p; // "ymir work/pc/.../x.gr2"
|
||||
}
|
||||
|
||||
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec) {
|
||||
String Metin2AnimPlayer::resolve_anim_gr2(const String &spec, const fmt::Msa &m) {
|
||||
String low = spec.to_lower();
|
||||
if (!low.ends_with(".msa")) {
|
||||
return spec; // already a .gr2 path
|
||||
}
|
||||
fmt::Msa m;
|
||||
std::string e;
|
||||
if (!fmt::parse_msa_file(std::string(spec.utf8().get_data()), m, &e)) {
|
||||
UtilityFunctions::push_error(String("[Metin2AnimPlayer] .msa parse: ") + String(e.c_str()));
|
||||
return spec;
|
||||
}
|
||||
const String motion = String(m.motion_gr2.c_str());
|
||||
const String base = motion.replace("\\", "/").get_file();
|
||||
const String msa_dir = spec.get_base_dir();
|
||||
@@ -171,16 +260,45 @@ String Metin2AnimPlayer::resolve_anim_gr2(const String &spec) {
|
||||
return cand;
|
||||
}
|
||||
|
||||
static Dictionary motion_event_dict(const fmt::MotionEvent &ev) {
|
||||
Dictionary d;
|
||||
d["type"] = ev.type;
|
||||
d["start_time"] = ev.start_time;
|
||||
d["effect"] = String(ev.effect_file.c_str());
|
||||
d["sound"] = String(ev.sound_file.c_str());
|
||||
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
|
||||
d["bone"] = String(ev.attaching_bone.c_str());
|
||||
d["attaching"] = ev.attaching;
|
||||
d["following"] = ev.following;
|
||||
d["independent"] = ev.independent;
|
||||
d["fishing_effect"] = ev.fishing_effect;
|
||||
return d;
|
||||
}
|
||||
|
||||
Dictionary Metin2AnimPlayer::get_effect_bone_pose(const String &bone) {
|
||||
Dictionary result;
|
||||
Metin2Model *model = resolve_model();
|
||||
const gr2::Skeleton *sk = model ? model->gr2_skeleton() : nullptr;
|
||||
if (!sk || !anim_file) return result;
|
||||
for (size_t i = 0; i < sk->bones.size() && i < world_buf.size(); ++i) {
|
||||
if (String(sk->bones[i].name.c_str()) == bone) {
|
||||
result["transform"] = gr2_to_godot(world_buf[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void Metin2AnimPlayer::emit_motion_event(const fmt::MotionEvent &ev) {
|
||||
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
|
||||
String(ev.sound_file.c_str()), Vector3(ev.position[0], ev.position[1], ev.position[2]));
|
||||
emit_signal("motion_event_detailed", motion_event_dict(ev));
|
||||
}
|
||||
|
||||
Array Metin2AnimPlayer::get_events() const {
|
||||
Array a;
|
||||
for (const fmt::MotionEvent &ev : events) {
|
||||
Dictionary d;
|
||||
d["type"] = ev.type;
|
||||
d["start_time"] = ev.start_time;
|
||||
d["effect"] = String(ev.effect_file.c_str());
|
||||
d["sound"] = String(ev.sound_file.c_str());
|
||||
d["pos"] = Vector3(ev.position[0], ev.position[1], ev.position[2]);
|
||||
a.push_back(d);
|
||||
a.push_back(motion_event_dict(ev));
|
||||
}
|
||||
return a;
|
||||
}
|
||||
@@ -280,7 +398,7 @@ void Metin2AnimPlayer::_ready() {
|
||||
}
|
||||
|
||||
void Metin2AnimPlayer::reload() {
|
||||
anim_file = std::nullopt;
|
||||
anim_file.reset();
|
||||
duration = 0.0;
|
||||
time = 0.0;
|
||||
prev_time = 0.0;
|
||||
@@ -294,16 +412,18 @@ void Metin2AnimPlayer::reload() {
|
||||
}
|
||||
|
||||
// .msa -> resolve the real motion .gr2 + pull accumulation / events.
|
||||
const bool diagnostics = OS::get_singleton()->get_environment("MTGODOT_ANIM_DIAGNOSTICS") == "1";
|
||||
String gr2_spec = anim_path;
|
||||
if (anim_path.to_lower().ends_with(".msa")) {
|
||||
fmt::Msa m;
|
||||
std::string e;
|
||||
if (fmt::parse_msa_file(std::string(anim_path.utf8().get_data()), m, &e)) {
|
||||
if (cached_msa(anim_path, m, &e)) {
|
||||
msa_metadata = m;
|
||||
accumulation = Vector3(m.accumulation[0], m.accumulation[1], m.accumulation[2]);
|
||||
events = m.events;
|
||||
gr2_spec = resolve_anim_gr2(anim_path);
|
||||
UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
|
||||
// Reuse the metadata just parsed; path resolution must not parse it again.
|
||||
gr2_spec = resolve_anim_gr2(anim_path, m);
|
||||
if (diagnostics) UtilityFunctions::print(vformat("[Metin2AnimPlayer] .msa -> %s accum=(%.2f %.2f %.2f) events=%d loopdata=%s",
|
||||
gr2_spec, accumulation.x, accumulation.y, accumulation.z, (int)events.size(),
|
||||
m.has_loop_data ? vformat("%d x [%.3f,%.3f]", m.motion_loop_count,
|
||||
m.loop_start_time, m.loop_end_time) : String("none")));
|
||||
@@ -314,7 +434,7 @@ void Metin2AnimPlayer::reload() {
|
||||
}
|
||||
|
||||
gr2::LoadError err;
|
||||
auto f = mtgodot::gr2_from_file(gr2_spec, &err);
|
||||
auto f = cached_clip(gr2_spec, &err);
|
||||
if (!f) {
|
||||
UtilityFunctions::push_error(String("[Metin2AnimPlayer] anim load failed [") +
|
||||
String(err.stage.c_str()) + "]: " + String(err.message.c_str()));
|
||||
@@ -330,7 +450,7 @@ void Metin2AnimPlayer::reload() {
|
||||
duration = an.duration;
|
||||
|
||||
// Diagnostic: how many anim tracks map to a model-skeleton bone by name?
|
||||
if (Metin2Model *model = resolve_model()) {
|
||||
if (Metin2Model *model = diagnostics ? resolve_model() : nullptr) {
|
||||
if (const gr2::Skeleton *sk = model->gr2_skeleton()) {
|
||||
int matched = 0;
|
||||
godot::String unmatched;
|
||||
@@ -356,7 +476,7 @@ void Metin2AnimPlayer::reload() {
|
||||
last_info = vformat("anim '%s' dur=%.3f tracks=%d anims=%d",
|
||||
String(an.name.c_str()), duration, (int)an.tracks.size(),
|
||||
(int)anim_file->file_info().animations.size());
|
||||
UtilityFunctions::print("[Metin2AnimPlayer] ", last_info);
|
||||
if (diagnostics) UtilityFunctions::print("[Metin2AnimPlayer] ", last_info);
|
||||
apply_pose(0.0);
|
||||
}
|
||||
|
||||
@@ -480,7 +600,7 @@ void Metin2AnimPlayer::_process(double delta) {
|
||||
if (prev_anim_file) {
|
||||
blend_elapsed += delta; // real seconds, independent of time_scale
|
||||
if (blend_elapsed >= blend_time) {
|
||||
prev_anim_file = std::nullopt;
|
||||
prev_anim_file.reset();
|
||||
}
|
||||
}
|
||||
double next = time + delta * time_scale;
|
||||
@@ -508,9 +628,7 @@ void Metin2AnimPlayer::dispatch_events(double from, double to) {
|
||||
double b = std::fmax(0.0, std::fmin(to, duration));
|
||||
for (const fmt::MotionEvent &ev : events) {
|
||||
if (a < ev.start_time && ev.start_time <= b) {
|
||||
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
|
||||
String(ev.sound_file.c_str()),
|
||||
Vector3(ev.position[0], ev.position[1], ev.position[2]));
|
||||
emit_motion_event(ev);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -522,9 +640,7 @@ void Metin2AnimPlayer::dispatch_events(double from, double to) {
|
||||
double t = ev.start_time;
|
||||
bool hit = (a < t && t <= b) || (b > duration && t <= b - duration);
|
||||
if (hit) {
|
||||
emit_signal("motion_event", ev.type, String(ev.effect_file.c_str()),
|
||||
String(ev.sound_file.c_str()),
|
||||
Vector3(ev.position[0], ev.position[1], ev.position[2]));
|
||||
emit_motion_event(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <msa.h>
|
||||
|
||||
#include <optional>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
namespace mtgodot {
|
||||
@@ -67,8 +68,12 @@ public:
|
||||
// .msa metadata (empty / zero when anim_path is a raw .gr2).
|
||||
godot::Vector3 get_accumulation() const { return accumulation; }
|
||||
godot::Array get_events() const; // [{type,start_time,effect,sound,pos}, ...]
|
||||
// Full affine pose in model GR2 coordinates, shared by CPU/GPU and blending.
|
||||
godot::Dictionary get_effect_bone_pose(const godot::String &bone);
|
||||
godot::Dictionary get_loop_data() const;
|
||||
godot::Dictionary get_motion_data() const; // {duration,next_combo,attack_start_time,...}
|
||||
static godot::Dictionary get_clip_cache_stats();
|
||||
static void clear_clip_cache();
|
||||
|
||||
// CLIENT-GAP §3.5 — parse the PC combo tables out of playersettingmodule.py
|
||||
// (chrmgr.ReserveComboAttackNew / RegisterComboAttackNew → CRaceData). Returns
|
||||
@@ -86,6 +91,7 @@ protected:
|
||||
static void _bind_methods();
|
||||
|
||||
private:
|
||||
void emit_motion_event(const fmt::MotionEvent &ev);
|
||||
godot::String anim_path;
|
||||
godot::NodePath model_path;
|
||||
bool playing = true;
|
||||
@@ -95,14 +101,14 @@ private:
|
||||
double duration = 0.0;
|
||||
godot::String last_info;
|
||||
|
||||
std::optional<gr2::File> anim_file;
|
||||
std::shared_ptr<const gr2::File> anim_file;
|
||||
std::vector<gr2::Mat4> world_buf;
|
||||
std::vector<gr2::Mat4> skin_buf;
|
||||
|
||||
// Crossfade state: the outgoing clip is kept and sampled at a frozen time,
|
||||
// its pose blended into the incoming clip for `blend_time` seconds.
|
||||
double blend_time = 0.15;
|
||||
std::optional<gr2::File> prev_anim_file;
|
||||
std::shared_ptr<const gr2::File> prev_anim_file;
|
||||
double prev_anim_time = 0.0;
|
||||
double prev_anim_duration = 0.0;
|
||||
bool prev_anim_loop = true;
|
||||
@@ -119,7 +125,7 @@ private:
|
||||
void apply_pose(double t);
|
||||
void dispatch_events(double from, double to);
|
||||
// .msa path -> real motion .gr2 path; passes plain .gr2 paths through.
|
||||
static godot::String resolve_anim_gr2(const godot::String &spec);
|
||||
static godot::String resolve_anim_gr2(const godot::String &spec, const fmt::Msa &metadata);
|
||||
};
|
||||
|
||||
} // namespace mtgodot
|
||||
|
||||
@@ -69,6 +69,7 @@ void Metin2Model::_bind_methods() {
|
||||
ClassDB::bind_method(D_METHOD("reload"), &Metin2Model::reload);
|
||||
ClassDB::bind_method(D_METHOD("get_info"), &Metin2Model::get_info);
|
||||
ClassDB::bind_method(D_METHOD("get_visual_aabb"), &Metin2Model::get_visual_aabb);
|
||||
ClassDB::bind_method(D_METHOD("get_fly_target_bounds"), &Metin2Model::get_fly_target_bounds);
|
||||
ClassDB::bind_method(D_METHOD("get_ground_offset"), &Metin2Model::get_ground_offset);
|
||||
ClassDB::bind_method(D_METHOD("get_hair_options"), &Metin2Model::get_hair_options);
|
||||
ClassDB::bind_method(D_METHOD("set_hair_gr2", "p"), &Metin2Model::set_hair_gr2);
|
||||
@@ -274,14 +275,26 @@ void Metin2Model::_set_lod(int n) {
|
||||
lod_prev_mi->set_name("LodGhost");
|
||||
lod_prev_mi->set_layer_mask(1u << 1);
|
||||
lod_prev_mi->set_mesh(mi->get_mesh());
|
||||
lod_prev_mi->set_custom_aabb(mi->get_custom_aabb());
|
||||
add_child(lod_prev_mi);
|
||||
lod_prev_mi->set_transform(mi->get_transform());
|
||||
std::vector<Ref<Material>> ghost_mats;
|
||||
for (int s = 0; s < (int)surf_mats.size(); ++s) {
|
||||
Ref<Material> src = surf_mats[s];
|
||||
// A frozen GPU ghost must not share the live, updated bone texture.
|
||||
Ref<ImageTexture> ghost_bones;
|
||||
if (gpu_skin_active && bones_img.is_valid()) {
|
||||
Ref<godot::Image> snapshot = bones_img->duplicate();
|
||||
ghost_bones = ImageTexture::create_from_image(snapshot);
|
||||
}
|
||||
for (int s = 0; s < mi->get_mesh()->get_surface_count(); ++s) {
|
||||
// CPU hair overrides are not necessarily present in surf_mats.
|
||||
Ref<Material> src = mi->get_active_material(s);
|
||||
Ref<Material> dup;
|
||||
if (src.is_valid()) {
|
||||
dup = src->duplicate();
|
||||
Ref<ShaderMaterial> shader = dup;
|
||||
if (shader.is_valid() && ghost_bones.is_valid()) {
|
||||
shader->set_shader_parameter("bones_tex", ghost_bones);
|
||||
}
|
||||
}
|
||||
lod_prev_mi->set_surface_override_material(s, dup);
|
||||
ghost_mats.push_back(dup);
|
||||
@@ -298,7 +311,11 @@ void Metin2Model::_set_lod(int n) {
|
||||
_end_lod_fade();
|
||||
return;
|
||||
}
|
||||
if (cpu_mesh.is_valid()) {
|
||||
if (gpu_skin_active) {
|
||||
// Keep attachments and the body/hair material boundary across LODs.
|
||||
_build_gpu_mesh();
|
||||
gpu_visual_bounds_ready = false;
|
||||
} else if (cpu_mesh.is_valid()) {
|
||||
AABB b;
|
||||
cpu_mesh = build_mesh(*afi, parts, flip_winding, b);
|
||||
mi->set_mesh(cpu_mesh);
|
||||
@@ -310,6 +327,9 @@ void Metin2Model::_set_lod(int n) {
|
||||
}
|
||||
}
|
||||
_apply_materials();
|
||||
if (gpu_skin_active && !current_skin.empty()) {
|
||||
gpu_skin(current_skin);
|
||||
}
|
||||
if (cpu_mesh.is_valid() && !current_skin.empty()) {
|
||||
cpu_skin(current_skin);
|
||||
}
|
||||
@@ -372,6 +392,8 @@ void Metin2Model::_clear_children() {
|
||||
}
|
||||
|
||||
void Metin2Model::reload() {
|
||||
current_world_pose.clear();
|
||||
fly_target_bounds_valid = false;
|
||||
_clear_children();
|
||||
*file = std::nullopt;
|
||||
last_info = "";
|
||||
@@ -595,24 +617,37 @@ void Metin2Model::_load_hair() {
|
||||
UtilityFunctions::print(vformat("[Metin2Model] hair: %s %d/%d bones matched, %d parts",
|
||||
hair_gr2.get_file(), matched, (int)hbones.size(), (int)hair_parts.size()));
|
||||
|
||||
if (has_cpu_skin_mesh() && !current_skin.empty())
|
||||
}
|
||||
|
||||
void Metin2Model::_refresh_hair_mesh() {
|
||||
if (gpu_skin_active) {
|
||||
_build_gpu_mesh();
|
||||
_apply_materials();
|
||||
gpu_visual_bounds_ready = false;
|
||||
if (!current_skin.empty()) gpu_skin(current_skin);
|
||||
} else if (has_cpu_skin_mesh() && !current_skin.empty()) {
|
||||
cpu_skin(current_skin);
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::set_hair_gr2(const String &p) {
|
||||
if (p == hair_gr2)
|
||||
return;
|
||||
hair_gr2 = p;
|
||||
if (file && *file)
|
||||
if (file && *file) {
|
||||
_load_hair();
|
||||
_refresh_hair_mesh();
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::set_hair_skin(const String &p) {
|
||||
if (p == hair_skin)
|
||||
return;
|
||||
hair_skin = p;
|
||||
if (file && *file && !hair_gr2.is_empty())
|
||||
if (file && *file && !hair_gr2.is_empty()) {
|
||||
_load_hair();
|
||||
_refresh_hair_mesh();
|
||||
}
|
||||
}
|
||||
|
||||
void Metin2Model::set_weapon_gr2(const String &p) {
|
||||
@@ -793,6 +828,7 @@ void Metin2Model::_load_attach(const String &gr2_rel, const String &bone_name,
|
||||
}
|
||||
|
||||
void Metin2Model::update_weapon_pose(const std::vector<gr2::Mat4> &world_pose) {
|
||||
current_world_pose = world_pose;
|
||||
// weapon local = gripPre · handWorld (client: weaponBone[0] composed onto the
|
||||
// parent attach-bone matrix). gripPre is identity for near-origin weapons, so
|
||||
// this is backward-compatible with the ones that already looked right.
|
||||
@@ -804,6 +840,38 @@ void Metin2Model::update_weapon_pose(const std::vector<gr2::Mat4> &world_pose) {
|
||||
mtgodot::mul4x3(shield_pre, world_pose[shield_bone_idx])));
|
||||
}
|
||||
|
||||
namespace { inline void xform_pt(const gr2::Mat4 &M, const float *v, float *o); }
|
||||
|
||||
Dictionary Metin2Model::get_fly_target_bounds() {
|
||||
// Legacy caches its sphere until the model is reset. Do not let particles,
|
||||
// hair, weapons, LOD ghosts or subsequent animation frames shift the centre.
|
||||
if (!fly_target_bounds_valid) {
|
||||
const gr2::FileInfo *fi = active_fi();
|
||||
bool found = false;
|
||||
AABB bounds;
|
||||
if (fi) for (const auto &mesh : fi->meshes) {
|
||||
for (size_t slot = 0; slot < mesh.bone_bounds.size(); ++slot) {
|
||||
const auto &obb = mesh.bone_bounds[slot];
|
||||
const int bone = slot < mesh.bone_bindings.size() ? mesh.bone_bindings[slot] : -1;
|
||||
if (!obb.valid || bone < 0 || bone >= (int)current_world_pose.size()) continue;
|
||||
for (int corner = 0; corner < 8; ++corner) {
|
||||
float point[3], transformed[3];
|
||||
for (int axis = 0; axis < 3; ++axis) point[axis] = (corner & (1 << axis)) ? obb.max[axis] : obb.min[axis];
|
||||
xform_pt(current_world_pose[bone], point, transformed);
|
||||
const Vector3 p(transformed[0], transformed[1], transformed[2]);
|
||||
bounds = found ? bounds.expand(p) : AABB(p, Vector3());
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found) { fly_target_bounds = bounds; fly_target_bounds_valid = true; }
|
||||
}
|
||||
Dictionary result;
|
||||
result["valid"] = fly_target_bounds_valid;
|
||||
result["bounds"] = fly_target_bounds;
|
||||
return result;
|
||||
}
|
||||
|
||||
String Metin2Model::_guess_texture_dir() const {
|
||||
if (!texture_dir.is_empty()) {
|
||||
return texture_dir;
|
||||
@@ -1137,6 +1205,7 @@ void Metin2Model::_apply_materials() {
|
||||
md.albedo = hair_tex;
|
||||
md.blend = mtgodot::BlendMode::AlphaTest;
|
||||
md.alpha_scissor = 0.5f;
|
||||
md.two_sided = true; // Match CPU hair cards when viewed from behind.
|
||||
md.skinned = true;
|
||||
md.bones_tex = bones_tex;
|
||||
Ref<Material> hm = mtgodot::make_material(md);
|
||||
@@ -1591,10 +1660,11 @@ void Metin2Model::gpu_skin(const std::vector<gr2::Mat4> &skin) {
|
||||
return;
|
||||
}
|
||||
current_skin = skin;
|
||||
// The shader owns deformation after this point. Sample the posed bounds
|
||||
// once for culling and initial camera framing; doing the full vertex walk on
|
||||
// every GPU frame would erase the CPU saving this path is intended to make.
|
||||
if (!gpu_visual_bounds_ready && _update_skinned_bounds(skin)) {
|
||||
// Culling must follow every pose, including offscreen actors and one-shots.
|
||||
// Only the initial ready notification reframes selection cameras; emitting
|
||||
// it on every pose would make the camera follow the breathing animation.
|
||||
const bool bounds_updated = _update_skinned_bounds(skin);
|
||||
if (!gpu_visual_bounds_ready && bounds_updated) {
|
||||
gpu_visual_bounds_ready = true;
|
||||
emit_signal("visual_bounds_changed");
|
||||
}
|
||||
@@ -1620,12 +1690,7 @@ String Metin2Model::get_info() const {
|
||||
}
|
||||
|
||||
AABB Metin2Model::get_visual_aabb() {
|
||||
// Consumers such as CharSelect ask only while framing. Refresh on demand
|
||||
// so their final camera fit reflects the current GPU pose without adding a
|
||||
// per-frame CPU vertex walk to every world actor.
|
||||
if (gpu_skin_active && !current_skin.empty()) {
|
||||
_update_skinned_bounds(current_skin);
|
||||
}
|
||||
// Both animation paths publish bounds when sampling, before camera queries.
|
||||
if (have_visual_bounds) {
|
||||
return visual_bounds;
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ public:
|
||||
// Deformed mesh bounds in this node's local GR2 coordinate frame. Unlike
|
||||
// MeshInstance3D::get_aabb(), this follows the GPU shader pose as well.
|
||||
godot::AABB get_visual_aabb();
|
||||
godot::Dictionary get_fly_target_bounds();
|
||||
// Parent-space Y adjustment that places the bind-pose model's lowest point
|
||||
// on the actor origin. GR2 is Z-up/cm; this is converted to Godot Y/metres.
|
||||
double get_ground_offset() const { return ground_offset; }
|
||||
@@ -187,6 +188,7 @@ private:
|
||||
godot::Ref<godot::ImageTexture> hair_tex; // resolved hair albedo (for GPU path)
|
||||
int gpu_base_surf = 0; // base surfaces before appended GPU hair
|
||||
void _load_hair();
|
||||
void _refresh_hair_mesh();
|
||||
void _build_gpu_mesh(); // base + remapped hair surfaces -> mi->mesh (GPU-skin path, §2.2)
|
||||
|
||||
godot::String weapon_gr2; // attached weapon .gr2 (or "")
|
||||
@@ -244,6 +246,9 @@ private:
|
||||
bool gpu_visual_bounds_ready = false;
|
||||
bool have_visual_bounds = false;
|
||||
godot::AABB visual_bounds;
|
||||
std::vector<gr2::Mat4> current_world_pose;
|
||||
godot::AABB fly_target_bounds;
|
||||
bool fly_target_bounds_valid = false;
|
||||
std::vector<gr2::Mat4> current_skin;
|
||||
godot::Ref<godot::Image> bones_img; // RGBAF 3 x bone_count
|
||||
godot::Ref<godot::ImageTexture> bones_tex;
|
||||
|
||||
@@ -611,12 +611,13 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
|
||||
}
|
||||
}
|
||||
|
||||
// Tree proxy:每 treefile 一个共享 mesh + MultiMeshInstance3D。
|
||||
// 当前从 .spt 嗅探真实 bark/composite atlas;几何仍等待离线 SpeedTree exporter。
|
||||
// Native extracted tree geometry, shared once per treefile across instances.
|
||||
for (auto &kv : tree_groups) {
|
||||
if (kv.second.xforms.empty())
|
||||
continue;
|
||||
Ref<godot::ArrayMesh> tmesh = get_tree_proxy_mesh(kv.first, *resolver, 12.0f);
|
||||
Ref<godot::ArrayMesh> tmesh = get_tree_mesh(kv.first, *resolver, 12.0f);
|
||||
const bool native = String(tmesh->get_meta("tree_geometry", "proxy")) == "native_spt";
|
||||
(native ? native_trees_placed : proxy_trees_placed) += (int)kv.second.xforms.size();
|
||||
Ref<MultiMesh> mm;
|
||||
mm.instantiate();
|
||||
mm->set_transform_format(MultiMesh::TRANSFORM_3D);
|
||||
@@ -628,6 +629,7 @@ void Metin2World::place_chunk_objects(int tx, int ty, Node3D *root, int &n_obj,
|
||||
mmi->set_name(String("Trees_") + String(kv.first.c_str()).get_file().get_basename());
|
||||
mmi->set_layer_mask(1u << 0);
|
||||
mmi->set_multimesh(mm);
|
||||
mmi->set_meta("tree_geometry", native ? "native_spt" : "proxy");
|
||||
// §3.3: 树影已烘焙进 shadowmap.dds;proxy 几何的实时投影形状也不对 -> 默认不投。
|
||||
mmi->set_cast_shadows_setting(tree_shadows ? GeometryInstance3D::SHADOW_CASTING_SETTING_ON
|
||||
: GeometryInstance3D::SHADOW_CASTING_SETTING_OFF);
|
||||
@@ -827,6 +829,7 @@ void Metin2World::set_focus_position(double gx_m, double gz_m) {
|
||||
}
|
||||
|
||||
void Metin2World::_process(double) {
|
||||
update_water_animation();
|
||||
for (int n = 0; n < stream_budget && !stream_queue.empty(); ++n) {
|
||||
auto [tx, ty] = stream_queue.front();
|
||||
stream_queue.erase(stream_queue.begin());
|
||||
@@ -854,6 +857,7 @@ void Metin2World::unload_map() {
|
||||
chunks_built = chunks_failed = chunks_splatted = 0;
|
||||
objects_placed = objects_skipped = objects_missing_model = 0;
|
||||
trees_placed = tree_species = 0;
|
||||
native_trees_placed = proxy_trees_placed = 0;
|
||||
objects_mdatr_built = 0;
|
||||
build_ms = 0;
|
||||
splat_ready = registry_ok = false;
|
||||
@@ -997,6 +1001,8 @@ Dictionary Metin2World::get_load_report() const {
|
||||
d["ambience_sources"] = (int)ambience_sources.size();
|
||||
d["trees_placed"] = trees_placed;
|
||||
d["tree_species"] = tree_species;
|
||||
d["native_trees_placed"] = native_trees_placed;
|
||||
d["proxy_trees_placed"] = proxy_trees_placed;
|
||||
d["objects_mdatr_built"] = objects_mdatr_built; // 使用 .mdatr 精确碰撞的对象数
|
||||
d["static_meshes"] = static_cache.loaded;
|
||||
d["build_ms"] = build_ms;
|
||||
|
||||
@@ -143,6 +143,7 @@ private:
|
||||
int chunks_built = 0, chunks_failed = 0, chunks_splatted = 0;
|
||||
int objects_placed = 0, objects_skipped = 0, objects_missing_model = 0;
|
||||
int trees_placed = 0, tree_species = 0;
|
||||
int native_trees_placed = 0, proxy_trees_placed = 0;
|
||||
int objects_mdatr_built = 0;
|
||||
godot::Node3D *objects_root = nullptr;
|
||||
double build_ms = 0;
|
||||
|
||||
@@ -48,6 +48,7 @@ void initialize_mtgodot_module(ModuleInitializationLevel p_level) {
|
||||
void uninitialize_mtgodot_module(ModuleInitializationLevel p_level) {
|
||||
if (p_level == MODULE_INITIALIZATION_LEVEL_SCENE) {
|
||||
fmt::set_file_reader(nullptr);
|
||||
mtgodot::Metin2AnimPlayer::clear_clip_cache();
|
||||
mtgodot::cleanup_material_shaders();
|
||||
mtgodot::cleanup_terrain_shader();
|
||||
mtgodot::cleanup_tree_shader();
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
|
||||
#include <godot_cpp/classes/image.hpp>
|
||||
#include <godot_cpp/classes/image_texture.hpp>
|
||||
#include <godot_cpp/classes/file_access.hpp>
|
||||
#include <godot_cpp/classes/json.hpp>
|
||||
#include <godot_cpp/classes/gltf_document.hpp>
|
||||
#include <godot_cpp/classes/gltf_state.hpp>
|
||||
#include <godot_cpp/classes/gltf_mesh.hpp>
|
||||
#include <godot_cpp/classes/importer_mesh.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
#include <godot_cpp/classes/shader.hpp>
|
||||
#include <godot_cpp/classes/shader_material.hpp>
|
||||
#include <godot_cpp/classes/standard_material3d.hpp>
|
||||
@@ -477,15 +484,57 @@ Ref<ArrayMesh> build_placeholder_tree(const String &species_hint, float height_m
|
||||
return build_proxy_impl(std::string(species_hint.utf8().get_data()), height_m, empty);
|
||||
}
|
||||
|
||||
Ref<ArrayMesh> get_tree_proxy_mesh(const std::string &treefile,
|
||||
static Ref<ArrayMesh> load_native_tree(const std::string &treefile, const fmt::AssetResolver &resolver) {
|
||||
const String source(resolver.resolve(treefile, nullptr).c_str());
|
||||
if (source.is_empty()) return {};
|
||||
const String directory = String(resolver.assets_root.c_str()).path_join("TreeGeometry");
|
||||
const String manifest_path = directory.path_join("manifest.json");
|
||||
if (!FileAccess::file_exists(manifest_path)) return {};
|
||||
const Variant parsed = JSON::parse_string(FileAccess::get_file_as_string(manifest_path));
|
||||
if (parsed.get_type() != Variant::DICTIONARY) return {};
|
||||
const Dictionary manifest = parsed;
|
||||
if (int(manifest.get("schema_version", 0)) != 1 || String(manifest.get("coordinates", "")) != "godot_y_up_meters") return {};
|
||||
const Dictionary trees = manifest.get("trees", Dictionary());
|
||||
const String source_hash = FileAccess::get_sha256(source);
|
||||
if (!trees.has(source_hash)) return {};
|
||||
const Dictionary record = trees[source_hash];
|
||||
const String filename = record.get("glb", "");
|
||||
if (filename != source_hash + String(".glb")) return {};
|
||||
const String path = directory.path_join(filename);
|
||||
if (!FileAccess::file_exists(path) || FileAccess::get_sha256(path) != String(record.get("glb_sha256", ""))) return {};
|
||||
Ref<GLTFDocument> document;
|
||||
document.instantiate();
|
||||
Ref<GLTFState> state;
|
||||
state.instantiate();
|
||||
if (document->append_from_file(path, state) != OK || state->get_meshes().size() != 1) return {};
|
||||
Ref<GLTFMesh> gltf_mesh = state->get_meshes()[0];
|
||||
if (gltf_mesh.is_null() || gltf_mesh->get_mesh().is_null()) return {};
|
||||
Ref<ArrayMesh> mesh = gltf_mesh->get_mesh()->get_mesh();
|
||||
if (mesh.is_null() || mesh->get_surface_count() == 0) return {};
|
||||
mesh->set_meta("tree_geometry", "native_spt");
|
||||
mesh->set_meta("source_sha256", source_hash);
|
||||
mesh->set_meta("glb_sha256", record.get("glb_sha256", ""));
|
||||
mesh->set_meta("leaf_mode", "static_extracted_cards");
|
||||
// GLB vertices already use Godot metres. Never impose the proxy's 12m height.
|
||||
return mesh;
|
||||
}
|
||||
|
||||
Ref<ArrayMesh> get_tree_mesh(const std::string &treefile,
|
||||
const fmt::AssetResolver &resolver, float height_m) {
|
||||
const std::string key = resolver.assets_root + "|" + fmt::AssetResolver::normalize(treefile) +
|
||||
"#" + std::to_string(height_m);
|
||||
auto found = g_tree_mesh_cache.find(key);
|
||||
if (found != g_tree_mesh_cache.end())
|
||||
return found->second;
|
||||
Ref<ArrayMesh> native = load_native_tree(treefile, resolver);
|
||||
if (native.is_valid()) {
|
||||
g_tree_mesh_cache.emplace(key, native);
|
||||
return native;
|
||||
}
|
||||
UtilityFunctions::push_warning(String("Tree native geometry unavailable or hash mismatch; proxy: ") + String(treefile.c_str()));
|
||||
const TreeTextures textures = resolve_tree_textures(treefile, resolver);
|
||||
Ref<ArrayMesh> mesh = build_proxy_impl(treefile, height_m, textures);
|
||||
mesh->set_meta("tree_geometry", "proxy");
|
||||
g_tree_mesh_cache.emplace(key, mesh);
|
||||
return mesh;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ namespace fmt {
|
||||
struct AssetResolver;
|
||||
}
|
||||
|
||||
// W4/R2 —— `.spt` 几何尚未跨平台读取(见 formats/spt.h),运行时使用 tree proxy:
|
||||
// 确定性枝干 + 多组交叉叶簇,并优先采用 .spt 指向的真实 bark/composite DDS。
|
||||
// Native trees use verified offline GLBs; `.spt` is never linked to a proprietary
|
||||
// runtime on macOS. Deterministic procedural geometry remains a reported fallback.
|
||||
// 一份共享 mesh / treefile,逐实例只由 MultiMesh 承载原 AreaData 位置。
|
||||
namespace mtgodot {
|
||||
|
||||
@@ -19,10 +19,11 @@ namespace mtgodot {
|
||||
godot::Ref<godot::ArrayMesh> build_placeholder_tree(const godot::String &species_hint,
|
||||
float height_m = 12.0f);
|
||||
|
||||
// R2 tree proxy:保留无专有运行时的限制,但从 .spt 嗅探真实树皮和 composite atlas,
|
||||
// 用确定性的枝干/叶簇 mesh 近似 SpeedTree 的 branch/frond/leaf 分层。结果按 treefile 缓存,
|
||||
// 可安全用于每树种一个 MultiMesh。若资源解析失败,自动退回纯程序化材质。
|
||||
godot::Ref<godot::ArrayMesh> get_tree_proxy_mesh(const std::string &treefile,
|
||||
// Cached per treefile for MultiMesh use. Missing or mismatched native resources
|
||||
// use a proxy with explicit metadata and a warning; package acceptance rejects it.
|
||||
// Prefer hash-matched native GLB geometry in assets/TreeGeometry. The procedural
|
||||
// implementation is an explicitly reported fallback for missing/invalid trees.
|
||||
godot::Ref<godot::ArrayMesh> get_tree_mesh(const std::string &treefile,
|
||||
const fmt::AssetResolver &resolver, float height_m = 12.0f);
|
||||
|
||||
// 从 treefile 名猜是否针叶(cedar / cypress / pine / fir / spruce…)。
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "water_builder.h"
|
||||
#include "water_motion.h"
|
||||
|
||||
#include "asset_io.h"
|
||||
#include "dxt.h"
|
||||
@@ -7,6 +8,8 @@
|
||||
#include <godot_cpp/classes/shader.hpp>
|
||||
#include <godot_cpp/classes/shader_material.hpp>
|
||||
#include <godot_cpp/classes/texture2d_array.hpp>
|
||||
#include <godot_cpp/classes/time.hpp>
|
||||
#include <godot_cpp/variant/utility_functions.hpp>
|
||||
#include <godot_cpp/variant/packed_byte_array.hpp>
|
||||
#include <godot_cpp/variant/packed_color_array.hpp>
|
||||
#include <godot_cpp/variant/packed_int32_array.hpp>
|
||||
@@ -29,32 +32,30 @@ namespace {
|
||||
// 30 帧序列 + 逐顶点水深 alpha(顶点 COLOR.a) + 轻微高度浮动。
|
||||
// UV = 世界米;平铺频率在 shader 里按 1/(CELLSCALE*4 cm) = 1/8m。
|
||||
const char *SRC_WATER = R"(shader_type spatial;
|
||||
render_mode blend_mix, cull_disabled, depth_draw_never, diffuse_lambert, specular_schlick_ggx;
|
||||
render_mode blend_mix, cull_disabled, depth_draw_never, unshaded;
|
||||
|
||||
uniform sampler2DArray frames : source_color, filter_linear_mipmap, repeat_enable;
|
||||
uniform vec3 tint : source_color = vec3(0.10, 0.22, 0.26);
|
||||
uniform float uv_per_meter = 0.125; // 1/8m,= 原客户端 1/(CELLSCALE*4)
|
||||
uniform float bob_amp = 0.06; // 高度浮动幅度(米),近似 MapOutdoorWater 0..-15cm
|
||||
uniform float height_offset = 0.0; // Shared reference water translation, metres.
|
||||
|
||||
void vertex() {
|
||||
VERTEX.y += sin(TIME * 0.6 + VERTEX.x * 0.01 + VERTEX.z * 0.013) * bob_amp;
|
||||
VERTEX.y += height_offset;
|
||||
}
|
||||
|
||||
void fragment() {
|
||||
int f = int(mod(TIME * 1000.0 / 70.0, 30.0)); // 70ms/帧
|
||||
vec2 uv = UV * uv_per_meter;
|
||||
vec3 tex = texture(frames, vec3(uv, float(f))).rgb;
|
||||
float fres = pow(1.0 - clamp(dot(normalize(VIEW), NORMAL), 0.0, 1.0), 3.0);
|
||||
ALBEDO = mix(tint, tex, 0.6) + fres * 0.15;
|
||||
ALPHA = clamp(COLOR.a + fres * 0.25, 0.12, 0.95);
|
||||
ROUGHNESS = 0.08;
|
||||
METALLIC = 0.0;
|
||||
SPECULAR = 0.6;
|
||||
// MapOutdoorWater selects texture RGB and vertex diffuse alpha directly.
|
||||
// Do not introduce PBR lighting, invented tint or view-dependent opacity.
|
||||
ALBEDO = tex;
|
||||
ALPHA = COLOR.a;
|
||||
}
|
||||
)";
|
||||
|
||||
Ref<Shader> g_water_shader;
|
||||
Ref<ShaderMaterial> g_water_mat; // 30 帧数组只建一次
|
||||
WaterMotion g_water_motion;
|
||||
|
||||
Ref<ShaderMaterial> water_material(const fmt::AssetResolver &res) {
|
||||
if (g_water_mat.is_valid())
|
||||
@@ -107,6 +108,19 @@ Ref<ShaderMaterial> water_material(const fmt::AssetResolver &res) {
|
||||
void cleanup_water_shader() {
|
||||
g_water_shader.unref();
|
||||
g_water_mat.unref();
|
||||
g_water_motion = WaterMotion{};
|
||||
}
|
||||
|
||||
void update_water_animation() {
|
||||
if (g_water_mat.is_null()) return;
|
||||
const uint64_t now = Time::get_singleton()->get_ticks_msec();
|
||||
uint64_t duration = 1000;
|
||||
double depth = 0;
|
||||
if (g_water_motion.initialized && now - g_water_motion.start_ms > g_water_motion.duration_ms) {
|
||||
duration = UtilityFunctions::randi_range(1000, 3000);
|
||||
if (g_water_motion.end == 0) depth = UtilityFunctions::randi_range(0, 15);
|
||||
}
|
||||
g_water_mat->set_shader_parameter("height_offset", g_water_motion.sample(now, duration, depth));
|
||||
}
|
||||
|
||||
std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::HeightMap &hm,
|
||||
@@ -126,8 +140,9 @@ std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::He
|
||||
double ly = tex_y * double(fmt::m2coord::CELLSCALE);
|
||||
double th = fmt::terrain_height_at(hm, lx, ly, height_scale); // cm
|
||||
double depth_cm = water_h_cm - th;
|
||||
float a = float(depth_cm / 60.0); // 60cm 深 -> 接近不透明
|
||||
return a < 0.12f ? 0.12f : (a > 0.9f ? 0.9f : a);
|
||||
// AreaTerrain uses RAW height differences, before HeightScale, with
|
||||
// OpaqueWaterDepth=400 and clamps at 80% (not a 12% shoreline floor).
|
||||
return water_depth_alpha(depth_cm, height_scale);
|
||||
};
|
||||
|
||||
for (int layer = 0; layer < wm.layer_count; ++layer) {
|
||||
@@ -147,8 +162,14 @@ std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::He
|
||||
continue;
|
||||
}
|
||||
int xs = x;
|
||||
while (x < W && wm.ids[y * W + x] == layer)
|
||||
++x;
|
||||
// Keep each cell's four shoreline samples. Merging a whole row
|
||||
// discarded interior terrain peaks and bridged dry banks with water.
|
||||
++x;
|
||||
float aLL = depth_alpha(xs, y, h_cm), aLR = depth_alpha(x, y, h_cm);
|
||||
float aUR = depth_alpha(x, y + 1, h_cm), aUL = depth_alpha(xs, y + 1, h_cm);
|
||||
// AreaTerrain skips the quad when every diffuse alpha is zero.
|
||||
if (aLL == 0 && aLR == 0 && aUR == 0 && aUL == 0)
|
||||
continue;
|
||||
double x0 = X0 + xs * CELL_M, x1 = X0 + x * CELL_M;
|
||||
double z0 = Z0 + y * CELL_M, z1 = Z0 + (y + 1) * CELL_M;
|
||||
int base = v.size();
|
||||
@@ -162,8 +183,6 @@ std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::He
|
||||
uv.push_back(Vector2(float(x1), float(z0)));
|
||||
uv.push_back(Vector2(float(x1), float(z1)));
|
||||
uv.push_back(Vector2(float(x0), float(z1)));
|
||||
float aLL = depth_alpha(xs, y, h_cm), aLR = depth_alpha(x, y, h_cm);
|
||||
float aUR = depth_alpha(x, y + 1, h_cm), aUL = depth_alpha(xs, y + 1, h_cm);
|
||||
col.push_back(Color(1, 1, 1, aLL));
|
||||
col.push_back(Color(1, 1, 1, aLR));
|
||||
col.push_back(Color(1, 1, 1, aUR));
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#pragma once
|
||||
#include "water_depth.h"
|
||||
|
||||
#include <godot_cpp/classes/array_mesh.hpp>
|
||||
#include <godot_cpp/classes/ref.hpp>
|
||||
@@ -16,6 +17,7 @@ struct AssetResolver;
|
||||
// 70ms/帧),UV 平铺频率 = 1/(CELLSCALE*4)(每 4 格一循环),逐顶点水深 alpha,轻微高度浮动。
|
||||
namespace mtgodot {
|
||||
|
||||
|
||||
struct WaterPiece {
|
||||
godot::Ref<godot::ArrayMesh> mesh; // Godot 空间,已含区块原点
|
||||
float godot_y = 0;
|
||||
@@ -26,5 +28,6 @@ std::vector<WaterPiece> build_chunk_water(const fmt::WaterMap &wm, const fmt::He
|
||||
int tile_x, int tile_y, double height_scale, const fmt::AssetResolver &res);
|
||||
|
||||
void cleanup_water_shader();
|
||||
void update_water_animation();
|
||||
|
||||
} // namespace mtgodot
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#pragma once
|
||||
#include <cmath>
|
||||
|
||||
namespace mtgodot {
|
||||
// 40250 MapOutdoor.cpp / AreaTerrain.cpp. Arguments in scaled centimetres.
|
||||
inline float water_depth_alpha(double depth_cm, double height_scale) {
|
||||
if (!std::isfinite(depth_cm) || !std::isfinite(height_scale) ||
|
||||
height_scale <= 0.0 || depth_cm <= 0.0) return 0.0f;
|
||||
double alpha = depth_cm / (400.0 * height_scale);
|
||||
return static_cast<float>(alpha > 0.8 ? 0.8 : alpha);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
#pragma once
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
|
||||
namespace mtgodot {
|
||||
// MapOutdoorWater: one shared vertical translation, alternating a random
|
||||
// 0..-15cm endpoint and zero, linearly interpolated over 1000..3000ms.
|
||||
struct WaterMotion {
|
||||
uint64_t start_ms = 0, duration_ms = 300;
|
||||
double begin = 0, end = 0, current = 0;
|
||||
bool initialized = false;
|
||||
double sample(uint64_t now, uint64_t next_duration, double next_depth_cm) {
|
||||
if (!initialized) { start_ms = now; initialized = true; }
|
||||
if (now - start_ms > duration_ms) {
|
||||
begin = current;
|
||||
end = end == 0 ? -std::clamp(next_depth_cm, 0.0, 15.0) * 0.01 : 0;
|
||||
start_ms = now;
|
||||
duration_ms = std::clamp<uint64_t>(next_duration, 1000, 3000);
|
||||
}
|
||||
current = begin + (end - begin) * double(now - start_ms) / double(duration_ms);
|
||||
return current;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user