文章目錄
- 處理流程簡介
- 核心功能實現
- 數據讀取與格式轉換
- 定義Point類
- 數據讀取
- splat轉gltf
- 點云數據分割
- 定義四叉樹
- 遞歸生成3dtiles瓦片
- 生成tileset.json
- 遞歸生成tileset.json
- 計算box
- 主函數調用
- 渲染
- 下一步工作
- 性能優化
- 渲染效果調優
- 其他
源碼地址: github
處理流程簡介
基本流程:
- 讀取點云數據。
- 制作tile
- 構建四叉樹
- 分割點云
- 將點云轉換為glTF格式。
- 生成配置文件tileset.json。
前置知識:
- glTF教程
- glTF2.0 高斯擴展
- 3dtiles 1.1 規范
核心功能實現
數據讀取與格式轉換
定義Point類
class Point:def __init__(self, position: Tuple[float, float, float], color: Tuple[int, int, int, int],scale: Tuple[float, float, float], rotation: Tuple[int, int, int, int]):self.position = positionself.color = colorself.scale = scaleself.rotation = rotationdef to_bytes(self) -> bytes:"""將點數據打包為二進制格式"""return struct.pack('3f4B3f4B', *self.position, *self.color, *self.scale, *self.rotation)@classmethoddef from_bytes(cls, data: bytes):"""從二進制數據解析為點"""unpacked = struct.unpack('3f4B3f4B', data)position = unpacked[:3]color = unpacked[3:7]scale = unpacked[7:10]rotation = unpacked[10:]return cls(position, color, scale, rotation)
數據讀取
def read_splat_file(file_path: str) -> List[Point]:"""讀取二進制格式的 Splat 文件:param file_path: Splat 文件路徑:return: 包含位置、縮放、顏色、旋轉數據的 Point 對象列表"""points = []with open(file_path, 'rb') as f:while True:position_data = f.read(3 * 4) # 3個 Float32,每個4字節if not position_data:breakposition = struct.unpack('3f', position_data)scale = struct.unpack('3f', f.read(3 * 4))color = struct.unpack('4B', f.read(4 * 1))rotation = struct.unpack('4B', f.read(4 * 1))points.append(Point(position, color, scale, rotation))return points
splat轉gltf
遵循3dtiles 1.1 規范,在glTF 2.0 基礎上,增加高斯擴展。
def splat_to_gltf_with_gaussian_extension(points: List[Point], output_path: str):"""將 Splat 數據轉換為支持 KHR_gaussian_splatting 擴展的 glTF 文件:param points: Point 對象列表:param output_path: 輸出的 glTF 文件路徑"""# 提取數據positions = np.array([point.position for point in points], dtype=np.float32)colors = np.array([point.color for point in points], dtype=np.uint8)scales = np.array([point.scale for point in points], dtype=np.float32)rotations = np.array([point.rotation for point in points], dtype=np.uint8)normalized_rotations = rotations / 255.0# 創建 GLTF 對象gltf = GLTF2()gltf.extensionsUsed = ["KHR_gaussian_splatting"]# 創建 Bufferbuffer = Buffer()gltf.buffers.append(buffer)# 將數據轉換為二進制positions_binary = positions.tobytes()colors_binary = colors.tobytes()scales_binary = scales.tobytes()rotations_binary = normalized_rotations.tobytes()# 創建 BufferView 和 Accessordef create_buffer_view(byte_offset: int, data: bytes, target: int = 34962) -> BufferView:return BufferView(buffer=0, byteOffset=byte_offset, byteLength=len(data), target=target)def create_accessor(buffer_view: int, component_type: int, count: int, type: str, max: List[float] = None, min: List[float] = None) -> Accessor:return Accessor(bufferView=buffer_view, componentType=component_type, count=count, type=type, max=max, min=min)buffer_views = [create_buffer_view(0, positions_binary),create_buffer_view(len(positions_binary), colors_binary),create_buffer_view(len(positions_binary) +len(colors_binary), rotations_binary),create_buffer_view(len(positions_binary) +len(colors_binary) + len(rotations_binary), scales_binary)]accessors = [create_accessor(0, 5126, len(positions), "VEC3", positions.max(axis=0).tolist(), positions.min(axis=0).tolist()),create_accessor(1, 5121, len(colors), "VEC4"),create_accessor(2, 5126, len(normalized_rotations), "VEC4"),create_accessor(3, 5126, len(scales), "VEC3")]gltf.bufferViews.extend(buffer_views)gltf.accessors.extend(accessors)# 創建 Mesh 和 Primitiveprimitive = Primitive(attributes={"POSITION": 0, "COLOR_0": 1, "_ROTATION": 2, "_SCALE": 3},mode=0,extensions={"KHR_gaussian_splatting": {"positions": 0, "colors": 1, "scales": 2, "rotations": 3}})mesh = Mesh(primitives=[primitive])gltf.meshes.append(mesh)# 創建 Node 和 Scenenode = Node(mesh=0)gltf.nodes.append(node)scene = Scene(nodes=[0])gltf.scenes.append(scene)gltf.scene = 0# 將二進制數據寫入 Buffergltf.buffers[0].uri = "data:application/octet-stream;base64," + base64.b64encode(positions_binary + colors_binary + rotations_binary + scales_binary).decode("utf-8")gltf.save(output_path)print(f"glTF 文件已保存到: {output_path}")
點云數據分割
定義四叉樹
定義四叉樹類,包含基本方法,初始化、插入、分割、判斷點是否在邊界范圍內等等。
#四叉樹
class QuadTreeNode:def __init__(self, bounds: Tuple[float, float, float, float], capacity: int = 100000):"""初始化四叉樹節點。:param bounds: 節點的邊界 (min_x, min_y, max_x, max_y):param capacity: 節點容量(每個節點最多存儲的點數)"""self.bounds = boundsself.capacity = capacityself.points: List[Point] = [] # 存儲點數據self.children = Nonedef insert(self, point: Point) -> bool:"""將點插入四叉樹。:param point: 要插入的點:return: 是否插入成功"""if not self._contains(point.position):return Falseif len(self.points) < self.capacity:self.points.append(point)return Trueelse:if self.children is None:self._subdivide()return any(child.insert(point) for child in self.children)def _contains(self, position: Tuple[float, float, float]) -> bool:"""檢查點是否在節點邊界內。:param position: 點的位置 (x, y, z):return: 是否在邊界內"""x, y, _ = positionmin_x, min_y, max_x, max_y = self.boundsreturn min_x <= x < max_x and min_y <= y < max_ydef _subdivide(self):"""將節點劃分為四個子節點。"""min_x, min_y, max_x, max_y = self.boundsmid_x = (min_x + max_x) / 2mid_y = (min_y + max_y) / 2self.children = [QuadTreeNode((min_x, min_y, mid_x, mid_y), self.capacity),QuadTreeNode((mid_x, min_y, max_x, mid_y), self.capacity),QuadTreeNode((min_x, mid_y, mid_x, max_y), self.capacity),QuadTreeNode((mid_x, mid_y, max_x, max_y), self.capacity)]for point in self.points:for child in self.children:if child.insert(point):breakself.points = [] # 清空當前節點的點數據def get_all_points(self) -> List[Point]:"""獲取當前節點及其子節點中的所有點。:return: 所有點的列表"""points = self.points.copy()if self.children is not None:for child in self.children:points.extend(child.get_all_points())return points
遞歸生成3dtiles瓦片
def generate_3dtiles(node: QuadTreeNode, output_dir: str, tile_name: str):if node.children is not None:for i, child in enumerate(node.children):generate_3dtiles(child, output_dir, f"{tile_name}_{i}")elif len(node.points) > 0:points = node.get_all_points()splat_to_gltf_with_gaussian_extension(points, f"{output_dir}/{tile_name}.gltf")
生成tileset.json
遞歸生成tileset.json
generate_tileset_json
def generate_tileset_json(output_dir: str, root_node: QuadTreeNode, bounds: List[float], geometric_error: int = 100):def build_tile_structure(node: QuadTreeNode, tile_name: str, current_geometric_error: int) -> Dict:bounding_volume = {"region": compute_region([point.position for point in node.get_all_points()])} if is_geographic_coordinate else {"box": compute_box([point.position for point in node.get_all_points()])}content = {"uri": f"{tile_name}.gltf"} if not node.children else Nonechildren = [build_tile_structure(child, f"{tile_name}_{i}", current_geometric_error / 2)for i, child in enumerate(node.children)] if node.children else []tile_structure = {"boundingVolume": bounding_volume,"geometricError": current_geometric_error,"refine": "ADD","content": content}if children:tile_structure["children"] = childrendel tile_structure["content"]return tile_structuretileset = {"asset": {"version": "1.1", "gltfUpAxis": "Z"},"geometricError": geometric_error,"root": build_tile_structure(root_node, "tile_0", geometric_error)}with open(f"{output_dir}/tileset.json", "w") as f:json.dump(tileset, f, cls=NumpyEncoder, indent=4)
數據格式轉換
class NumpyEncoder(json.JSONEncoder):def default(self, obj):if isinstance(obj, (np.int_, np.intc, np.intp, np.int8, np.int16, np.int32, np.int64, np.uint8, np.uint16, np.uint32, np.uint64)):return int(obj)elif isinstance(obj, (np.float_, np.float16, np.float32, np.float64)):return float(obj)elif isinstance(obj, np.ndarray):return obj.tolist()return json.JSONEncoder.default(self, obj)
計算box
def compute_box(points: np.ndarray) -> List[float]:center = np.mean(points, axis=0)half_size = (np.max(points, axis=0) - np.min(points, axis=0)) / 2return [center[0], center[1], center[2], half_size[0], 0, 0, 0, half_size[1], 0, 0, 0, half_size[2]]
主函數調用
def main(input_path: str, output_dir: str):# 讀取 .splat 文件points = read_splat_file(input_path)# 創建四叉樹根節點positions = np.array([point.position for point in points])min_x, min_y = np.min(positions[:, :2], axis=0)max_x, max_y = np.max(positions[:, :2], axis=0)root = QuadTreeNode((min_x, min_y, max_x, max_y), capacity=100000)# 將點插入四叉樹for point in points:root.insert(point)# 生成 3D Tilesgenerate_3dtiles(root, output_dir, "tile_0")# 生成 tileset.jsonbounds = [min_x, min_y, np.min(positions[:, 2]), max_x, max_y, np.max(positions[:, 2])]generate_tileset_json(output_dir, root, bounds)if __name__ == "__main__":# 解析命令行參數parser = argparse.ArgumentParser(description="將 Splat 文件轉換為 3D Tiles。")parser.add_argument("input_path", type=str, help="輸入的 .splat 文件路徑")parser.add_argument("output_dir", type=str, help="輸出的 3D Tiles 目錄路徑")args = parser.parse_args()# 調用主函數main(args.input_path, args.output_dir)
渲染
編譯cesium的splat-shader版本,參考示例代碼3D Tiles Gaussian Splatting.html實現。
async function loadTileset() {try {const tileset = await Cesium.Cesium3DTileset.fromUrl("http://localhost:8081/data/outputs/model/tileset.json",{modelMatrix:computeModelMatrix(),maximumScreenSpaceError: 1,}).then((tileset) => {CesiumViewer.scene.primitives.add(tileset);setupCamera();});} catch (error) {console.error(`Error creating tileset: ${error}`);}
}
下一步工作
性能優化
- 支持LOD 。
- 支持多線程、多任務,分批處理 。
- 切片方案優化,嘗試構建其他空間索引,例如八叉樹 。
渲染效果調優
目前渲染效果不理想,橢圓的某個軸長過大,問題排查中。
其他
其他待優化項。本文輸出的是一個簡易版的splat轉3dtiles工具,供學習和交流使用,待優化的地方,若有精力后續會持續完善。
參考資料:
[1] https://github.com/KhronosGroup/glTF-Tutorials/tree/main/gltfTutorial
[2] https://github.com/CesiumGS/3d-tiles
[3] https://github.com/CesiumGS/glTF/tree/proposal-KHR_gaussian_splatting/extensions/2.0/Khronos/KHR_gaussian_splatting
[4] https://github.com/CesiumGS/cesium/tree/splat-shader