EG/ssbo_component/ssbo_controller.py
2026-02-27 16:57:53 +08:00

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