4、扩展开发基础:掌握Extension的基本结构、manifest.json配置、Python入口和注册方式

Omniverse Kit 是一个高度模块化的应用框架,其核心设计哲学是“一切皆扩展(Extension)”。无论是 UI 工具、仿真逻辑、渲染管线还是硬件接入,最终都以 Extension 的形式被加载、组合和运行。理解 Extension 的基本结构、配置清单和注册机制,是进入 Omniverse Kit 开发世界的第一道门槛。本章将围绕一个最小可运行的 Extension 从零到一的构建过程,深入剖析其内部结构、manifest.json 的字段语义、Python 入口的加载顺序以及 Kit 的扩展注册表机制,帮助开发者建立清晰、正确的扩展开发心智模型。

Extension 生命周期与注册机制 Extension 包结构 extension.toml (manifest) python/ (Python 入口) config/ (扩展配置) data/ (资源文件) Omniverse Kit Extension Manager 依赖解析器 Python 运行时 注册与加载流程 1. 扫描搜索路径 2. 解析 manifest.toml 3. 检查依赖与冲突 4. 执行 start() / shutdown() 5. 注册 UI / 服务 / 命令 manifest.toml 关键字段映射 title / version dependencies python.entry extension.license • 每个 Extension 必须包含一个 manifest.toml 文件,它是扩展的“身份证”和“说明书”。 • Kit 启动时通过 Extension Manager 扫描所有配置的搜索路径,读取 manifest 并构建依赖图。 • Python 入口通过 manifest 中的 python.entry 字段指定,Kit 在加载时动态导入并调用 start() 方法。 • 注册方式包括:UI 窗口注册、命令注册、服务注册、事件监听注册等。

上图展示了 Extension 从磁盘结构到 Kit 运行时加载的完整链路。左侧是扩展包在文件系统中的典型布局,中间是 Kit 进程内的核心管理组件,右侧则是注册与加载的关键步骤。整个流程的核心在于 manifest.toml 文件,它充当了扩展与 Kit 之间的契约。下面我们将从扩展的基本结构开始,逐步深入到配置细节和代码实现。

4.1 Extension 的基本结构与目录规范

一个标准的 Omniverse Kit Extension 本质上是一个包含特定文件和目录结构的文件夹。虽然 Kit 允许一定程度的灵活性,但遵循社区和官方推荐的规范,可以显著降低维护成本并提高可移植性。一个典型的扩展包结构如下:

my_extension/
├── extension.toml          # 扩展清单文件(manifest)
├── python/
│   └── my_extension/
│       ├── __init__.py     # Python 包初始化
│       └── extension.py    # 扩展主逻辑(可选,但推荐)
├── config/
│   └── extension.toml      # 扩展的默认配置(可选)
├── data/
│   ├── icons/
│   └── resources/          # 静态资源(UI、模型等)
├── docs/
│   └── README.md
└── tests/
    └── test_extension.py

其中,extension.toml 是唯一强制要求的文件,它必须位于扩展包的根目录。Kit 通过该文件识别一个文件夹是否为合法的扩展。Python 代码通常放置在 python/ 目录下,并以包(Package)的形式组织。这种结构不仅清晰,而且便于 Kit 的依赖解析器正确处理 Python 模块的导入路径。

经验提醒: 扩展包的命名建议使用小写字母和下划线,避免使用连字符或空格。例如 my_extension 优于 my-extension。因为 Python 模块导入机制不支持连字符,而 Kit 内部会将扩展名与 Python 包名进行关联。

4.2 manifest.json(extension.toml)配置详解

尽管题目中提及 manifest.json,但在 Omniverse Kit 的现代版本中,扩展清单文件的标准格式是 TOML(extension.toml)。TOML 相比 JSON 具有更好的可读性和注释支持,更适合作为配置文件。不过,理解 JSON 格式的开发者可以轻松迁移,因为字段语义是相通的。下面是一个最小但完整的 extension.toml 示例:

[package]
title = "My First Extension"
version = "1.0.0"
description = "A minimal extension for learning Kit API."
keywords = ["example", "tutorial"]
authors = ["Your Name <you@example.com>"]

[package.license]
name = "MIT"
text = "MIT License ..."

[dependencies]
"omni.kit.app" = ">=1.0.0"
"omni.kit.window.toolbar" = ">=1.0.0"

[python]
entry = "my_extension.extension:MyExtension"

[extension]
category = "Examples"
enabled = true

上述配置中,[package] 段定义了扩展的基本元信息,title 和 version 是必填项。[dependencies] 段声明了该扩展所依赖的其他扩展及其版本约束,Kit 的依赖解析器会据此构建加载顺序。[python] 段的 entry 字段是核心,它指定了 Python 入口类的路径,格式为 模块路径:类名。[extension] 段则包含一些运行时属性,如分类和默认启用状态。

字段 类型 必填 说明
title string 是 扩展的显示名称,用于 UI 和日志。
version string 是 遵循语义化版本规范,如 "1.2.3"。
description string 否 扩展功能的简要描述。
dependencies table 否 声明对其他扩展的依赖及版本范围。
python.entry string 是* Python 入口点,格式为 "module:Class"。*如果扩展包含 Python 代码则必填。
extension.category string 否 用于在 Extension Manager 中分类显示。
风险说明: 版本约束中的 >= 和 < 符号在 TOML 文件中需要谨慎处理。TOML 要求字符串值必须用引号包裹,因此版本约束应写成 "omni.kit.app" = ">=1.0.0",而不是 >=1.0.0(不带引号会导致解析错误)。

4.3 Python 入口与生命周期方法

当 Kit 决定加载一个扩展时,它会根据 python.entry 字段动态导入指定的模块,并实例化指定的类。这个类不需要继承任何基类,但必须实现两个关键方法:start() 和 shutdown()。start() 在扩展被启用时调用,通常用于创建 UI、注册命令、连接服务等;shutdown() 在扩展被禁用或 Kit 退出时调用,用于释放资源、注销回调。

# python/my_extension/extension.py
import omni.ext
import omni.ui as ui

class MyExtension:
    def __init__(self):
        self._window = None

    def start(self):
        """扩展启动时调用"""
        self._window = ui.Window("My Extension", width=300, height=200)
        with self._window.frame:
            with ui.VStack():
                ui.Label("Hello from My Extension!")
                ui.Button("Click Me", clicked_fn=self._on_click)

    def shutdown(self):
        """扩展关闭时调用"""
        if self._window:
            self._window.destroy()
            self._window = None

    def _on_click(self):
        print("Button clicked!")

上述代码展示了最基础的扩展实现。start() 方法中创建了一个简单的 UI 窗口,shutdown() 中销毁了该窗口。值得注意的是,start() 和 shutdown() 可能会被多次调用(例如用户在 Extension Manager 中手动禁用和启用扩展),因此实现时必须保证幂等性——即重复调用不会产生错误或资源泄漏。

核心原理: Kit 的扩展加载机制基于 Python 的 importlib 模块。当扩展被启用时,Kit 会将扩展的 python/ 目录添加到 sys.path,然后执行 importlib.import_module() 导入入口模块。实例化类后,Kit 会持有该实例的引用,并在适当时机调用其生命周期方法。这种设计使得扩展可以完全控制自己的资源,而不需要依赖 Kit 的垃圾回收机制。

4.4 注册方式:UI、命令与服务

除了生命周期方法,扩展还需要与 Kit 的其他部分交互。这种交互通过“注册”机制实现。常见的注册方式包括:

  • UI 注册: 通过 omni.ui 创建窗口、面板、菜单项等。窗口可以通过 ui.Window 创建,菜单项可以通过 omni.kit.menu.utils 添加。
  • 命令注册: 使用 omni.kit.commands 模块注册可撤销/重做的命令。命令是 Kit 中执行操作的标准方式,支持事务和宏录制。
  • 服务注册: 通过 omni.ext.IExtension 接口或 carb 框架注册服务,供其他扩展查询和调用。
  • 事件监听: 订阅 Kit 或 USD 舞台的事件,如 StageEventType、UpdateEventType 等。

以命令注册为例,一个典型的命令类需要继承 omni.kit.commands.Command 并实现 do() 和 undo() 方法:

import omni.kit.commands

class CreateSphereCommand(omni.kit.commands.Command):
    def do(self, radius: float = 1.0):
        # 创建球体的逻辑
        self._radius = radius
        print(f"Creating sphere with radius {radius}")
        return True

    def undo(self):
        # 撤销操作
        print("Undo sphere creation")

# 注册命令
omni.kit.commands.register("MyExtension.CreateSphere", CreateSphereCommand)

注册命令后,其他扩展或脚本可以通过 omni.kit.commands.execute("MyExtension.CreateSphere", radius=2.0) 来调用,并且该操作会自动支持撤销/重做。

4.5 实战场景:构建一个带配置面板的扩展

为了将上述知识串联起来,我们设计一个实战场景:构建一个扩展,它读取配置文件中的参数,并在 UI 中显示一个可调节的滑块。当滑块值改变时,扩展会更新舞台中一个立方体的缩放比例。

背景与约束: 假设我们正在开发一个场景布置工具,需要快速调整基础几何体的尺寸。扩展需要支持配置文件的持久化,以便下次启动时恢复上次的数值。

实现步骤:

  1. 创建扩展目录结构,编写 extension.toml,声明依赖 omni.kit.app 和 omni.usd。
  2. 在 config/extension.toml 中定义默认参数:scale = 1.0。
  3. 在 start() 中读取配置,创建 UI 窗口和滑块。
  4. 滑块值改变时,通过 omni.usd.get_context() 获取舞台,修改立方体的缩放属性。
  5. 在 shutdown() 中保存配置并销毁窗口。
# 简化版实现
import omni.ext
import omni.ui as ui
import omni.usd
from pxr import UsdGeom

class ScaleControllerExtension:
    def __init__(self):
        self._window = None
        self._scale = 1.0

    def start(self):
        # 读取配置
        self._scale = omni.ext.get_extension_settings().get("scale", 1.0)
        
        self._window = ui.Window("Scale Controller", width=250, height=100)
        with self._window.frame:
            with ui.VStack():
                self._slider = ui.FloatSlider(min=0.1, max=10.0, value=self._scale)
                self._slider.model.add_value_changed_callback(self._on_scale_changed)

    def _on_scale_changed(self, model):
        self._scale = model.get_value_as_float()
        # 更新舞台中的立方体
        stage = omni.usd.get_context().get_stage()
        if stage:
            prim = stage.GetPrimAtPath("/World/Cube")
            if prim.IsValid():
                UsdGeom.Xformable(prim).GetOrderedXformOps()[0].Set(self._scale)

    def shutdown(self):
        # 保存配置
        omni.ext.get_extension_settings()["scale"] = self._scale
        if self._window:
            self._window.destroy()

结果与复盘: 该扩展成功实现了参数化控制,并且配置在重启后得以保留。通过这个案例,我们实践了 manifest 配置、Python 入口、UI 注册和配置读写。需要注意的是,GetOrderedXformOps()[0] 假设立方体只有一个缩放操作,实际项目中应更稳健地处理变换栈。

4.6 常见误区与总结

在扩展开发初期,开发者常会遇到以下问题:

  • 入口类路径错误: python.entry 中的模块路径必须与实际的 Python 包结构完全匹配,且类名大小写敏感。
  • 依赖未声明: 如果扩展使用了 omni.usd 但未在 dependencies 中声明,Kit 可能不会自动加载该依赖,导致导入失败。
  • shutdown 不彻底: 忘记注销回调或销毁窗口,导致扩展被禁用后 UI 残留或内存泄漏。
  • 配置文件路径错误: 扩展的配置应放在 config/extension.toml 中,而不是与 extension.toml 混淆。
调试技巧: 在 start() 方法开头添加 print(f"Starting extension: {__name__}"),并在 shutdown() 中添加对应的日志。通过观察 Kit 的控制台输出,可以快速确认扩展的加载和卸载时机是否正确。

总结而言,掌握 Extension 开发基础的关键在于理解 manifest 的契约作用、Python 入口的生命周期语义以及 Kit 的注册机制。一个结构清晰、配置完整、生命周期管理得当的扩展,是构建复杂应用的基础。建议开发者在动手编写业务逻辑之前,先花时间设计好扩展的目录结构和配置项,这将为后续的维护和扩展带来极大的便利。