365 lines
13 KiB
Python
365 lines
13 KiB
Python
|
|
import math
|
|
from panda3d.core import GeomNode, GeomVertexFormat, GeomVertexWriter
|
|
from panda3d.core import InternalName, LMatrix4f, NodePath, Vec3
|
|
import time
|
|
|
|
class ObjectController:
|
|
"""
|
|
混合架构控制器 (Chunked Static + Dynamic Editing)
|
|
================================================
|
|
- 默认: 每个 chunk 使用 flatten 后的静态表示
|
|
- 编辑: 被选中对象所属 chunk 切换为动态表示,直接改 NodePath 变换
|
|
- 提交: 离开 chunk 时仅重建该 chunk 的静态表示
|
|
"""
|
|
|
|
def __init__(self, chunk_size=64, chunk_world_size=40.0):
|
|
self.chunk_size = max(8, int(chunk_size))
|
|
self.chunk_world_size = max(8.0, float(chunk_world_size))
|
|
self._reset_state()
|
|
|
|
def _reset_state(self):
|
|
self.name_to_ids = {}
|
|
self.id_to_name = {}
|
|
self.node_list = []
|
|
self.display_names = {}
|
|
self.global_transforms = []
|
|
self.position_offsets = {}
|
|
|
|
self.model = None
|
|
self.pick_model = None
|
|
self.id_to_chunk = {} # global_id -> chunk_id
|
|
self.id_to_object_np = {} # global_id -> dynamic object nodepath
|
|
self.id_to_pick_np = {} # global_id -> pick-scene nodepath
|
|
|
|
# chunk_id -> {
|
|
# "dynamic_np": NodePath,
|
|
# "static_np": NodePath or None,
|
|
# "members": [global_id],
|
|
# "dirty": bool,
|
|
# "dynamic_enabled": bool
|
|
# }
|
|
self.chunks = {}
|
|
self.active_chunks = set()
|
|
self._next_chunk_id = 0
|
|
# spatial cell key -> [chunk_id, ...]
|
|
self._cell_to_chunks = {}
|
|
|
|
# UI hierarchy metadata (matches source model parent/child structure)
|
|
self.tree_root_key = None
|
|
self.tree_nodes = {}
|
|
self._path_to_tree_key = {}
|
|
|
|
def _register_tree_node(self, key, display_name, parent_key):
|
|
self.tree_nodes[key] = {
|
|
"name": display_name,
|
|
"parent": parent_key,
|
|
"children": [],
|
|
"local_ids": [],
|
|
}
|
|
self.display_names[key] = display_name
|
|
self.name_to_ids[key] = []
|
|
if parent_key is not None and parent_key in self.tree_nodes:
|
|
self.tree_nodes[parent_key]["children"].append(key)
|
|
|
|
def _build_scene_tree(self, root_np):
|
|
"""Capture source model hierarchy for UI (independent from render batching)."""
|
|
self.tree_root_key = "0"
|
|
|
|
def walk(np, parent_key, key):
|
|
display_name = np.get_name() or "Unnamed"
|
|
self._register_tree_node(key, display_name, parent_key)
|
|
self._path_to_tree_key[str(np)] = key
|
|
|
|
children = list(np.get_children())
|
|
for i, child in enumerate(children):
|
|
walk(child, key, f"{key}/{i}")
|
|
|
|
walk(root_np, None, self.tree_root_key)
|
|
|
|
def _aggregate_tree_ids(self, key):
|
|
node = self.tree_nodes[key]
|
|
agg_ids = list(node["local_ids"])
|
|
for child_key in node["children"]:
|
|
agg_ids.extend(self._aggregate_tree_ids(child_key))
|
|
self.name_to_ids[key] = agg_ids
|
|
return agg_ids
|
|
|
|
def _build_tree_preorder(self, key, out):
|
|
out.append(key)
|
|
for child_key in self.tree_nodes[key]["children"]:
|
|
self._build_tree_preorder(child_key, out)
|
|
|
|
def should_hide_tree_node(self, key):
|
|
"""
|
|
Hide a redundant wrapper node directly below the file root, e.g. ROOT.
|
|
This keeps `model.glb` as the visible root in the UI.
|
|
"""
|
|
node = self.tree_nodes.get(key)
|
|
if not node:
|
|
return False
|
|
if node["parent"] != self.tree_root_key:
|
|
return False
|
|
|
|
name = (node["name"] or "").strip().lower()
|
|
if name != "root":
|
|
return False
|
|
|
|
# Keep node visible if it actually carries direct geoms.
|
|
if node["local_ids"]:
|
|
return False
|
|
|
|
return len(node["children"]) > 0
|
|
|
|
def _encode_id_color(self, vdata, object_id):
|
|
if not vdata.has_column("color"):
|
|
new_fmt = vdata.get_format().get_union_format(GeomVertexFormat.get_v3c4())
|
|
vdata.set_format(new_fmt)
|
|
|
|
low = object_id & 0xFF
|
|
high = (object_id >> 8) & 0xFF
|
|
r = low / 255.0
|
|
g = high / 255.0
|
|
|
|
writer = GeomVertexWriter(vdata, InternalName.make("color"))
|
|
for row in range(vdata.get_num_rows()):
|
|
writer.set_row(row)
|
|
writer.set_data4f(r, g, 0.0, 1.0)
|
|
|
|
def _ensure_chunk(self, root_np, chunk_id):
|
|
if chunk_id in self.chunks:
|
|
return self.chunks[chunk_id]
|
|
|
|
dynamic_np = root_np.attach_new_node(f"chunk_{chunk_id:04d}_dynamic")
|
|
dynamic_np.stash()
|
|
|
|
chunk_data = {
|
|
"dynamic_np": dynamic_np,
|
|
"static_np": None,
|
|
"members": [],
|
|
"dirty": False,
|
|
"dynamic_enabled": False,
|
|
}
|
|
self.chunks[chunk_id] = chunk_data
|
|
return chunk_data
|
|
|
|
def _get_cell_key_from_pos(self, pos):
|
|
inv = 1.0 / self.chunk_world_size
|
|
return (
|
|
int(math.floor(pos.x * inv)),
|
|
int(math.floor(pos.y * inv)),
|
|
int(math.floor(pos.z * inv)),
|
|
)
|
|
|
|
def _allocate_spatial_chunk(self, root_np, world_pos):
|
|
"""
|
|
Allocate object into a spatially-local chunk for better frustum culling.
|
|
Objects in the same world cell are grouped together, and overflow creates
|
|
another chunk for that same cell.
|
|
"""
|
|
cell_key = self._get_cell_key_from_pos(world_pos)
|
|
chunk_ids = self._cell_to_chunks.setdefault(cell_key, [])
|
|
|
|
for chunk_id in chunk_ids:
|
|
chunk = self.chunks.get(chunk_id)
|
|
if chunk and len(chunk["members"]) < self.chunk_size:
|
|
return chunk_id, chunk
|
|
|
|
chunk_id = self._next_chunk_id
|
|
self._next_chunk_id += 1
|
|
chunk_ids.append(chunk_id)
|
|
return chunk_id, self._ensure_chunk(root_np, chunk_id)
|
|
|
|
def _rebuild_static_chunk(self, chunk_id):
|
|
chunk = self.chunks.get(chunk_id)
|
|
if not chunk:
|
|
return
|
|
|
|
old_static = chunk.get("static_np")
|
|
if old_static and not old_static.is_empty():
|
|
old_static.remove_node()
|
|
|
|
static_np = chunk["dynamic_np"].copy_to(self.model)
|
|
static_np.set_name(f"chunk_{chunk_id:04d}_static")
|
|
static_np.unstash()
|
|
static_np.flatten_strong()
|
|
|
|
chunk["static_np"] = static_np
|
|
chunk["dirty"] = False
|
|
|
|
# Keep visibility coherent with current mode after rebuild.
|
|
if chunk["dynamic_enabled"]:
|
|
static_np.stash()
|
|
else:
|
|
static_np.unstash()
|
|
|
|
def _set_chunk_dynamic(self, chunk_id, enabled):
|
|
chunk = self.chunks.get(chunk_id)
|
|
if not chunk:
|
|
return
|
|
|
|
if enabled:
|
|
if chunk["dynamic_enabled"]:
|
|
return
|
|
chunk["dynamic_np"].unstash()
|
|
if chunk["static_np"] and not chunk["static_np"].is_empty():
|
|
chunk["static_np"].stash()
|
|
chunk["dynamic_enabled"] = True
|
|
self.active_chunks.add(chunk_id)
|
|
return
|
|
|
|
if not chunk["dynamic_enabled"]:
|
|
return
|
|
if chunk["static_np"] and not chunk["static_np"].is_empty():
|
|
chunk["static_np"].unstash()
|
|
chunk["dynamic_np"].stash()
|
|
chunk["dynamic_enabled"] = False
|
|
self.active_chunks.discard(chunk_id)
|
|
|
|
def set_active_ids(self, active_ids):
|
|
"""切换编辑激活集合,仅保留 active_ids 对应 chunk 为动态模式。"""
|
|
target_chunks = {self.id_to_chunk[obj_id] for obj_id in active_ids if obj_id in self.id_to_chunk}
|
|
|
|
# Demote no-longer-active chunks. Dirty chunks are re-baked before demotion.
|
|
for chunk_id in list(self.active_chunks):
|
|
if chunk_id in target_chunks:
|
|
continue
|
|
if self.chunks[chunk_id]["dirty"]:
|
|
self._rebuild_static_chunk(chunk_id)
|
|
self._set_chunk_dynamic(chunk_id, False)
|
|
|
|
# Promote target chunks.
|
|
for chunk_id in target_chunks:
|
|
self._set_chunk_dynamic(chunk_id, True)
|
|
|
|
def bake_ids_and_collect(self, model):
|
|
"""
|
|
构建混合架构:
|
|
1) 把每个 geom 拆成可独立编辑的动态对象
|
|
2) 按 chunk 生成 flatten 后的静态副本
|
|
"""
|
|
t0 = time.time()
|
|
self._reset_state()
|
|
|
|
geom_nodes = list(model.find_all_matches("**/+GeomNode"))
|
|
print(f"[控制器] 找到 {len(geom_nodes)} 个 GeomNode")
|
|
|
|
# Build hierarchy metadata first so UI can mirror source model tree.
|
|
self._build_scene_tree(model)
|
|
|
|
root_name = model.get_name() or "scene"
|
|
scene_root = NodePath(root_name)
|
|
pick_root = NodePath(root_name + "_pick")
|
|
self.model = scene_root
|
|
self.pick_model = pick_root
|
|
|
|
global_id = 0
|
|
for np in geom_nodes:
|
|
gnode = np.node()
|
|
owner_key = self._path_to_tree_key.get(str(np), self.tree_root_key)
|
|
|
|
world_mat = LMatrix4f(np.get_mat(model))
|
|
|
|
for gi in range(gnode.get_num_geoms()):
|
|
# Render geometry stays untouched (keep original material/color behavior).
|
|
render_geom = gnode.get_geom(gi).make_copy()
|
|
render_gnode = GeomNode(f"obj_{global_id}")
|
|
render_gnode.add_geom(render_geom, gnode.get_geom_state(gi))
|
|
|
|
# Picking geometry gets encoded ID in vertex color.
|
|
pick_geom = gnode.get_geom(gi).make_copy()
|
|
pick_vdata = pick_geom.modify_vertex_data()
|
|
self._encode_id_color(pick_vdata, global_id)
|
|
pick_gnode = GeomNode(f"pick_{global_id}")
|
|
pick_gnode.add_geom(pick_geom, gnode.get_geom_state(gi))
|
|
|
|
world_pos = world_mat.get_row3(3)
|
|
chunk_id, chunk = self._allocate_spatial_chunk(scene_root, world_pos)
|
|
|
|
obj_np = chunk["dynamic_np"].attach_new_node(render_gnode)
|
|
obj_np.set_mat(world_mat)
|
|
pick_np = pick_root.attach_new_node(pick_gnode)
|
|
pick_np.set_mat(world_mat)
|
|
|
|
chunk["members"].append(global_id)
|
|
self.id_to_chunk[global_id] = chunk_id
|
|
self.id_to_object_np[global_id] = obj_np
|
|
self.id_to_pick_np[global_id] = pick_np
|
|
self.tree_nodes[owner_key]["local_ids"].append(global_id)
|
|
self.id_to_name[global_id] = owner_key
|
|
self.global_transforms.append(LMatrix4f(world_mat))
|
|
self.position_offsets[global_id] = Vec3(0, 0, 0)
|
|
global_id += 1
|
|
|
|
t1 = time.time()
|
|
print(f"[控制器] Dynamic object build took {(t1 - t0) * 1000:.0f}ms")
|
|
|
|
for chunk_id in sorted(self.chunks):
|
|
self._rebuild_static_chunk(chunk_id)
|
|
self._set_chunk_dynamic(chunk_id, False)
|
|
|
|
t2 = time.time()
|
|
print(f"[控制器] Static chunk flatten took {(t2 - t1) * 1000:.0f}ms")
|
|
print(f"[控制器] Built {len(self.chunks)} chunks, {global_id} objects")
|
|
print(f"[控制器] Spatial chunking: cell={self.chunk_world_size:.1f}, max_members={self.chunk_size}")
|
|
|
|
# Fill per-node aggregate IDs and build deterministic preorder list for UI.
|
|
self._aggregate_tree_ids(self.tree_root_key)
|
|
self.node_list = []
|
|
self._build_tree_preorder(self.tree_root_key, self.node_list)
|
|
|
|
model.remove_node()
|
|
return global_id
|
|
|
|
def create_ssbo(self):
|
|
"""No SSBO needed in hybrid mode."""
|
|
return None
|
|
|
|
def move_object(self, global_id, delta):
|
|
"""
|
|
动态编辑路径: 仅改 NodePath 变换,不改顶点数据。
|
|
"""
|
|
if global_id not in self.id_to_object_np:
|
|
return
|
|
|
|
chunk_id = self.id_to_chunk[global_id]
|
|
self._set_chunk_dynamic(chunk_id, True)
|
|
|
|
obj_np = self.id_to_object_np[global_id]
|
|
next_pos = obj_np.get_pos() + delta
|
|
# Fluid transform helps reduce visible one-frame transform lag in some pipelines.
|
|
if hasattr(obj_np, "set_fluid_pos"):
|
|
obj_np.set_fluid_pos(next_pos)
|
|
else:
|
|
obj_np.set_pos(next_pos)
|
|
|
|
pick_np = self.id_to_pick_np.get(global_id)
|
|
if pick_np and not pick_np.is_empty():
|
|
if hasattr(pick_np, "set_fluid_pos"):
|
|
pick_np.set_fluid_pos(next_pos)
|
|
else:
|
|
pick_np.set_pos(next_pos)
|
|
|
|
self.position_offsets[global_id] = self.position_offsets.get(global_id, Vec3(0, 0, 0)) + delta
|
|
self.chunks[chunk_id]["dirty"] = True
|
|
|
|
def get_world_pos(self, global_id):
|
|
if global_id not in self.id_to_object_np or not self.model:
|
|
return Vec3(0, 0, 0)
|
|
return self.id_to_object_np[global_id].get_pos(self.model)
|
|
|
|
def get_object_center(self, global_id):
|
|
if global_id >= len(self.global_transforms):
|
|
return Vec3(0, 0, 0)
|
|
mat = self.global_transforms[global_id]
|
|
return Vec3(mat.get_row3(3))
|
|
|
|
def get_transform(self, global_id):
|
|
if global_id >= len(self.global_transforms):
|
|
return LMatrix4f.ident_mat()
|
|
return self.global_transforms[global_id]
|
|
|
|
@property
|
|
def transforms(self):
|
|
return self.global_transforms
|