60 秒限时打靶场玩法原型:碎裂逻辑提取 FractureLib 共享库(HostView 桥接 protected 底座)+ 玻璃靶 Target(跨脚本参数计分)+ GameDirector(靶阵/倒计时/重开/图元 HUD)

This commit is contained in:
2026-10-09 13:00:18 +08:00
parent 6854d53f11
commit f15b104e77
4 changed files with 1103 additions and 497 deletions
+562
View File
@@ -0,0 +1,562 @@
//
// FractureLib.h:共享碎裂库(自 WallFracture.cpp 提取,行为逐行保持)。
//
// 碎裂语义 = 玻璃/陶瓷式("碎掉"),非爆炸飞散,碎片几何 = 2D
// Voronoi 分割:
// 碎裂瞬间在面上撒种子点(命中点周边加密 → 孔洞周围碎片小、
// 远处碎片大,玻璃碎的典型分布),对每颗种子做垂直平分线半平面
// 交集得到不规则凸多边形 cell,沿面厚挤出成凸棱柱——碎片在碎裂
// 前一刻完美拼合原面,碎开后沿裂纹分离。碰撞形状不走 Box,直接
// 用 MeshCollider(Dynamic → Jolt ConvexHull,数据源 = 网格 CPU
// 位置副本,PhysicsWorld::BuildMeshShape)→ 不规则碎片连碰撞体
// 都是真的,落地翻滚/堆积接触自然。
//
// 初速 = 径向 × 距离指数衰减(只有命中点周边的碎片被推动,远处
// 几乎原地悬置)+ 沿命中法线轻微外推(朝弹侧剥落)+ 微弱上抛
// ——重力是塌落的主导,整体呈"裂纹分离 → 孔洞塌落 → 碎块堆在
// 墙脚"的观感。
//
// 用法(宿主 = 挂在可碎实体上的脚本):
// Fracture::Params p_; // DeclareParams 直绑成员(pb.Field)
// Fracture::Burst burst_; // 碎片簿记 + 生命周期
// HostView HV() { // 桥接(脚本自身成员函数内可访问
// return {App(), EntityBits(),// protected 基类设施,库内只走公共 API)
// [this](auto& n) { return Spawn(n); },
// [this](auto e) { Destroy(e); }};
// }
// OnCollisionEnter → burst_.TryFire(HV(), other, point, normal)
// OnUpdate → burst_.Tick(HV(), dt)
// OnDestroy → burst_.Shutdown(HV())
// 外观:Fire 前改 burst_.look(默认砖色不透明;玻璃靶 = 半透明
// + 自发光)
// HostView 每次调用现填(不存指针:组件存储重排/实例重建均安全)
//
// 占位局限(真碎片资产接入后本路径退役):碎片网格顶点按宿主世界
// 尺寸烘焙、碎片实体复用宿主 world pos/rot(scale 恒 1,防
// ConvexHull 顶点二次缩放)——宿主须无旋转摆放。
//
#ifndef HELLOVULKAN_FRACTURE_LIB_H
#define HELLOVULKAN_FRACTURE_LIB_H
#include <Engine/Scripting/Script.h>
#include <Engine/Core/Application.h>
#include <Engine/Core/Types.h>
#include <Engine/Assets/AssetManager.h> // LoadModel(门面只前向声明)
#include <Engine/Renderer/Model.h> // ParsedModel/ParsedMesh(程序化碎片几何)
#include <Engine/Renderer/Vertex.h> // Vertex 聚合布局
#include <Engine/Physics/PhysicsWorld.h>
#include <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>
#include <algorithm>
#include <cmath>
#include <functional>
#include <random>
namespace Engine::Fracture {
// bits(PhysicsWorld 键)→ 实体(还原位模式;用前自行 valid 校验)
inline ECS::Entity EntityFromBits(u32 bits) {
return static_cast<ECS::Entity>(bits);
}
// 实体句柄 → bits(EntityBits() 只覆盖自身绑定实体,碎片簿记
// 需要任意实体的同款换算)
inline u32 BitsOf(const ECS::EntityHandle& e) {
return static_cast<u32>(
entt::to_integral(static_cast<entt::entity>(e)));
}
inline f32 Rand(f32 lo, f32 hi) {
thread_local std::mt19937 rng{std::random_device{}()};
return std::uniform_real_distribution<f32>(lo, hi)(rng);
}
// 碎片模型代序号(全局递增):每代碎片一组唯一缓存键 → 每次
// 命中都重新随机生成形状(命中点量化键会让同位置重复命中
// 复用首次几何——缓存去重把新几何丢弃,碎片就"固定"了)
inline u32& GenSeq() {
static u32 s = 0;
return s;
}
// ---- 宿主桥接(脚本侧现填;库内只走公共 API)----
// spawn/destroy 走脚本成员(内部才能访问 ScriptBehavior 的
// protected 底座),app/实体走数据。std::function 捕 this 无
// 分配(小对象优化),随调用栈存亡,无悬垂面
struct HostView {
Application& app;
u32 entityBits = 0;
ECS::Entity entity() const { return EntityFromBits(entityBits); }
std::function<ECS::EntityHandle(const std::string&)> spawn;
std::function<void(ECS::Entity)> destroy;
};
// ---- 2D Voronoi(碎裂裂纹)----
//
// cell = 相对其它种子的垂直平分线半平面交集,初始域 = 墙面矩形。
// Sutherland–Hodgman 凸多边形裁剪:保留靠近 p 的一侧
//(dot(a,x) <= b,a = normalize(q-p),b = dot(a,p)+|q-p|/2)。
// 输入多边形须 CCW(初始矩形即 CCW,裁剪保序)。
inline std::vector<glm::vec2> ClipByBisector(std::vector<glm::vec2> poly,
const glm::vec2& p,
const glm::vec2& q) {
const glm::vec2 d = q - p;
const f32 len = glm::length(d);
if (len < 1e-6f) return poly;
const glm::vec2 a = d / len;
const f32 b = glm::dot(a, p) + 0.5f * len;
std::vector<glm::vec2> out;
out.reserve(poly.size() + 1);
for (size_t i = 0; i < poly.size(); ++i) {
const glm::vec2& c = poly[i];
const glm::vec2& n = poly[(i + 1) % poly.size()];
const f32 dc = glm::dot(a, c) - b;
const f32 dn = glm::dot(a, n) - b;
if (dc <= 0.0f) out.push_back(c);
if ((dc < 0.0f) != (dn < 0.0f)) { // 边跨越裁剪线 → 求交点
const f32 t = dc / (dc - dn);
out.push_back(c + t * (n - c));
}
}
return out;
}
// 种子 p 的 Voronoi cell(域 = [-hw,hw]×[-hh,hh] 的墙面矩形)
inline std::vector<glm::vec2> VoronoiCell(const glm::vec2& p,
const std::vector<glm::vec2>& seeds,
f32 hw, f32 hh) {
std::vector<glm::vec2> poly = {
{-hw, -hh}, {hw, -hh}, {hw, hh}, {-hw, hh}};
for (const glm::vec2& q : seeds) {
if (glm::dot(q - p, q - p) < 1e-10f) continue; // 自身
poly = ClipByBisector(std::move(poly), p, q);
if (poly.size() < 3) return {}; // 退化(最小间距保护,罕见)
}
return poly;
}
// cell(CCW 凸多边形,墙局部 XY)沿 z ∈ [-hz, +hz] 挤出成棱柱:
// 顶/底面 fan 三角化 + 侧面逐边 quad,per-face 法线。返回 mesh
// 数据;cpuPositions 单独回传(ConvexHull 物理数据源)。
struct PrismMesh {
std::vector<Engine::Vertex> vertices;
std::vector<u32> indices;
std::vector<glm::vec3> cpuPositions;
};
inline PrismMesh CellToPrism(const std::vector<glm::vec2>& cell, f32 hz) {
PrismMesh m;
const size_t n = cell.size();
const size_t topBase = 0;
const size_t botBase = n;
m.vertices.reserve(n * 2);
for (const glm::vec2& p : cell) {
const Engine::Vertex v{
{p.x, p.y, hz}, {0.0f, 0.0f, 1.0f},
{p.x, p.y}, {1.0f, 0.0f, 0.0f}};
m.vertices.push_back(v);
}
for (const glm::vec2& p : cell) {
const Engine::Vertex v{
{p.x, p.y, -hz}, {0.0f, 0.0f, -1.0f},
{p.x, p.y}, {1.0f, 0.0f, 0.0f}};
m.vertices.push_back(v);
}
// top fan(CCW 从 +Z 看朝外)+ bottom fan(反向)
for (size_t i = 1; i + 1 < n; ++i) {
m.indices.push_back(topBase);
m.indices.push_back(topBase + i);
m.indices.push_back(topBase + i + 1);
m.indices.push_back(botBase);
m.indices.push_back(botBase + i + 1);
m.indices.push_back(botBase + i);
}
// 侧面:(i, N+i, N+i+1, i+1)——外法线 = CCW 边的右侧法向
for (size_t i = 0; i < n; ++i) {
const size_t j = (i + 1) % n;
const glm::vec2 e = cell[j] - cell[i];
const f32 el = glm::length(e);
const glm::vec3 sn = el > 1e-6f
? glm::vec3{e.y / el, -e.x / el, 0.0f}
: glm::vec3{0.0f, -1.0f, 0.0f};
const size_t b = m.vertices.size();
const Engine::Vertex q0{
{cell[i].x, cell[i].y, hz}, sn,
{0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}};
const Engine::Vertex q1{
{cell[i].x, cell[i].y, -hz}, sn,
{0.0f, 1.0f}, {1.0f, 0.0f, 0.0f}};
const Engine::Vertex q2{
{cell[j].x, cell[j].y, -hz}, sn,
{1.0f, 1.0f}, {1.0f, 0.0f, 0.0f}};
const Engine::Vertex q3{
{cell[j].x, cell[j].y, hz}, sn,
{1.0f, 0.0f}, {1.0f, 0.0f, 0.0f}};
m.vertices.push_back(q0);
m.vertices.push_back(q1);
m.vertices.push_back(q2);
m.vertices.push_back(q3);
m.indices.insert(m.indices.end(),
{u32(b), u32(b + 1), u32(b + 2),
u32(b), u32(b + 2), u32(b + 3)});
}
// 物理凸包数据源(BuildMeshShape Dynamic 分支逐顶点收集)
m.cpuPositions.reserve(m.vertices.size());
for (const auto& v : m.vertices) {
m.cpuPositions.push_back(v.position);
}
return m;
}
// ---- 参数(原 WallFracture 全量参数,字段类型随 Inspector 控件)----
struct Params {
i32 count = 30; // Voronoi 种子数 = 碎片数
f32 seedRadius = 0.9f; // 加密半径 m
f32 minSeedDist = 0.12f; // 种子最小间距 m
f32 burstSpeed = 1.2f; // 近点径向初速 m/s
f32 falloff = 1.0f; // 距离衰减率 1/m
f32 normalPop = 0.4f; // 法线外推 m/s
f32 upBias = 0.3f; // 上抛偏置 m/s
f32 lifetime = 6.0f; // 碎片寿命(秒)
i32 maxFragments = 128; // 活碎片上限
f32 minImpactSpeed = 2.0f; // 触发阈值(m/s)
};
// ---- 碎片外观(Fire 时套用到全部碎片网格;默认砖色不透明 =
// 原 WallFracture 观感)----
struct Appearance {
glm::vec4 baseColor{140.0f / 255.0f, 92.0f / 255.0f,
72.0f / 255.0f, 1.0f};
bool transparent = false; // 透明绘制队列(玻璃碎片)
glm::vec3 emissive{0.0f}; // 自发光颜色(黑 = 不发光)
f32 emissiveStrength = 1.0f;
};
// ---- 碎裂执行体(宿主脚本持有;一次 Fire 管一整批碎片)----
class Burst {
public:
Params p; // 宿主 DeclareParams 直绑
Appearance look; // 碎片外观(Fire 前设置)
// 触发流程一次做完:一次性保护 → 对方校验(活 Dynamic +
// 速度阈值)→ 藏宿主 → 切分 → spawn 碎片群。未触发返回
// false(速度不够/重复触发),宿主无需再写判定
bool TryFire(const HostView& host,
u32 other, const glm::vec3& point,
const glm::vec3& normal) {
if (m_Fired) return false;
// 对方须是活的 Dynamic 刚体且速度够快(过滤静止贴靠/
// 角色轻碰——子弹/抛体必中)
auto& registry = host.app.GetScene().GetRegistry();
const ECS::Entity otherE = EntityFromBits(other);
if (!registry.valid(otherE)) return false;
const auto* otherRb =
registry.try_get<Engine::ECS::RigidBodyComponent>(otherE);
if (!otherRb ||
otherRb->type !=
Engine::ECS::RigidBodyComponent::BodyType::Dynamic) {
return false;
}
const f32 speed = glm::length(
host.app.GetPhysics().GetLinearVelocity(other));
if (speed < p.minImpactSpeed) return false;
m_Fired = true;
Fire(host, point, normal);
return true;
}
// 每帧簿记(宿主 OnUpdate 直调;未 Fire 过无簿记可理)
void Tick(const HostView& host, f32 dt) {
if (!m_Fired) return;
ApplyPendingUnload(host);
ApplyPendingVelocities(host);
AgeAndCleanup(host, dt);
}
// 宿主销毁(PIE stop 换血 / 手动删除):碎片实体由场景清亡
// 处理,独占碎片模型统一批量回收(模型不被快照恢复的场景
// 引用——唯一代键;批量接口内部 GPU 停飞后再销毁)
void Shutdown(const HostView& host) {
if (!m_Fired) return; // 从未 Fire:无碎片无模型
std::vector<std::string> paths;
paths.reserve(m_Fragments.size() + m_PendingUnload.size());
for (const Fragment& f : m_Fragments) {
if (!f.modelPath.empty()) paths.push_back(f.modelPath);
}
m_Fragments.clear();
paths.insert(paths.end(),
std::make_move_iterator(m_PendingUnload.begin()),
std::make_move_iterator(m_PendingUnload.end()));
m_PendingUnload.clear();
if (!paths.empty()) {
host.app.GetAssets().UnloadModelsByPaths(paths);
}
}
bool Fired() const { return m_Fired; }
private:
// ---- 触发碎裂:藏宿主 + Voronoi 切分 + spawn 碎片群 ----
void Fire(const HostView& host,
const glm::vec3& hitPoint, const glm::vec3& hitNormal) {
auto& registry = host.app.GetScene().GetRegistry();
const ECS::Entity self = host.entity();
// 藏宿主:渲染/拾取跳过 + body 对账销毁(组件级 active,
// 与 Unity Renderer.enabled + Rigidbody 摘除同语义)
if (auto* mr =
registry.try_get<Engine::ECS::MeshRendererComponent>(
self)) {
mr->active = false;
}
if (auto* rb =
registry.try_get<Engine::ECS::RigidBodyComponent>(
self)) {
rb->active = false;
}
// 宿主几何:碎片网格顶点按宿主世界尺寸烘焙(面 = XY 平面,
// 厚度沿 Z;宿主须无旋转摆放,见头注释占位局限)
const auto& wall = registry.get<Engine::ECS::TransformComponent>(
self);
const glm::vec3 wallPos = wall.GetWorldPosition();
const glm::vec3 wallSize = wall.GetWorldScale();
const f32 hw = 0.5f * wallSize.x;
const f32 hh = 0.5f * wallSize.y;
const f32 hz = 0.5f * wallSize.z;
// 命中点 → 宿主局部(逆旋转 + 平移),XY 即裂纹平面坐标
const glm::quat invRot = glm::conjugate(
wall.GetWorldRotationQuat());
const glm::vec3 localHit = invRot * (hitPoint - wallPos);
const glm::vec2 uvHit{localHit.x, localHit.y};
// ---- 种子:40% 命中点半径内加密 + 60% 全面均匀,最小
// 间距拒绝采样(防碎片退化为细条)----
const u32 count = std::clamp(p.count, 4, 96);
const u32 nearCount = count * 2 / 5;
std::vector<glm::vec2> seeds;
seeds.reserve(count);
const f32 minD2 = p.minSeedDist * p.minSeedDist;
auto tryPush = [&](const glm::vec2& s) {
if (s.x < -hw || s.x > hw || s.y < -hh || s.y > hh) return;
for (const glm::vec2& e : seeds) {
if (glm::dot(e - s, e - s) < minD2) return;
}
seeds.push_back(s);
};
// 命中点周边:均匀盘采样(sqrt(Rand) 面积均匀)
for (u32 i = 0; i < nearCount * 12 && seeds.size() < nearCount;
++i) {
const f32 a = Rand(0.0f, glm::two_pi<f32>());
const f32 r = p.seedRadius * std::sqrt(Rand(0.0f, 1.0f));
tryPush(uvHit + r * glm::vec2{std::cos(a), std::sin(a)});
}
// 全面均匀补足(同款拒绝采样)
for (u32 i = 0; i < count * 12 && seeds.size() < count; ++i) {
tryPush({Rand(-hw, hw), Rand(-hh, hh)});
}
if (seeds.size() < 4) return; // 面太小采样失败:不碎
// ---- 每种子 → cell → 棱柱 mesh → ParsedModel ----
// 唯一代键(见 GenSeq 注释):形状每次随机;模型回收走
// Fragment.modelPath + 待卸载队列(ApplyPendingUnload)
const std::string keyBase =
"procedural://vorfrag/" +
std::to_string(GenSeq()++) + '/';
auto& assets = host.app.GetAssets();
std::vector<Engine::ModelRef> refs;
std::vector<glm::vec3> centroids; // 碎片质心(世界系,初速径向用)
std::vector<std::string> paths; // 模型缓存键(回收用,与 refs 对齐)
refs.reserve(seeds.size());
centroids.reserve(seeds.size());
paths.reserve(seeds.size());
for (size_t s = 0; s < seeds.size(); ++s) {
const std::vector<glm::vec2> cell =
VoronoiCell(seeds[s], seeds, hw, hh);
if (cell.size() < 3) {
refs.push_back({}); // 退化 cell:占位,spawn 跳过
centroids.emplace_back();
paths.emplace_back();
continue;
}
PrismMesh pm = CellToPrism(cell, hz);
glm::vec2 c2{0.0f};
for (const glm::vec2& q : cell) c2 += q;
c2 /= static_cast<f32>(cell.size());
centroids.push_back(
wallPos + wall.GetWorldRotationQuat() *
glm::vec3{c2.x, c2.y, 0.0f});
Engine::ParsedModel parsed;
parsed.path = keyBase + std::to_string(s);
Engine::ParsedMesh& mesh =
parsed.meshes.emplace_back();
mesh.vertices = std::move(pm.vertices);
mesh.lods.push_back(std::move(pm.indices));
mesh.cpuPositions = std::move(pm.cpuPositions);
mesh.name = "frag" + std::to_string(s);
mesh.params.baseColor = look.baseColor;
mesh.transparent = look.transparent;
mesh.params.emissiveFactor =
glm::vec4{look.emissive, 1.0f};
mesh.params.surface.z = look.emissiveStrength;
const Engine::ModelRef ref =
assets.LoadModel(parsed.path, std::move(parsed));
refs.push_back(ref);
paths.push_back(parsed.path);
}
// ---- spawn 碎片实体:pos/rot 复用宿主位姿(mesh 已真
// 尺寸,scale 恒 1 防 ConvexHull 顶点二次缩放),碎裂
// 瞬间完美拼合原面,动起来后沿裂纹自然分离 ----
for (size_t s = 0; s < refs.size(); ++s) {
if (!refs[s].Valid()) continue;
auto frag = host.spawn("fragment");
auto& tr = frag.GetComponent<
Engine::ECS::TransformComponent>();
tr.SetPosition(wallPos);
tr.SetRotationQuat(wall.GetWorldRotationQuat());
tr.SetScale(glm::vec3{1.0f});
auto& fmr =
frag.AddComponent<
Engine::ECS::MeshRendererComponent>();
fmr.model = refs[s];
// Dynamic MeshCollider → ConvexHull(数据源 = 刚上传
// 的 cpuPositions;Box 字段不参与 Mesh 形状)
auto& frb =
frag.AddComponent<
Engine::ECS::RigidBodyComponent>();
frb.type =
Engine::ECS::RigidBodyComponent::BodyType::Dynamic;
frb.collider =
Engine::ECS::RigidBodyComponent::ColliderType::Mesh;
frb.mass = 0.3f;
frb.friction = 0.7f;
frb.restitution = 0.05f;
// 碎裂初速(玻璃式,非爆炸):径向 = 碎片质心 - 命中
// 点,离命中点越远越接近静止(指数衰减)——只有孔洞
// 周边的碎片被冲击波推动;法线外推让碎片朝弹侧剥出
glm::vec3 dir = centroids[s] - hitPoint;
const f32 len = glm::length(dir);
if (len < 1e-3f) {
dir = glm::vec3{Rand(-1.0f, 1.0f), 0.6f,
Rand(-1.0f, 1.0f)};
} else {
dir /= len;
}
const f32 falloff = std::exp(-len * p.falloff);
const glm::vec3 vel =
dir * p.burstSpeed * falloff *
Rand(0.4f, 1.1f) +
hitNormal * p.normalPop * Rand(0.3f, 0.8f) +
glm::vec3{0.0f, p.upBias * Rand(0.0f, 0.5f),
0.0f};
// 总量上限:超限立即回收最老碎片(防泄漏的兜底闸;
// 寿命清理才是常态回收)。模型延迟一帧卸载
const u32 bits = BitsOf(frag);
if (m_Fragments.size() >=
static_cast<size_t>(p.maxFragments)) {
host.destroy(EntityFromBits(m_Fragments.front().bits));
if (!m_Fragments.front().modelPath.empty()) {
m_PendingUnload.push_back(
std::move(m_Fragments.front().modelPath));
}
m_Fragments.erase(m_Fragments.begin());
}
m_Fragments.push_back(
Fragment{bits, vel, 0.0f, false, paths[s]});
}
}
// ---- 模型回收:延迟一帧 + 批量停飞卸载(Destroy 是帧尾延迟,
// 碎片实体本帧渲染收集仍会引用模型;下一帧实体已灭,但上一帧
// 提交的命令缓冲仍可能引用顶点缓冲/sampler——批量接口先
// WaitDeviceIdle 再销毁,与资产重建换绑同一引擎先例)----
void ApplyPendingUnload(const HostView& host) {
if (m_PendingUnload.empty()) return;
host.app.GetAssets().UnloadModelsByPaths(m_PendingUnload);
m_PendingUnload.clear();
}
// ---- 施速待办:body 建好(bodyValid)即注入,勿重复 ----
void ApplyPendingVelocities(const HostView& host) {
auto& registry = host.app.GetScene().GetRegistry();
for (auto it = m_Fragments.begin();
it != m_Fragments.end();) {
if (it->applied) { ++it; continue; }
const ECS::Entity e = EntityFromBits(it->bits);
if (!registry.valid(e)) { // 实体没了(外部销毁)
// 模型是独占资源(一代一键),实体没了模型也回收
if (!it->modelPath.empty()) {
m_PendingUnload.push_back(
std::move(it->modelPath));
}
it = m_Fragments.erase(it);
continue;
}
const auto* rb = registry.try_get<
Engine::ECS::RigidBodyComponent>(e);
if (rb && rb->bodyValid) {
host.app.GetPhysics().SetLinearVelocity(
it->bits, it->velocity);
it->applied = true;
}
++it; // body 未建:留待下一帧(对账晚一帧必到)
}
}
// ---- 寿命清理:到期 Destroy(延迟队列,遍历安全)----
void AgeAndCleanup(const HostView& host, f32 dt) {
auto& registry = host.app.GetScene().GetRegistry();
for (auto it = m_Fragments.begin();
it != m_Fragments.end();) {
it->age += dt;
if (it->age < p.lifetime) { ++it; continue; }
const ECS::Entity e = EntityFromBits(it->bits);
if (registry.valid(e)) {
host.destroy(e);
}
// 模型延迟一帧卸载(本帧渲染收集还引用它)
if (!it->modelPath.empty()) {
m_PendingUnload.push_back(std::move(it->modelPath));
}
it = m_Fragments.erase(it);
}
}
struct Fragment {
u32 bits = 0; // PhysicsWorld 键(实体位模式)
glm::vec3 velocity{0.0f};// 待施初速(body 建好后注入)
f32 age = 0.0f; // 存活秒数(寿命清理用)
bool applied = false; // 初速已注入
std::string modelPath; // 独占模型缓存键(回收时按路径卸载)
};
bool m_Fired = false; // 一次性触发保护
std::vector<Fragment> m_Fragments; // 活碎片簿记
// 待卸载模型路径(碎片销毁的下一帧执行——Destroy 帧尾延迟,
// 本帧渲染收集仍引用模型,同帧卸载会渲染悬空指针)
std::vector<std::string> m_PendingUnload;
};
} // namespace Engine::Fracture
#endif //HELLOVULKAN_FRACTURE_LIB_H
+386
View File
@@ -0,0 +1,386 @@
//
// GameDirector.cpp:60 秒限时打靶场导演(玩法原型验证侧线)。
//
// 完整玩法循环(全现有能力,零引擎改动):
// 开局 → 程序化搭玻璃靶阵(行对齐网格,居中逐行收缩)→ 60 秒
// 倒计时 → 射击碎靶计分(Target 报分,跨脚本参数通道)→ 时间到
// 收靶结算 → R 重开(任意时刻可重置)
//
// 计分协议:Score/AcceptHits = 导演的声明参数——Target 经
// ScriptRuntime::GetParam/SetParam 跨实体读写(参数层即按名消息,
// 与序列化同一条路)。Score 写穿 Item.params,PIE Inspector 实时
// 反映;AcceptHits=false 后 Target 停止报分(双保险,收靶为主)。
//
// HUD:引擎暂无文字渲染(缺失能力,已记录),倒计时/得分用
// Debug 图元在世界空间凑——顶部倒计时条(绿→黄→红随剩余时间)
// + 条下一排金色得分点(1 分 = 1 点)。挂在靶阵上方,depthTest
// 关闭保证穿墙可见。
//
// 靶子模型:程序化玻璃立方(ParsedModel 手工装配,半透明 + 自发
// 光),缓存键 = 颜色/尺寸参数量化——同配置共享一份 GPU 模型,
// 跨回合复用;改参数重开局才重建。碎片几何由 FractureLib 每靶
// 现场生成(唯一代键,自管回收)。
//
// 【演示步骤】任意实体挂 GameDirector,相机实体挂 TestShoot →
// Play → RMB 飞行瞄准靶阵 → 左键射击:靶子玻璃式碎裂 + 得分点
// 亮起,倒计时条流逝,时间到结算(控制台输出得分),R 重开。
//
// 【缺失能力记录(玩法侧线的下一批依据)】
// - UI/HUD 文字(倒计时数字/得分数字只得用图元条凑)
// - 音效(碎裂/射击/倒计时提示全程无声)
// - 准星(靠弹道调试十字凑合)
// - 游戏状态机/关卡流(重开 = 脚本内手搓,无引擎级 GameMode)
//
#include <Engine/Scripting/Script.h>
#include <Engine/Core/Application.h>
#include <Engine/Core/Input.h>
#include <Engine/Core/Log.h>
#include <Engine/Core/Types.h>
#include <Engine/Debug/DebugDraw.h>
#include <Engine/Assets/AssetManager.h>
#include <Engine/Renderer/Model.h>
#include "FractureLib.h" // BitsOf/EntityFromBits(碎片簿记同款换算)
#include <glm/glm.hpp>
#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
using namespace Engine; // Input/Debug 门面 + 基础类型
namespace {
// ---- 玻璃立方 ParsedModel 装配(半透明 + 自发光;6 面 quad
// 手工展开,外向 CCW:u × v = n 定切向基)----
Engine::ParsedModel BuildGlassCube(const glm::vec3& he,
const glm::vec3& color,
const std::string& path) {
Engine::ParsedModel parsed;
parsed.path = path;
auto& mesh = parsed.meshes.emplace_back();
// 面:法线 n + 切向 u/v(u×v=n 保证外向 CCW 索引序)
static constexpr glm::vec3 kN[6] = {
{1, 0, 0}, {-1, 0, 0}, {0, 1, 0},
{0, -1, 0}, {0, 0, 1}, {0, 0, -1}};
static constexpr glm::vec3 kU[6] = {
{0, 0, -1}, {0, 0, 1}, {1, 0, 0},
{1, 0, 0}, {1, 0, 0}, {1, 0, 0}};
static constexpr glm::vec3 kV[6] = {
{0, 1, 0}, {0, 1, 0}, {0, 0, -1},
{0, 0, 1}, {0, 1, 0}, {0, 1, 0}};
// 轴向半长:轴 a 上的偏移 = a ∘ he(分量积;单位轴即取该轴
// 分量),面心 = n ∘ he
auto along = [&](const glm::vec3& a) {
return glm::vec3{a.x * he.x, a.y * he.y, a.z * he.z};
};
mesh.vertices.reserve(24);
for (int f = 0; f < 6; ++f) {
const u32 base = static_cast<u32>(mesh.vertices.size());
const glm::vec3 center = along(kN[f]);
// quad 角序:(−u,−v) (+u,−v) (+u,+v) (−u,+v)——CCW
for (int c = 0; c < 4; ++c) {
const f32 su = (c == 1 || c == 2) ? 1.0f : -1.0f;
const f32 sv = (c >= 2) ? 1.0f : -1.0f;
const glm::vec3 pos = center + su * along(kU[f]) +
sv * along(kV[f]);
const Engine::Vertex v{
pos, kN[f],
{(su + 1.0f) * 0.5f, (sv + 1.0f) * 0.5f}, kU[f]};
mesh.vertices.push_back(v);
}
mesh.lods.emplace_back();
auto& idx = mesh.lods.back();
idx.insert(idx.end(),
{base, base + 1, base + 2,
base, base + 2, base + 3});
}
// 玻璃材质:低金属度高光洁 + alpha 混合 + 靶色自发光
mesh.name = "target";
mesh.params.factors = {0.05f, 0.15f, 0.0f, 0.0f};
mesh.params.baseColor = glm::vec4{color, 0.42f};
mesh.params.surface = {1.5f, 0.0f, 2.0f, 0.0f};
mesh.params.emissiveFactor = glm::vec4{color * 0.85f, 1.0f};
mesh.transparent = true;
return parsed;
}
// ---- 打靶场导演 ----
class GameDirector final : public Engine::Scripting::ScriptBehavior {
public:
const char* Name() const override { return "GameDirector"; }
void DeclareParams(Engine::Scripting::ParamBuilder& pb) override {
// ---- 回合规则 ----
pb.Field("RoundTime", &m_RoundTime, 60.0f); // 局时长(秒)
pb.Field("TargetCount", &m_TargetCount, 5); // 靶数
pb.Field("Score", &m_Score, 0); // 得分(Target 报分写这里)
pb.Field("AcceptHits", &m_AcceptHits, true); // 计分门(Target 读)
// ---- 靶阵布局(Center = 底行中心)----
pb.Field("Center", &m_Center,
glm::vec3{0.0f, 1.5f, -6.0f});
pb.Field("PerRow", &m_PerRow, 5); // 每行靶数
pb.Field("Spacing", &m_Spacing, 1.2f); // 行/列间距 m
// ---- 靶子规格 ----
pb.Field("TargetSize", &m_TargetSize,
glm::vec3{0.7f, 0.7f, 0.2f}); // 单靶尺寸 m(厚 ≥0.2 防高速弹隧穿)
pb.Field("TargetColor", &m_TargetColor,
glm::vec3{1.0f, 0.3f, 0.25f}); // 玻璃色
pb.Field("FragCount", &m_FragCount, 22); // 每靶碎片数(写进 Target)
// ---- HUD ----
pb.Field("HudHeight", &m_HudHeight, 1.6f); // HUD 高于靶阵中心 m
}
void OnStart() override { StartRound(); }
void OnUpdate(f32 dt) override {
// R 上升沿重开(整局任意时刻可重置)
const bool rDown = Input::IsKeyDown(Input::Key::R);
const bool restart = rDown && !m_RWasDown;
m_RWasDown = rDown;
if (restart) {
StartRound();
return;
}
if (m_AcceptHits) {
m_TimeLeft -= dt;
if (m_TimeLeft <= 0.0f) {
m_TimeLeft = 0.0f;
EndRound();
}
}
DrawHud();
}
// PIE stop:玻璃靶模型是本脚本独占资产(参数键缓存),批量
// 停飞卸载(同 WallFracture 碎片模型回收先例)
void OnDestroy() override {
if (!m_ModelKey.empty()) {
std::vector<std::string> paths{m_ModelKey};
App().GetAssets().UnloadModelsByPaths(paths);
m_ModelKey.clear();
m_TargetModel = {};
}
}
private:
// ---- 开局:清场 → 建模型 → spawn 靶阵 → 计时/计分归零 ----
void StartRound() {
DestroyRemaining();
EnsureTargetModel();
SpawnTargets();
m_Score = 0;
SetParam("Score", Engine::Scripting::ParamValue{0});
m_TimeLeft = m_RoundTime;
m_AcceptHits = true;
SetParam("AcceptHits", Engine::Scripting::ParamValue{true});
ENGINE_INFO("[GameDirector] 回合开始:{} 个靶 / {} 秒",
m_TargetCount, m_RoundTime);
}
// ---- 结算:停计分 → 收掉没打碎的靶 → 报分 ----
void EndRound() {
m_AcceptHits = false;
SetParam("AcceptHits", Engine::Scripting::ParamValue{false});
DestroyRemaining();
ENGINE_INFO("[GameDirector] 时间到!得分 {}/{}(R 重开)",
m_Score, m_TargetCount);
}
// ---- 靶阵:行对齐网格,每行水平居中,行向上堆 ----
void SpawnTargets() {
const int count = std::max(1, m_TargetCount);
const int perRow = std::max(1, m_PerRow);
for (int i = 0; i < count; ++i) {
const int row = i / perRow;
const int col = i % perRow;
const int inRow = std::min(count - row * perRow, perRow);
glm::vec3 pos = m_Center;
pos.y += static_cast<f32>(row) * m_Spacing;
pos.x += static_cast<f32>(col) * m_Spacing -
static_cast<f32>(inRow - 1) * m_Spacing * 0.5f;
SpawnTarget(pos);
}
}
void SpawnTarget(const glm::vec3& pos) {
auto t = Spawn("target");
auto& tr = t.GetComponent<Engine::ECS::TransformComponent>();
tr.SetPosition(pos); // scale 1:尺寸烘焙进模型与碰撞体
auto& mr =
t.AddComponent<Engine::ECS::MeshRendererComponent>();
mr.model = m_TargetModel;
// Static Box:厚 ≥0.2m 时对 25m/s 无 CCD 弹保证检出
//(检测带 2*(弹半径0.12+靶半厚0.1)=0.44m > 每步位移0.42m)
auto& rb = t.AddComponent<Engine::ECS::RigidBodyComponent>();
rb.type = Engine::ECS::RigidBodyComponent::BodyType::Static;
rb.collider = Engine::ECS::RigidBodyComponent::ColliderType::Box;
rb.boxHalfExtents = m_TargetSize * 0.5f;
rb.friction = 0.5f;
rb.restitution = 0.1f;
// Target 脚本项:计分去向 + 靶色/碎片档经参数层传入
auto& sc = t.AddComponent<Engine::ECS::ScriptComponent>();
Engine::ECS::ScriptComponent::Item item;
item.name = "Target";
item.params.push_back(
{"Director",
Engine::Scripting::ParamValue{
static_cast<i32>(EntityBits())}});
item.params.push_back(
{"Color", Engine::Scripting::ParamValue{m_TargetColor}});
item.params.push_back(
{"Count", Engine::Scripting::ParamValue{m_FragCount}});
sc.items.push_back(std::move(item));
m_TargetBits.push_back(Engine::Fracture::BitsOf(t));
}
void DestroyRemaining() {
auto& registry = App().GetScene().GetRegistry();
for (const u32 bits : m_TargetBits) {
const ECS::Entity e =
Engine::Fracture::EntityFromBits(bits);
if (registry.valid(e)) {
Destroy(e); // 延迟队列,帧尾统一执行
}
}
m_TargetBits.clear();
}
// ---- 玻璃靶模型:参数量化缓存键(同配置共享一份 GPU 模型,
// 跨回合复用;改色/改尺寸后下次开局重建。注:键按"一个导演
// 实例独占"设计,双导演同参数会互踩——原型的合理取舍)----
void EnsureTargetModel() {
const auto q = [](f32 c) {
const int b = static_cast<int>(
std::clamp(c, 0.0f, 1.0f) * 255.0f + 0.5f);
return std::clamp(b, 0, 255);
};
// 尺寸量化:分米精度(0.1m 步进;同尺寸档共享模型)
const auto qs = [](f32 v) {
return std::clamp(
static_cast<int>(std::max(v, 0.0f) * 10.0f + 0.5f),
0, 999);
};
char hex[96];
std::snprintf(hex, sizeof(hex),
"procedural://rangetarget/%02X%02X%02X_"
"%d_%d_%d",
q(m_TargetColor.r), q(m_TargetColor.g),
q(m_TargetColor.b),
qs(m_TargetSize.x), qs(m_TargetSize.y),
qs(m_TargetSize.z));
const std::string key = hex;
if (key == m_ModelKey && m_TargetModel.Valid()) return;
// 配置变了:旧键独占资产先回收
if (m_TargetModel.Valid()) {
std::vector<std::string> old{m_ModelKey};
App().GetAssets().UnloadModelsByPaths(old);
m_TargetModel = {};
}
auto parsed = BuildGlassCube(m_TargetSize * 0.5f,
m_TargetColor, key);
m_TargetModel = App().GetAssets().LoadModel(
key, std::move(parsed));
m_ModelKey = key;
}
// ---- HUD(图元凑,无文字是已知缺失能力):倒计时条
//(绿→黄→红)+ 得分点(1 分 = 1 金点);结算态整条红 ----
void DrawHud() {
const glm::vec3 base =
m_Center + glm::vec3{0.0f, m_HudHeight, 0.0f};
constexpr f32 kBarW = 3.0f;
const f32 left = base.x - kBarW * 0.5f;
const glm::vec3 z{0.0f, 0.0f, base.z};
const f32 frac = m_AcceptHits
? std::clamp(m_TimeLeft / std::max(m_RoundTime, 0.01f),
0.0f, 1.0f)
: 0.0f;
// 颜色:>50% 绿→黄,≤50% 黄→红
const glm::vec4 green{0.24f, 0.86f, 0.52f, 1.0f};
const glm::vec4 yellow{1.0f, 0.84f, 0.31f, 1.0f};
const glm::vec4 red{1.0f, 0.32f, 0.32f, 1.0f};
const glm::vec4 col = frac > 0.5f
? glm::mix(yellow, green, (frac - 0.5f) * 2.0f)
: glm::mix(red, yellow, frac * 2.0f);
const glm::vec4 dim{0.25f, 0.25f, 0.25f, 1.0f};
// 外框 + 端点刻度
Debug::Line({left, base.y, base.z}, {left + kBarW, base.y, base.z},
dim, 2.0f, 0.0f, /*depthTest*/ false);
Debug::Line({left, base.y - 0.08f, base.z},
{left, base.y + 0.08f, base.z},
dim, 2.0f, 0.0f, false);
Debug::Line({left + kBarW, base.y - 0.08f, base.z},
{left + kBarW, base.y + 0.08f, base.z},
dim, 2.0f, 0.0f, false);
if (m_AcceptHits) {
if (frac > 0.003f) {
Debug::Line({left, base.y, base.z},
{left + kBarW * frac, base.y, base.z},
col, 8.0f, 0.0f, false);
}
} else {
// 结算态:整条红 + 上方短刻线(与运行态一眼区分)
Debug::Line({left, base.y, base.z},
{left + kBarW, base.y, base.z},
red, 4.0f, 0.0f, false);
Debug::Line({left, base.y + 0.14f, base.z},
{left + kBarW, base.y + 0.14f, base.z},
red, 2.0f, 0.0f, false);
}
// 得分点:1 分 = 1 金点(封顶 24 防溢出条外)
const glm::vec4 gold{1.0f, 0.78f, 0.2f, 1.0f};
const int pips = std::min<i32>(m_Score, 24);
for (i32 i = 0; i < pips; ++i) {
const f32 px = left + 0.18f +
static_cast<f32>(i) * 0.24f;
Debug::Cross({px, base.y - 0.3f, base.z}, 0.05f,
gold, 2.0f, 0.0f, false);
}
}
// ---- 回合状态 ----
f32 m_RoundTime = 60.0f;
i32 m_TargetCount = 5;
i32 m_Score = 0;
bool m_AcceptHits = true; // 计分门(参数成员,Target 跨脚本读写)
f32 m_TimeLeft = 60.0f;
bool m_RWasDown = false; // R 上升沿基准
// ---- 布局/规格 ----
glm::vec3 m_Center{0.0f, 1.5f, -6.0f};
i32 m_PerRow = 5;
f32 m_Spacing = 1.2f;
glm::vec3 m_TargetSize{0.7f, 0.7f, 0.2f};
glm::vec3 m_TargetColor{1.0f, 0.3f, 0.25f};
i32 m_FragCount = 22;
f32 m_HudHeight = 1.6f;
// ---- 靶子簿记 ----
std::vector<u32> m_TargetBits; // 本局靶实体(重开收靶用)
Engine::ModelRef m_TargetModel; // 玻璃立方(参数键缓存共享)
std::string m_ModelKey;
};
} // namespace
ENGINE_REGISTER_SCRIPT(GameDirector)
+118
View File
@@ -0,0 +1,118 @@
//
// Target.cpp:打靶场玻璃靶子脚本(侧线"玩法原型验证")。
//
// 职责(挂靶子实体:MeshRenderer 玻璃立方 + RigidBody Static Box +
// 本脚本;通常由 GameDirector 运行时成批 spawn,也可编辑器手动摆):
// OnCollisionEnter → 玻璃外观套用(半透明 + 靶色自发光)→
// Fracture::Burst::TryFire(一次性保护 + Dynamic 速度校验 +
// 藏靶 + Voronoi 切分 + spawn 玻璃碎片群,共享库内完成)
// ReportScore:向 Director 实体的 GameDirector 脚本报分(Score +1,
// 写穿 Item.params,PIE Inspector 实时反映);Director=0 表示
// 独立靶不计分;导演 AcceptHits=false(回合已结束)不报
// OnUpdate/OnDestroy → Burst::Tick/Shutdown(碎片施速/寿命/回收)
//
// 覆盖的引擎设施:跨脚本参数访问(ScriptRuntime::GetParam/SetParam,
// GameDirector ↔ Target 的计分通道)、共享碎裂库(FractureLib.h,
// 与 WallFracture 同一条碎片路径)、玻璃材质(透明队列 + 自发光)。
//
// 演示步骤:任意实体挂 GameDirector → Play → 左键射击靶阵:
// 靶子玻璃式碎裂 + 计分,60 秒倒计时,时间到结算,R 重开。
//
#include <Engine/Scripting/Script.h>
#include <Engine/Scripting/ScriptRuntime.h> // GetScripts() 跨脚本参数访问
#include <Engine/Core/Types.h>
#include "FractureLib.h"
using namespace Engine; // i32/f32/u32 基础类型(同 WallFracture)
namespace {
// ---- 玻璃靶子 ----
class Target final : public Engine::Scripting::ScriptBehavior {
public:
const char* Name() const override { return "Target"; }
void DeclareParams(Engine::Scripting::ParamBuilder& pb) override {
// 击碎报分去向:Director 实体 bits(0 = 独立靶不计分);
// GameDirector spawn 时写进脚本项参数
pb.Field("Director", &m_Director, 0);
// 靶色(玻璃染色 + 自发光基色;碎片同色系)
pb.Field("Color", &m_Color,
glm::vec3{1.0f, 0.3f, 0.25f});
// 碎裂参数(同 WallFracture 语义;靶面小 → 种子更密)
pb.Field("Count", &m_Burst.p.count, 22);
pb.Field("SeedRadius", &m_Burst.p.seedRadius, 0.35f);
pb.Field("MinSeedDist", &m_Burst.p.minSeedDist, 0.07f);
pb.Field("BurstSpeed", &m_Burst.p.burstSpeed, 1.6f);
pb.Field("Falloff", &m_Burst.p.falloff, 2.0f);
pb.Field("NormalPop", &m_Burst.p.normalPop, 0.6f);
pb.Field("UpBias", &m_Burst.p.upBias, 0.4f);
pb.Field("Lifetime", &m_Burst.p.lifetime, 6.0f);
pb.Field("MaxFragments", &m_Burst.p.maxFragments, 96);
pb.Field("MinImpactSpeed", &m_Burst.p.minImpactSpeed, 2.0f);
}
void OnCollisionEnter(u32 other, const glm::vec3& point,
const glm::vec3& normal) override {
// 玻璃外观:半透明 + 靶色自发光(Fire 前套用,碎片沿用;
// 每次覆写代价可忽略,且吃 Inspector 实时改色)
m_Burst.look.baseColor = glm::vec4{m_Color, 0.45f};
m_Burst.look.transparent = true;
m_Burst.look.emissive = m_Color * 0.7f;
m_Burst.look.emissiveStrength = 2.0f;
if (m_Burst.TryFire(HV(), other, point, normal)) {
ReportScore();
}
}
void OnUpdate(f32 dt) override { m_Burst.Tick(HV(), dt); }
// 靶销毁(PIE stop / 重开 / 手动删除):碎片模型批量回收
void OnDestroy() override { m_Burst.Shutdown(HV()); }
private:
// 库桥接:本成员函数内可访问 protected 基类设施(App/Spawn/
// Destroy),库内只走公共 API——见 FractureLib.h HostView 注
Engine::Fracture::HostView HV() {
return {App(), EntityBits(),
[this](const std::string& n) { return Spawn(n); },
[this](Engine::ECS::Entity e) { Destroy(e); }};
}
// 击碎报分:读导演 AcceptHits 门(回合结束不报)→ Score +1。
// 参数层 = 跨脚本通信协议(与序列化同一条路,C# 反射缺失下
// 的按名消息);导演实例未建(罕见:同帧开局碰撞)时忽略
void ReportScore() {
if (m_Director == 0) return;
auto& scripts = App().GetScripts();
const u32 director = static_cast<u32>(m_Director);
// 参数层按名访问(ScriptRuntime 只认 ParamValue 变体,
// 取值自行 std::get_if 校型)
using Engine::Scripting::ParamValue;
ParamValue v{true};
if (scripts.GetParam(director, "GameDirector",
"AcceptHits", v)) {
if (const auto* b = std::get_if<bool>(&v.v); b && !*b) {
return; // 回合已结束(Director 收靶前的漏网命中)
}
}
i32 score = 0;
if (scripts.GetParam(director, "GameDirector", "Score", v)) {
if (const auto* s = std::get_if<i32>(&v.v)) {
score = *s;
}
}
scripts.SetParam(director, "GameDirector", "Score",
ParamValue{static_cast<i32>(score + 1)});
}
i32 m_Director = 0; // 导演实体 bits(0 = 独立靶)
glm::vec3 m_Color{1.0f, 0.3f, 0.25f};
Engine::Fracture::Burst m_Burst; // 碎片簿记(look 每次命中前套)
};
} // namespace
ENGINE_REGISTER_SCRIPT(Target)
+37 -497
View File
@@ -1,44 +1,15 @@
//
// WallFracture.cpp:侧线A 验收脚本——可碎墙 + 碎片刚体群 + 寿命清理。
// WallFracture.cpp:可碎墙脚本——碎裂逻辑已提取至 FractureLib.h
//(打靶场 Target 复用同一条路径),本文件只剩宿主职责:
// DeclareParams 直绑 Fracture::Params(参数名/默认值与历史档位
// 逐一同名同值,旧场景不受影响)
// OnCollisionEnter → Burst::TryFire(一次性保护 + 对方 Dynamic
// 速度校验 + 藏墙 + Voronoi 切分 + spawn 碎片群,库内完成)
// OnUpdate → Burst::Tick(施速待办 / 寿命清理 / 模型回收)
// OnDestroy → Burst::Shutdown(独占碎片模型批量停飞卸载)
//
// 碎裂语义 = 玻璃/陶瓷式("碎掉"),非爆炸飞散,碎片几何 = 2D
// Voronoi 分割:
// 碎裂瞬间在墙面上撒种子点(命中点周边加密 → 孔洞周围碎片小、
// 远处碎片大,玻璃碎的典型分布),对每颗种子做垂直平分线半平面
// 交集得到不规则凸多边形 cell,沿墙厚挤出成凸棱柱——碎片在碎裂
// 前一刻完美拼合成整面墙,碎开后沿裂纹分离。碰撞形状不走 Box,
// 直接用 MeshCollider(Dynamic → Jolt ConvexHull,数据源 = 网格
// CPU 位置副本,PhysicsWorld::BuildMeshShape)→ 不规则碎片连
// 碰撞体都是真的,落地翻滚/堆积接触自然。
//
// 初速 = 径向 × 距离指数衰减(只有命中点周边的碎片被推动,远处
// 几乎原地悬置)+ 沿命中法线轻微外推(朝弹侧剥落)+ 微弱上抛
// ——重力是塌落的主导,整体呈"裂纹分离 → 孔洞塌落 → 碎块堆在
// 墙脚"的观感。
//
// WallFracture(挂墙实体:Static Box 刚体 + MeshRenderer 立方体):
// OnCollisionEnter(Dynamic 体以足够速度命中)→ 触发一次:
// 藏墙(MeshRenderer/RigidBody.active = false,PhysicsWorld 对账
// 自然消化:不渲染 + body 销毁)→ Voronoi 切分 → 每碎片运行时
// 构建独立 ParsedModel(AssetManager::LoadModel(path, parsed)
// 同步 GPU 构建,缓存去重)+ spawn 碎片实体
// OnUpdate:碎片簿记——body 建好(下一帧 Step 对账,同 Bullet
// OnCreate 施速的时序契约)即统一施加初速;寿命到期统一 Destroy;
// 总量上限防泄漏
// 碎片模型生命周期:每代唯一缓存键(形状每次随机)+ 碎片销毁的
// 下一帧 UnloadModelByPath 回收(Destroy 帧尾延迟,本帧渲染收集
// 仍引用模型,同帧卸载 = 渲染悬空)+ OnDestroy 兜底
//
// 占位局限(真碎片资产接入后本路径退役):碎片网格顶点按墙世界
// 尺寸烘焙、碎片实体复用墙 world pos/rot(scale 恒 1,防 ConvexHull
// 顶点二次缩放)——墙须无旋转摆放。
//
// 覆盖的引擎设施:
// - Spawn()/Destroy():帧内即时创建 / 帧尾延迟销毁
// - 组件运行时装配:MeshRenderer(运行时 ParsedModel 程序化资产)+
// RigidBody(MeshCollider → ConvexHull;active=false 对账销毁)
// - OnCollisionEnter:命中点/法线 + 对方实线速度查询
// - SetLinearVelocity:body 建好后的初速注入
// 外观默认 = 砖色不透明(原观感);玻璃/发光碎片换 Target.cpp 的
// 用法(Fire 前改 Burst::look)。
//
// 验收步骤:编辑器摆一堵墙(MeshRenderer = procedural://cube +
// RigidBody Static Box + 挂 WallFracture,缩放即墙尺寸)→ 场景里
@@ -48,164 +19,15 @@
//
#include <Engine/Scripting/Script.h>
#include <Engine/Core/Application.h>
#include <Engine/Core/Types.h>
#include <Engine/Assets/AssetManager.h> // LoadModel(门面只前向声明)
#include <Engine/Renderer/Model.h> // ParsedModel/ParsedMesh(程序化碎片几何)
#include <Engine/Renderer/Vertex.h> // Vertex 聚合布局
#include <Engine/Physics/PhysicsWorld.h>
#include <glm/glm.hpp>
#include <glm/gtc/quaternion.hpp>
#include "FractureLib.h"
#include <algorithm>
#include <cmath>
#include <random>
using namespace Engine; // u32/f32/Entity 等基础类型 + 门面(同 TestShoot)
using namespace Engine; // u32/f32/i32 基础类型(历史脚本同款)
namespace {
// bits(PhysicsWorld 键)→ 实体(还原位模式;用前自行 valid 校验)
ECS::Entity EntityFromBits(u32 bits) {
return static_cast<ECS::Entity>(bits);
}
// 碎片模型代序号(全局递增):每代碎片一组唯一缓存键 → 每次
// 命中都重新随机生成形状(命中点量化键会让同位置重复命中
// 复用首次几何——缓存去重把新几何丢弃,碎片就"固定"了)
u32 s_GenSeq = 0;
// 实体句柄 → bits(EntityBits() 只覆盖自身绑定实体,碎片簿记
// 需要任意实体的同款换算)
u32 BitsOf(const ECS::EntityHandle& e) {
return static_cast<u32>(
entt::to_integral(static_cast<entt::entity>(e)));
}
// ---- 2D Voronoi(碎裂裂纹)----
//
// cell = 相对其它种子的垂直平分线半平面交集,初始域 = 墙面矩形。
// Sutherland–Hodgman 凸多边形裁剪:保留靠近 p 的一侧
//(dot(a,x) <= b,a = normalize(q-p),b = dot(a,p)+|q-p|/2)。
// 输入多边形须 CCW(初始矩形即 CCW,裁剪保序)。
std::vector<glm::vec2> ClipByBisector(std::vector<glm::vec2> poly,
const glm::vec2& p,
const glm::vec2& q) {
const glm::vec2 d = q - p;
const f32 len = glm::length(d);
if (len < 1e-6f) return poly;
const glm::vec2 a = d / len;
const f32 b = glm::dot(a, p) + 0.5f * len;
std::vector<glm::vec2> out;
out.reserve(poly.size() + 1);
for (size_t i = 0; i < poly.size(); ++i) {
const glm::vec2& c = poly[i];
const glm::vec2& n = poly[(i + 1) % poly.size()];
const f32 dc = glm::dot(a, c) - b;
const f32 dn = glm::dot(a, n) - b;
if (dc <= 0.0f) out.push_back(c);
if ((dc < 0.0f) != (dn < 0.0f)) { // 边跨越裁剪线 → 求交点
const f32 t = dc / (dc - dn);
out.push_back(c + t * (n - c));
}
}
return out;
}
// 种子 p 的 Voronoi cell(域 = [-hw,hw]×[-hh,hh] 的墙面矩形)
std::vector<glm::vec2> VoronoiCell(const glm::vec2& p,
const std::vector<glm::vec2>& seeds,
f32 hw, f32 hh) {
std::vector<glm::vec2> poly = {
{-hw, -hh}, {hw, -hh}, {hw, hh}, {-hw, hh}};
for (const glm::vec2& q : seeds) {
if (glm::dot(q - p, q - p) < 1e-10f) continue; // 自身
poly = ClipByBisector(std::move(poly), p, q);
if (poly.size() < 3) return {}; // 退化(最小间距保护,罕见)
}
return poly;
}
// cell(CCW 凸多边形,墙局部 XY)沿 z ∈ [-hz, +hz] 挤出成棱柱:
// 顶/底面 fan 三角化 + 侧面逐边 quad,per-face 法线。返回 mesh
// 数据;cpuPositions 单独回传(ConvexHull 物理数据源)。
struct PrismMesh {
std::vector<Engine::Vertex> vertices;
std::vector<u32> indices;
std::vector<glm::vec3> cpuPositions;
};
PrismMesh CellToPrism(const std::vector<glm::vec2>& cell, f32 hz) {
PrismMesh m;
const size_t n = cell.size();
const size_t topBase = 0;
const size_t botBase = n;
m.vertices.reserve(n * 2);
for (const glm::vec2& p : cell) {
const Engine::Vertex v{
{p.x, p.y, hz}, {0.0f, 0.0f, 1.0f},
{p.x, p.y}, {1.0f, 0.0f, 0.0f}};
m.vertices.push_back(v);
}
for (const glm::vec2& p : cell) {
const Engine::Vertex v{
{p.x, p.y, -hz}, {0.0f, 0.0f, -1.0f},
{p.x, p.y}, {1.0f, 0.0f, 0.0f}};
m.vertices.push_back(v);
}
// top fan(CCW 从 +Z 看朝外)+ bottom fan(反向)
for (size_t i = 1; i + 1 < n; ++i) {
m.indices.push_back(topBase);
m.indices.push_back(topBase + i);
m.indices.push_back(topBase + i + 1);
m.indices.push_back(botBase);
m.indices.push_back(botBase + i + 1);
m.indices.push_back(botBase + i);
}
// 侧面:(i, N+i, N+i+1, i+1)——外法线 = CCW 边的右侧法向
for (size_t i = 0; i < n; ++i) {
const size_t j = (i + 1) % n;
const glm::vec2 e = cell[j] - cell[i];
const f32 el = glm::length(e);
const glm::vec3 sn = el > 1e-6f
? glm::vec3{e.y / el, -e.x / el, 0.0f}
: glm::vec3{0.0f, -1.0f, 0.0f};
const size_t b = m.vertices.size();
const Engine::Vertex q0{
{cell[i].x, cell[i].y, hz}, sn,
{0.0f, 0.0f}, {1.0f, 0.0f, 0.0f}};
const Engine::Vertex q1{
{cell[i].x, cell[i].y, -hz}, sn,
{0.0f, 1.0f}, {1.0f, 0.0f, 0.0f}};
const Engine::Vertex q2{
{cell[j].x, cell[j].y, -hz}, sn,
{1.0f, 1.0f}, {1.0f, 0.0f, 0.0f}};
const Engine::Vertex q3{
{cell[j].x, cell[j].y, hz}, sn,
{1.0f, 0.0f}, {1.0f, 0.0f, 0.0f}};
m.vertices.push_back(q0);
m.vertices.push_back(q1);
m.vertices.push_back(q2);
m.vertices.push_back(q3);
m.indices.insert(m.indices.end(),
{u32(b), u32(b + 1), u32(b + 2),
u32(b), u32(b + 2), u32(b + 3)});
}
// 物理凸包数据源(BuildMeshShape Dynamic 分支逐顶点收集)
m.cpuPositions.reserve(m.vertices.size());
for (const auto& v : m.vertices) {
m.cpuPositions.push_back(v.position);
}
return m;
}
// ---- 可碎墙(挂墙实体;触发后自管一整批碎片)----
// ---- 可碎墙(挂墙实体;触发后由 Burst 管一整批碎片)----
class WallFracture final : public Engine::Scripting::ScriptBehavior {
public:
const char* Name() const override { return "WallFracture"; }
@@ -213,327 +35,45 @@ namespace {
void DeclareParams(Engine::Scripting::ParamBuilder& pb) override {
// Voronoi 种子数 = 碎片数(40% 在命中点半径内加密 → 孔洞
// 周边碎块小、边缘大块的玻璃碎分布;最小间距防碎片退化)
pb.Field("Count", &m_Count, 30);
pb.Field("SeedRadius", &m_SeedRadius, 0.9f); // 加密半径 m
pb.Field("MinSeedDist", &m_MinSeedDist, 0.12f); // 种子最小间距 m
pb.Field("Count", &m_P.count, 30);
pb.Field("SeedRadius", &m_P.seedRadius, 0.9f); // 加密半径 m
pb.Field("MinSeedDist", &m_P.minSeedDist, 0.12f); // 种子最小间距 m
// 玻璃/陶瓷式碎裂(裂纹分离 + 重力塌落),非爆炸飞散:
// 初速 = 命中点径向 × 距离指数衰减(近动远不动)+ 法线
// 轻微外推(剥落)+ 微弱上抛;重力是塌落的主导
pb.Field("BurstSpeed", &m_BurstSpeed, 1.2f); // 近点径向初速 m/s
pb.Field("Falloff", &m_Falloff, 1.0f); // 距离衰减率 1/m
pb.Field("NormalPop", &m_NormalPop, 0.4f); // 法线外推 m/s
pb.Field("UpBias", &m_UpBias, 0.3f); // 上抛偏置 m/s
pb.Field("Lifetime", &m_Lifetime, 6.0f); // 碎片寿命(秒)
pb.Field("MaxFragments", &m_MaxFragments, 128); // 活碎片上限
pb.Field("MinImpactSpeed", &m_MinImpactSpeed, 2.0f); // 触发阈值
pb.Field("BurstSpeed", &m_P.burstSpeed, 1.2f); // 近点径向初速 m/s
pb.Field("Falloff", &m_P.falloff, 1.0f); // 距离衰减率 1/m
pb.Field("NormalPop", &m_P.normalPop, 0.4f); // 法线外推 m/s
pb.Field("UpBias", &m_P.upBias, 0.3f); // 上抛偏置 m/s
pb.Field("Lifetime", &m_P.lifetime, 6.0f); // 碎片寿命(秒)
pb.Field("MaxFragments", &m_P.maxFragments, 128); // 活碎片上限
pb.Field("MinImpactSpeed", &m_P.minImpactSpeed, 2.0f); // 触发阈值
}
void OnCollisionEnter(u32 other, const glm::vec3& point,
const glm::vec3& normal) override {
if (m_Fired) return;
// 触发条件:对方是活的 Dynamic 刚体且速度够快(过滤
// 静止贴靠/角色轻碰——子弹/抛体必中)
auto& scene = App().GetScene();
const ECS::Entity otherE = EntityFromBits(other);
if (!scene.GetRegistry().valid(otherE)) return;
const auto* otherRb =
scene.GetRegistry().try_get<Engine::ECS::RigidBodyComponent>(
otherE);
if (!otherRb ||
otherRb->type !=
Engine::ECS::RigidBodyComponent::BodyType::Dynamic) {
return;
}
const f32 speed = glm::length(
App().GetPhysics().GetLinearVelocity(other));
if (speed < m_MinImpactSpeed) return;
Fire(point, normal);
// 触发条件在库内:对方须是活的 Dynamic 刚体且速度够快
//(过滤静止贴靠/角色轻碰——子弹/抛体必中)
m_Burst.TryFire(HV(), other, point, normal);
}
void OnUpdate(f32 dt) override {
ApplyPendingUnload();
ApplyPendingVelocities();
AgeAndCleanup(dt);
}
void OnUpdate(f32 dt) override { m_Burst.Tick(HV(), dt); }
// 墙销毁(PIE stop 换血 / 手动删除):碎片实体由场景清亡处理,
// 独占碎片模型统一批量回收(模型不被快照恢复的场景引用——
// 唯一代键;批量接口内部 GPU 停飞后再销毁)
void OnDestroy() override {
std::vector<std::string> paths;
paths.reserve(m_Fragments.size() + m_PendingUnload.size());
for (const Fragment& f : m_Fragments) {
if (!f.modelPath.empty()) paths.push_back(f.modelPath);
}
m_Fragments.clear();
paths.insert(paths.end(),
std::make_move_iterator(m_PendingUnload.begin()),
std::make_move_iterator(m_PendingUnload.end()));
m_PendingUnload.clear();
App().GetAssets().UnloadModelsByPaths(paths);
}
// 独占碎片模型统一批量回收
void OnDestroy() override { m_Burst.Shutdown(HV()); }
private:
// ---- 触发碎裂:藏墙 + Voronoi 切分 + spawn 碎片群 ----
void Fire(const glm::vec3& hitPoint, const glm::vec3& hitNormal) {
m_Fired = true;
// 藏墙:渲染/拾取跳过 + body 对账销毁(组件级 active,
// 与 Unity Renderer.enabled + Rigidbody 摘除同语义)
if (auto* mr = TryGetComponent<
Engine::ECS::MeshRendererComponent>()) {
mr->active = false;
}
if (auto* rb = TryGetComponent<
Engine::ECS::RigidBodyComponent>()) {
rb->active = false;
// 库桥接:本成员函数内可访问 protected 基类设施(App/Spawn/
// Destroy),库内只走公共 API——见 FractureLib.h HostView 注
Engine::Fracture::HostView HV() {
return {App(), EntityBits(),
[this](const std::string& n) { return Spawn(n); },
[this](Engine::ECS::Entity e) { Destroy(e); }};
}
// 墙几何:碎片网格顶点按墙世界尺寸烘焙(墙面 = XY 平面,
// 厚度沿 Z;墙须无旋转摆放,见头注释占位局限)
const auto& wall = Transform();
const glm::vec3 wallPos = wall.GetWorldPosition();
const glm::vec3 wallSize = wall.GetWorldScale();
const f32 hw = 0.5f * wallSize.x;
const f32 hh = 0.5f * wallSize.y;
const f32 hz = 0.5f * wallSize.z;
// 命中点 → 墙局部(逆旋转 + 平移),XY 即裂纹平面坐标
const glm::quat invRot = glm::conjugate(
wall.GetWorldRotationQuat());
const glm::vec3 localHit = invRot * (hitPoint - wallPos);
const glm::vec2 uvHit{localHit.x, localHit.y};
// ---- 种子:40% 命中点半径内加密 + 60% 全墙均匀,最小
// 间距拒绝采样(防碎片退化为细条)----
const u32 count = std::clamp(m_Count, 4, 96);
const u32 nearCount = count * 2 / 5;
std::vector<glm::vec2> seeds;
seeds.reserve(count);
const f32 minD2 = m_MinSeedDist * m_MinSeedDist;
auto tryPush = [&](const glm::vec2& s) {
if (s.x < -hw || s.x > hw || s.y < -hh || s.y > hh) return;
for (const glm::vec2& e : seeds) {
if (glm::dot(e - s, e - s) < minD2) return;
}
seeds.push_back(s);
};
// 命中点周边:均匀盘采样(sqrt(Rand) 面积均匀)
for (u32 i = 0; i < nearCount * 12 && seeds.size() < nearCount;
++i) {
const f32 a = Rand(0.0f, glm::two_pi<f32>());
const f32 r = m_SeedRadius * std::sqrt(Rand(0.0f, 1.0f));
tryPush(uvHit + r * glm::vec2{std::cos(a), std::sin(a)});
}
// 全墙均匀补足(同款拒绝采样)
for (u32 i = 0; i < count * 12 && seeds.size() < count; ++i) {
tryPush({Rand(-hw, hw), Rand(-hh, hh)});
}
if (seeds.size() < 4) return; // 墙面太小采样失败:不碎
// ---- 每种子 → cell → 棱柱 mesh → ParsedModel ----
// 唯一代键(见 s_GenSeq 注释):形状每次随机;模型回收走
// Fragment.modelPath + 待卸载队列(ApplyPendingUnload)
const std::string keyBase =
"procedural://vorfrag/" +
std::to_string(s_GenSeq++) + '/';
// 砖色 baseColor(glTF baseColorFactor 语义:rgb 乘进 albedo)
const glm::vec4 brick{140.0f / 255.0f, 92.0f / 255.0f,
72.0f / 255.0f, 1.0f};
auto& assets = App().GetAssets();
std::vector<Engine::ModelRef> refs;
std::vector<glm::vec3> centroids; // 碎片质心(世界系,初速径向用)
std::vector<std::string> paths; // 模型缓存键(回收用,与 refs 对齐)
refs.reserve(seeds.size());
centroids.reserve(seeds.size());
paths.reserve(seeds.size());
for (size_t s = 0; s < seeds.size(); ++s) {
const std::vector<glm::vec2> cell =
VoronoiCell(seeds[s], seeds, hw, hh);
if (cell.size() < 3) {
refs.push_back({}); // 退化 cell:占位,spawn 跳过
centroids.emplace_back();
paths.emplace_back();
continue;
}
PrismMesh pm = CellToPrism(cell, hz);
glm::vec2 c2{0.0f};
for (const glm::vec2& p : cell) c2 += p;
c2 /= static_cast<f32>(cell.size());
centroids.push_back(
wallPos + wall.GetWorldRotationQuat() *
glm::vec3{c2.x, c2.y, 0.0f});
Engine::ParsedModel parsed;
parsed.path = keyBase + std::to_string(s);
Engine::ParsedMesh& mesh =
parsed.meshes.emplace_back();
mesh.vertices = std::move(pm.vertices);
mesh.lods.push_back(std::move(pm.indices));
mesh.cpuPositions = std::move(pm.cpuPositions);
mesh.name = "frag" + std::to_string(s);
mesh.params.baseColor = brick;
const Engine::ModelRef ref =
assets.LoadModel(parsed.path, std::move(parsed));
refs.push_back(ref);
paths.push_back(parsed.path);
}
// ---- spawn 碎片实体:pos/rot 复用墙位姿(mesh 已真尺寸,
// scale 恒 1 防 ConvexHull 顶点二次缩放),碎裂瞬间完美
// 拼合墙面,动起来后沿裂纹自然分离 ----
for (size_t s = 0; s < refs.size(); ++s) {
if (!refs[s].Valid()) continue;
auto frag = Spawn("fragment");
auto& tr = frag.GetComponent<
Engine::ECS::TransformComponent>();
tr.SetPosition(wallPos);
tr.SetRotationQuat(wall.GetWorldRotationQuat());
tr.SetScale(glm::vec3{1.0f});
auto& fmr =
frag.AddComponent<
Engine::ECS::MeshRendererComponent>();
fmr.model = refs[s];
// Dynamic MeshCollider → ConvexHull(数据源 = 刚上传
// 的 cpuPositions;Box 字段不参与 Mesh 形状)
auto& frb =
frag.AddComponent<
Engine::ECS::RigidBodyComponent>();
frb.type =
Engine::ECS::RigidBodyComponent::BodyType::Dynamic;
frb.collider =
Engine::ECS::RigidBodyComponent::ColliderType::Mesh;
frb.mass = 0.3f;
frb.friction = 0.7f;
frb.restitution = 0.05f;
// 碎裂初速(玻璃式,非爆炸):径向 = 碎片质心 - 命中点,
// 离命中点越远越接近静止(指数衰减)——只有孔洞周边的
// 碎片被冲击波推动;法线外推让碎片朝弹侧剥出
glm::vec3 dir = centroids[s] - hitPoint;
const f32 len = glm::length(dir);
if (len < 1e-3f) {
dir = glm::vec3{Rand(-1.0f, 1.0f), 0.6f,
Rand(-1.0f, 1.0f)};
} else {
dir /= len;
}
const f32 falloff = std::exp(-len * m_Falloff);
const glm::vec3 vel =
dir * m_BurstSpeed * falloff *
Rand(0.4f, 1.1f) +
hitNormal * m_NormalPop * Rand(0.3f, 0.8f) +
glm::vec3{0.0f, m_UpBias * Rand(0.0f, 0.5f),
0.0f};
// 总量上限:超限立即回收最老碎片(防泄漏的兜底闸;
// 寿命清理才是常态回收)。模型延迟一帧卸载
const u32 bits = BitsOf(frag);
if (m_Fragments.size() >=
static_cast<size_t>(m_MaxFragments)) {
Destroy(EntityFromBits(m_Fragments.front().bits));
if (!m_Fragments.front().modelPath.empty()) {
m_PendingUnload.push_back(
std::move(m_Fragments.front().modelPath));
}
m_Fragments.erase(m_Fragments.begin());
}
m_Fragments.push_back(
Fragment{bits, vel, 0.0f, false, paths[s]});
}
}
// ---- 模型回收:延迟一帧 + 批量停飞卸载(Destroy 是帧尾延迟,
// 碎片实体本帧渲染收集仍会引用模型;下一帧实体已灭,但上一帧
// 提交的命令缓冲仍可能引用顶点缓冲/sampler——批量接口先
// WaitDeviceIdle 再销毁,与资产重建换绑同一引擎先例)----
void ApplyPendingUnload() {
App().GetAssets().UnloadModelsByPaths(m_PendingUnload);
m_PendingUnload.clear();
}
// ---- 施速待办:body 建好(bodyValid)即注入,勿重复 ----
void ApplyPendingVelocities() {
auto& registry = App().GetScene().GetRegistry();
for (auto it = m_Fragments.begin();
it != m_Fragments.end();) {
if (it->applied) { ++it; continue; }
const ECS::Entity e = EntityFromBits(it->bits);
if (!registry.valid(e)) { // 实体没了(外部销毁)
// 模型是独占资源(一代一键),实体没了模型也回收
if (!it->modelPath.empty()) {
m_PendingUnload.push_back(
std::move(it->modelPath));
}
it = m_Fragments.erase(it);
continue;
}
const auto* rb = registry.try_get<
Engine::ECS::RigidBodyComponent>(e);
if (rb && rb->bodyValid) {
App().GetPhysics().SetLinearVelocity(it->bits,
it->velocity);
it->applied = true;
}
++it; // body 未建:留待下一帧(对账晚一帧必到)
}
}
// ---- 寿命清理:到期 Destroy(延迟队列,遍历安全)----
void AgeAndCleanup(f32 dt) {
auto& registry = App().GetScene().GetRegistry();
for (auto it = m_Fragments.begin();
it != m_Fragments.end();) {
it->age += dt;
if (it->age < m_Lifetime) { ++it; continue; }
const ECS::Entity e = EntityFromBits(it->bits);
if (registry.valid(e)) {
Destroy(e);
}
// 模型延迟一帧卸载(本帧渲染收集还引用它)
if (!it->modelPath.empty()) {
m_PendingUnload.push_back(std::move(it->modelPath));
}
it = m_Fragments.erase(it);
}
}
static f32 Rand(f32 lo, f32 hi) {
thread_local std::mt19937 rng{std::random_device{}()};
return std::uniform_real_distribution<f32>(lo, hi)(rng);
}
struct Fragment {
u32 bits = 0; // PhysicsWorld 键(实体位模式)
glm::vec3 velocity{0.0f};// 待施初速(body 建好后注入)
f32 age = 0.0f; // 存活秒数(寿命清理用)
bool applied = false; // 初速已注入
std::string modelPath; // 独占模型缓存键(回收时按路径卸载)
};
int m_Count = 30;
f32 m_SeedRadius = 0.9f;
f32 m_MinSeedDist = 0.12f;
f32 m_BurstSpeed = 1.2f;
f32 m_Falloff = 1.0f;
f32 m_NormalPop = 0.4f;
f32 m_UpBias = 0.3f;
f32 m_Lifetime = 6.0f;
int m_MaxFragments = 128;
f32 m_MinImpactSpeed = 2.0f;
bool m_Fired = false; // 一次性触发保护
std::vector<Fragment> m_Fragments; // 活碎片簿记
// 待卸载模型路径(碎片销毁的下一帧执行——Destroy 帧尾延迟,
// 本帧渲染收集仍引用模型,同帧卸载会渲染悬空指针)
std::vector<std::string> m_PendingUnload;
Engine::Fracture::Params m_P; // 参数(DeclareParams 直绑)
Engine::Fracture::Burst m_Burst; // 碎片簿记 + 生命周期(look 默认砖色)
};
} // namespace