[
  {
    "path": ".cargo/config.toml",
    "content": "# alternatively, `export CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-server-runner`\n\n[target.wasm32-unknown-unknown]\nrunner = \"wasm-server-runner\"\nrustflags = [\n    \"--cfg=web_sys_unstable_apis\",\n    \"--cfg=getrandom_backend=\\\"wasm_js\\\"\",\n    # \"--cfg=wasm_js\",\n    # \"-C\",\n    # \"target-feature=+atomics,+bulk-memory,+mutable-globals\",  # for wasm-bindgen-rayon\n]\n\n\n# fix spurious network error on windows\n# [source.crates-io]\n# registry = \"https://github.com/rust-lang/crates.io-index\"\n\n[http]\nproxy = \"\"\n\n\n# offline development\n# [source.crates-io]\n# replace-with = \"vendored-sources\"\n\n# [source.vendored-sources]\n# directory = \"vendor\"\n"
  },
  {
    "path": ".devcontainer/Dockerfile",
    "content": "FROM mcr.microsoft.com/devcontainers/rust:0-1\n\nWORKDIR /workspace\n\nRUN apt-get update && DEBIAN_FRONTEND=\"noninteractive\" apt-get install -y \\\n    build-essential \\\n    libpulse-dev \\\n    libdbus-1-dev \\\n    libudev-dev \\\n    libssl-dev \\\n    xorg \\\n    openbox \\\n    alsa-tools \\\n    librust-alsa-sys-dev \\\n    && rm -rf /var/lib/apt/lists/*\n\nRUN rustup target install wasm32-unknown-unknown\n\nRUN cargo install flamegraph\nRUN cargo install wasm-server-runner\n"
  },
  {
    "path": ".devcontainer/docker-compose.yaml",
    "content": "services:\n  devcontainer:\n    build:\n      context: .\n      dockerfile: ./Dockerfile\n    volumes:\n      - type: bind\n        source: ..\n        target: /workspace\n    command: sleep infinity\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto eol=lf\n*.{cmd,[cC][mM][dD]} text eol=crlf\n*.{bat,[bB][aA][tT]} text eol=crlf\n*.sh text eol=lf\n*.conf text eol=lf\n\n*.ply binary\n*.splat binary\n\n*.lock -diff\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# Please see the documentation for all configuration options:\n# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"cargo\"\n    directory: \"/\"\n    schedule:\n      interval: \"weekly\"\n    ignore:\n        # These are peer deps of Cargo and should not be automatically bumped\n        - dependency-name: \"semver\"\n        - dependency-name: \"crates-io\"\n    rebase-strategy: \"disabled\"\n"
  },
  {
    "path": ".github/workflows/bench.yml",
    "content": "name: bench\n\non:\n  pull_request:\n    types: [ labeled, synchronize ]\n    branches: [ \"main\" ]\n\nenv:\n  CARGO_TERM_COLOR: always\n  RUST_BACKTRACE: 1\n\njobs:\n  bench:\n    if: contains(github.event.pull_request.labels.*.name, 'bench')\n\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [windows-latest, macos-latest, macos-14]\n\n    runs-on: ${{ matrix.os }}\n    timeout-minutes: 120\n\n    steps:\n    - uses: actions/checkout@v4\n    - uses: actions/cache@v4\n      with:\n        path: |\n          ~/.cargo/bin/\n          ~/.cargo/registry/index/\n          ~/.cargo/registry/cache/\n          ~/.cargo/git/db/\n          target/\n        key: ${{ runner.os }}-cargo-build-stable-${{ hashFiles('**/Cargo.toml') }}\n\n    - name: io benchmark\n      uses: boa-dev/criterion-compare-action@v3.2.4\n      with:\n        benchName: \"io\"\n        branchName: ${{ github.base_ref }}\n        token: ${{ secrets.GITHUB_TOKEN }}\n"
  },
  {
    "path": ".github/workflows/build.yml",
    "content": "name: build\n\non:\n  push:\n    branches: [ \"main\" ]\n  pull_request:\n    branches: [ \"main\" ]\n\nenv:\n  CARGO_TERM_COLOR: always\n  RUST_BACKTRACE: 1\n\njobs:\n  build:\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [windows-latest, macos-latest, macos-14]\n        rust-toolchain:\n          - nightly\n\n    runs-on: ${{ matrix.os }}\n    timeout-minutes: 120\n\n    steps:\n    - uses: actions/checkout@v4\n\n    - name: Setup ${{ matrix.rust-toolchain }} rust toolchain with caching\n      uses: brndnmtthws/rust-action@v1\n      with:\n        toolchain: ${{ matrix.rust-toolchain }}\n        components: rustfmt, clippy\n        enable-sccache: \"false\"\n\n    - name: build\n      run: cargo build\n\n\n  build_tools:\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [windows-latest, macos-latest, macos-14]\n        rust-toolchain:\n          - nightly\n\n    runs-on: ${{ matrix.os }}\n    timeout-minutes: 120\n\n    steps:\n    - uses: actions/checkout@v4\n\n    - name: Setup ${{ matrix.rust-toolchain }} rust toolchain with caching\n      uses: brndnmtthws/rust-action@v1\n      with:\n        toolchain: ${{ matrix.rust-toolchain }}\n        components: rustfmt, clippy\n        enable-sccache: \"false\"\n\n    - name: build_tools\n      run: cargo build --bin ply_to_gcloud"
  },
  {
    "path": ".github/workflows/clippy.yml",
    "content": "name: clippy\n\non:\n  push:\n    branches: [ \"main\" ]\n  pull_request:\n    branches: [ \"main\" ]\n\nenv:\n  CARGO_TERM_COLOR: always\n  RUST_BACKTRACE: 1\n\njobs:\n  clippy:\n\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [windows-latest, macos-latest, macos-14]\n        rust-toolchain:\n          - nightly\n\n    runs-on: ${{ matrix.os }}\n    timeout-minutes: 120\n\n    steps:\n    - uses: actions/checkout@v4\n\n    - name: Setup ${{ matrix.rust-toolchain }} rust toolchain with caching\n      uses: brndnmtthws/rust-action@v1\n      with:\n        toolchain: ${{ matrix.rust-toolchain }}\n        components: rustfmt, clippy\n        enable-sccache: \"false\"\n\n    - name: lint\n      run: cargo clippy --all-features --all-targets -- -D warnings\n"
  },
  {
    "path": ".github/workflows/deploy-pages.yml",
    "content": "name: deploy github pages\n\non:\n  push:\n    branches:\n      - main\n  workflow_dispatch:\n\npermissions:\n  contents: write\n\nenv:\n  CARGO_TERM_COLOR: always\n  RUST_BACKTRACE: 1\n\n\njobs:\n  deploy:\n    runs-on: macos-latest\n\n    steps:\n      - name: checkout repository\n        uses: actions/checkout@v4\n\n      - name: setup nightly rust toolchain with caching\n        uses: brndnmtthws/rust-action@v1\n        with:\n          toolchain: nightly\n          components: rustfmt, clippy\n          enable-sccache: \"false\"\n\n      - name: install wasm32-unknown-unknown\n        run: rustup target add wasm32-unknown-unknown\n\n      - name: install wasm-bindgen-cli\n        run: cargo install wasm-bindgen-cli --version 0.2.108 --locked --force\n\n      - name: build web output and thumbnails\n        env:\n          RUSTFLAGS: \"\"\n          CARGO_ENCODED_RUSTFLAGS: \"\"\n          RUSTDOCFLAGS: \"\"\n          THUMBNAIL_SCENE_CACHE_CLEANUP: \"1\"\n        run: bash ./tools/build_www.sh\n\n      - name: copy assets\n        run: |\n          mkdir -p ./www/assets\n          rsync -a --exclude '.thumbnail_cache' ./assets/ ./www/assets/\n\n      - name: deploy to github pages\n        uses: JamesIves/github-pages-deploy-action@v4\n        with:\n          folder: ./www\n          branch: www\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: test\n\non:\n  push:\n    branches: [ \"main\" ]\n  pull_request:\n    branches: [ \"main\" ]\n\nenv:\n  CARGO_TERM_COLOR: always\n  RUST_BACKTRACE: 1\n\njobs:\n  test:\n\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [windows-latest, macos-latest, macos-14]\n        rust-toolchain:\n          - nightly\n\n    runs-on: ${{ matrix.os }}\n    timeout-minutes: 120\n\n    steps:\n    - uses: actions/checkout@v4\n\n    - name: Setup ${{ matrix.rust-toolchain }} rust toolchain with caching\n      uses: brndnmtthws/rust-action@v1\n      with:\n        toolchain: ${{ matrix.rust-toolchain }}\n        components: rustfmt, clippy\n        enable-sccache: \"false\"\n\n    - name: test (default)\n      run: cargo test\n\n\n  test_web:\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [windows-latest, macos-latest, macos-14]\n        rust-toolchain:\n          - nightly\n\n    runs-on: ${{ matrix.os }}\n    timeout-minutes: 120\n\n    steps:\n    - uses: actions/checkout@v4\n\n    - name: Setup ${{ matrix.rust-toolchain }} rust toolchain with caching\n      uses: brndnmtthws/rust-action@v1\n      with:\n        toolchain: ${{ matrix.rust-toolchain }}\n        components: rustfmt, clippy\n        enable-sccache: \"false\"\n\n    - name: test (web)\n      run: cargo test --no-default-features --features=\"web io_ply tooling\"\n"
  },
  {
    "path": ".github/workflows/todo_tracker.yml",
    "content": "\nname: 'todo tracker'\n\non:\n  push:\n    branches: [ main ]\n\njobs:\n  build:\n    permissions:\n      issues: write\n\n    name: todo_tracker\n    runs-on: [ubuntu-latest]\n\n    steps:\n    - name: Checkout code\n      uses: actions/checkout@v4\n\n    - name: \"TODO to Issue\"\n      uses: \"alstr/todo-to-issue-action@v5\"\n      id: \"todo\"\n      with:\n        AUTO_ASSIGN: true\n"
  },
  {
    "path": ".gitignore",
    "content": "debug/\ntarget/\nvendor/\nout/\n**/*.rs.bk\n*.pdb\n\n*.py\n\n*.gc4d\n*.gcloud\n*.glb\n*.ply\n*.ply4d\n*.json\n\n.DS_Store\n\nexports/\nscreenshots/\nheadless_output/\nwww/assets/\nwww/examples/thumbnails/\n\n# Keep generated GLBs out of git while allowing conformance fixtures.\n!tests/fixtures/**/*.glb\n"
  },
  {
    "path": ".vscode/bevy_gaussian_splatting.code-workspace",
    "content": "{\n\t\"folders\": [\n\t\t{\n\t\t\t\"path\": \"../\"\n\t\t},\n\t\t{\n\t\t\t\"path\": \"../../bevy\"\n\t\t}\n\t],\n\t\"settings\": {\n\t\t\"liveServer.settings.multiRootWorkspaceName\": \"bevy_gaussian_splatting\"\n\t}\n}"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# contributing to bevy_gaussian_splatting\n\n![alt text](docs/notferris2.webp)\n\nthank you for your interest in contributing to `bevy_gaussian_splatting`! contributions of all forms are welcome and appreciated. this includes code contributions, bug reports, documentation, or any other form of support.\n\n## getting started\n\nas `bevy_gaussian_splatting` is a smaller project, the contribution process is more informal. feel free to contribute in a way that aligns with your interests and expertise. if you have any questions or need guidance, don't hesitate to reach out.\n\n## ways to contribute\n\n1. **code contributions**: if you have improvements or new features in mind, feel free to open a pull request. for larger changes, it's a good idea to discuss them first through a github issue.\n\n2. **bug reports**: if you find any bugs, please open an issue describing the problem, including any relevant details that could help in resolving it.\n\n3. **documentation**: improvements or additions to documentation are always welcome. this can include both inline code comments and updates to readme or other markdown files.\n\n4. **ideas and suggestions**: have ideas for new features or ways to improve the project? open an issue to discuss your suggestions!\n\n## pull request process\n\n1. ensure that any new code complies with the existing code style and structure.\n2. update the readme.md or other documentation with details of changes, if applicable.\n3. open a pull request with a clear description of the changes.\n\n## questions?\n\nif you're unsure about something or need help, feel free to open an issue asking for guidance. as the project grows, we aim to make contributing as accessible and straightforward as possible.\n\n## code of conduct\n\nwhile we don't have a formal code of conduct, we expect contributors to be respectful, open-minded, and collaborative. let's work together to maintain a welcoming and inclusive environment.\n"
  },
  {
    "path": "Cargo.toml",
    "content": "[package]\nname = \"bevy_gaussian_splatting\"\ndescription = \"bevy gaussian splatting render pipeline plugin\"\nversion = \"7.0.1\"\nedition = \"2024\"\nrust-version = \"1.89.0\"\nauthors = [\"mosure <mitchell@mosure.me>\"]\nlicense = \"MIT OR Apache-2.0\"\nkeywords = [\n  \"bevy\",\n  \"gaussian-splatting\",\n  \"render-pipeline\",\n  \"ply\",\n]\ncategories = [\n  \"computer-vision\",\n  \"graphics\",\n  \"rendering\",\n  \"rendering::data-formats\",\n]\nhomepage = \"https://github.com/mosure/bevy_gaussian_splatting\"\nrepository = \"https://github.com/mosure/bevy_gaussian_splatting\"\nreadme = \"README.md\"\nexclude = [\n  \".devcontainer\",\n  \".github\",\n  \"docs\",\n  \"dist\",\n  \"build\",\n  \"assets\",\n  \"credits\",\n]\ndefault-run = \"bevy_gaussian_splatting\"\n\n\n# TODO: add a feature flag for each gaussian format\n# TODO: resolve one-hot feature flags through runtime configuration\n[features]\ndefault = [\n  \"io_flexbuffers\",\n  \"io_ply\",\n\n  # \"packed\",\n  \"planar\",\n\n  \"buffer_storage\",\n  # \"buffer_texture\",\n\n  \"sh3\",\n\n  # \"precompute_covariance_3d\",\n\n  \"query_select\",\n  # \"query_sparse\",\n\n  # TODO: bevy_interleave storage bind group read_only per plane attribute support\n  # \"morph_particles\",\n  \"morph_interpolate\",\n\n  \"nightly_generic_alias\",\n\n  \"sort_bitonic\",\n  \"sort_radix\",\n  \"sort_rayon\",\n  \"sort_std\",\n\n  \"tooling\",\n  \"viewer\",\n\n  \"file_asset\",\n  \"web_asset\",\n]\n\ndebug_gpu = []\n\nio_bincode2 = [\"dep:bincode2\", \"dep:flate2\"]\nio_flexbuffers = [\"dep:flexbuffers\"]\nio_ply = [\"dep:ply-rs\"]\n\nmaterial_noise = [\"noise\", \"dep:noise\"]\n\nmorph_particles = []\nmorph_interpolate = []\n\nnightly_generic_alias = []\n\nnoise = []\n\nsh0 = []\nsh1 = []\nsh2 = []\nsh3 = []\nsh4 = []\n\nprecompute_covariance_3d = []\n\npacked = []\nplanar = []\n\nbuffer_storage = []\nbuffer_texture = []\n\nquery_raycast = []\nquery_select = []\nquery_sparse = [\"dep:kd-tree\", \"query_select\"]\n\nsort_bitonic = []\nsort_radix = []\nsort_rayon = [\"dep:rayon\"]\nsort_std = []\n\ntesting = []\ntooling = [\"dep:byte-unit\"]\ndebug_tooling = [\"tooling\"]\n\nperftest = []\n\nheadless = [\n  \"bevy/png\",\n  \"io_flexbuffers\",\n  \"io_ply\",\n  \"planar\",\n  \"buffer_storage\",\n  \"sh3\",\n  \"sort_rayon\",\n  \"sort_std\",\n  \"sort_bitonic\",\n  \"sort_radix\",\n]\n\nviewer = [\n  \"dep:bevy-inspector-egui\",\n  \"dep:bevy_panorbit_camera\",\n  # \"bevy_transform_gizmo\",\n  \"bevy/bevy_gizmos\",\n  \"bevy/bevy_text\",\n  \"bevy/bevy_ui\",\n  \"bevy/bevy_ui_render\",\n  \"bevy/multi_threaded\",  # bevy screenshot functionality requires bevy/multi_threaded as of 0.12.1\n  \"bevy/png\",\n]\n\nweb = [\n  \"buffer_storage\",\n  \"sh0\",\n  \"io_flexbuffers\",\n  \"io_ply\",\n  \"planar\",\n  \"sort_radix\",\n  \"sort_std\",\n  \"viewer\",\n  \"web_asset\",\n  \"webgpu\",\n]\nfile_asset = []\nweb_asset = [\n  \"bevy/http\",\n  \"bevy/https\",\n]\n\n# note: webgl2/buffer_texture are deprecated\nwebgl2 = [\"bevy/webgl2\"]\nwebgpu = [\"bevy/webgpu\"]\n\n\n[dependencies]\nbase64 = \"0.22\"\nbevy_args = { version = \"3.0.0\" }\nbevy-inspector-egui = { version = \"0.36.0\", optional = true }\nbevy_interleave = { version = \"0.9.0\" }\nbevy_panorbit_camera = { version = \"0.34.0\", optional = true, features = [\"bevy_egui\"] }\nbevy_transform_gizmo = { version = \"0.12.1\", optional = true }\nbincode2 = { version = \"2.0\", optional = true }\nbyte-unit = { version = \"5.2\", optional = true }\nbytemuck = \"1.23\"\nclap = { version = \"4.5\", features = [\"derive\"] }\nflate2  = { version = \"1.1\", optional = true }\nflexbuffers = { version = \"25.2\", optional = true }\ngltf = \"1.4\"\nhalf = { version = \"2.7\", features = [\"serde\"] }\n# image = { version = \"0.25.6\", default-features = false, features = [\"png\"] }\nkd-tree = { version = \"0.6\", optional = true }\nnoise = { version = \"0.9.0\", optional = true }\nply-rs = { version = \"0.1\", optional = true }\nrand = \"0.9\"\nrayon = { version = \"1.8\", optional = true }\nserde = \"1.0\"\nserde_json = \"1.0\"\nstatic_assertions = \"1.1\"\ntypenum = \"1.18\"\nwgpu = \"27\"\n\n\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\nconsole_error_panic_hook = \"0.1\"\ngetrandom = { version = \"0.3\", default-features = false, features = [\"wasm_js\"] }\nwasm-bindgen = \"0.2\"\n\n\n[dependencies.bevy]\nversion = \"0.18\"\ndefault-features = false\nfeatures = [\n  \"bevy_asset\",\n  \"bevy_camera\",\n  \"bevy_core_pipeline\",\n  \"bevy_log\",\n  \"bevy_pbr\",\n  \"bevy_render\",\n  \"bevy_winit\",\n  \"serialize\",\n  \"std\",\n  \"zstd_rust\",\n  \"x11\",\n]\n\n\n[dependencies.web-sys]\nversion = \"0.3\"\nfeatures = [\n  'Document',\n  'Element',\n  'HtmlElement',\n  'Location',\n  'Node',\n  'Window',\n]\n\n\n[dev-dependencies]\ncriterion = { version = \"0.8\", features = [\"html_reports\"] }\ncrossbeam-channel = \"0.5.15\"\nfutures-intrusive = { version = \"0.5.0\" }\npollster = { version = \"0.4.0\" }\n\n[profile.dev.package.\"*\"]\nopt-level = 3\n\n[profile.dev]\nopt-level = 1\n\n[profile.release]\nlto = \"thin\"\ncodegen-units = 1\nopt-level = 3\n\n[profile.wasm-release]\ninherits = \"release\"\nopt-level = \"z\"\nlto = \"fat\"\ncodegen-units = 1\n\n\n[lib]\npath = \"src/lib.rs\"\n\n[[bin]]\nname = \"bevy_gaussian_splatting\"\npath = \"viewer/viewer.rs\"\nrequired-features = [\"viewer\"]\n\n[[bin]]\nname = \"ply_to_gcloud\"\npath = \"tools/ply_to_gcloud.rs\"\nrequired-features = [\"io_ply\", \"tooling\"]\n\n\n[[bin]]\nname = \"compare_aabb_obb\"\npath = \"tools/compare_aabb_obb.rs\"\nrequired-features = [\"debug_tooling\"]\n\n[[bin]]\nname = \"surfel_plane\"\npath = \"tools/surfel_plane.rs\"\nrequired-features = [\"debug_tooling\"]\n\n[[bin]]\nname = \"render_trellis_thumbnails\"\npath = \"tools/render_trellis_thumbnails.rs\"\nrequired-features = [\"io_ply\"]\n\n\n[[bin]]\nname = \"test_gaussian\"\npath = \"tests/gpu/gaussian.rs\"\nrequired-features = [\"testing\"]\n\n[[bin]]\nname = \"test_radix\"\npath = \"tests/gpu/radix.rs\"\nrequired-features = [\"debug_gpu\", \"sort_radix\", \"testing\"]\n\n[[example]]\nname = \"minimal\"\npath = \"examples/minimal.rs\"\n\n[[example]]\nname = \"headless\"\npath = \"examples/headless.rs\"\nrequired-features = [\"headless\"]\n\n[[example]]\nname = \"multi_camera\"\npath = \"examples/multi_camera.rs\"\nrequired-features = [\"viewer\"]\n\n\n[[bench]]\nname = \"io\"\nharness = false\n"
  },
  {
    "path": "LICENSE-APACHE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless  by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n"
  },
  {
    "path": "LICENSE-MIT",
    "content": "MIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "# bevy_gaussian_splatting 🌌\n\n[![test](https://github.com/mosure/bevy_gaussian_splatting/workflows/test/badge.svg)](https://github.com/Mosure/bevy_gaussian_splatting/actions?query=workflow%3Atest)\n[![GitHub License](https://img.shields.io/github/license/mosure/bevy_gaussian_splatting)](https://raw.githubusercontent.com/mosure/bevy_gaussian_splatting/main/LICENSE-MIT)\n[![crates.io](https://img.shields.io/crates/v/bevy_gaussian_splatting.svg)](https://crates.io/crates/bevy_gaussian_splatting)\n\nbevy gaussian splatting render pipeline plugin. view the [live demo gallery](https://mosure.github.io/bevy_gaussian_splatting/examples/) or open [`trellis.glb`](https://mosure.github.io/bevy_gaussian_splatting/index.html?input_scene=https%3A%2F%2Fmitchell.mosure.me%2Ftrellis.glb&rasterization_mode=Color) directly.\n\n![Alt text](docs/bevy_gaussian_splatting_demo.webp)\n![Alt text](docs/go.gif)\n\n\n## install\n\n```bash\ncargo +nightly install bevy_gaussian_splatting\nbevy_gaussian_splatting --input-cloud [file://gaussian.ply | https://mitchell.mosure.me/go_trimmed.ply]\nbevy_gaussian_splatting --input-scene [file://scene.glb | https://mitchell.mosure.me/trellis.glb]\n```\n\n> note: default bevy_gaussian_splatting features require nightly rust for generic associated types. to use on stable, disable default features and `nightly_generic_alias` feature\n\n## viewer hotkeys\n\n- `esc`: close viewer\n- `s`: save screenshot to `screenshots/`\n- `g`: export the loaded gaussian scene to `exports/gaussian_scene_<frame>.glb` (cloud transforms + active camera)\n\n\n## capabilities\n\n- [X] ply to gcloud converter\n- [X] gcloud and ply asset loaders\n- [X] bevy gaussian cloud render pipeline\n- [X] gaussian cloud particle effects\n- [X] wasm support /w [live demo](https://mosure.github.io/bevy_gaussian_splatting/index.html)\n- [X] depth colorization\n- [X] normal rendering\n- [X] f16 and f32 gcloud\n- [X] wgl2 and webgpu\n- [X] multi-format scenes\n- [X] 2dgs\n- [X] 3dgs\n- [x] 4dgs\n- [X] [glTF `KHR_gaussian_splatting`](https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_gaussian_splatting) scene load/save\n- [ ] 4dgs motion blur\n- [ ] [deformable radial kernel](https://github.com/VAST-AI-Research/Deformable-Radial-Kernel-Splatting)\n- [ ] implicit mlp node (isotropic rotation, color)\n- [ ] temporal gaussian hierarchy\n- [ ] gcloud, spherical harmonic coefficients Huffman encoding\n- [ ] [spz](https://github.com/nianticlabs/spz) format io\n- [ ] spherical harmonic coefficients clustering\n- [ ] 4D gaussian cloud wavelet compression\n- [ ] accelerated spatial queries\n- [ ] temporal depth sorting\n- [ ] skeletons\n- [ ] volume masks\n- [ ] level of detail\n- [ ] lighting and shadows\n- [ ] bevy_openxr support\n- [ ] bevy 3D camera to gaussian cloud pipeline\n\n\n## usage\n\n```rust\nuse bevy::prelude::*;\nuse bevy_gaussian_splatting::{\n    CloudSettings,\n    GaussianSplattingPlugin,\n    PlanarGaussian3dHandle,\n};\n\nfn main() {\n    App::new()\n        .add_plugins(DefaultPlugins)\n        .add_plugins(GaussianSplattingPlugin)\n        .add_systems(Startup, setup_gaussian_cloud)\n        .run();\n}\n\nfn setup_gaussian_cloud(\n    mut commands: Commands,\n    asset_server: Res<AssetServer>,\n) {\n    // CloudSettings and Visibility are automatically added\n    commands.spawn((\n        PlanarGaussian3dHandle(asset_server.load(\"scenes/icecream.gcloud\")),\n        CloudSettings::default(),\n    ));\n\n    commands.spawn(Camera3d::default());\n}\n```\n\n\n## tools\n\n- [ply to gcloud converter](tools/README.md#ply-to-gcloud-converter)\n- [gaussian cloud training pipeline](https://github.com/mosure/burn_gaussian_splatting)\n- aabb vs. obb gaussian comparison via `cargo run --bin compare_aabb_obb`\n\n\n### creating gaussian clouds\n\nthe following tools are compatible with `bevy_gaussian_splatting`:\n\n- [X] 2d gaussian clouds:\n    - [gsplat](https://docs.gsplat.studio/main/)\n\n- [X] 3d gaussian clouds:\n    - [brush](https://github.com/ArthurBrussee/brush)\n    - [gsplat](https://docs.gsplat.studio/main/)\n    - [gaussian-splatting](https://github.com/graphdeco-inria/gaussian-splatting)\n\n- [X] 4d gaussian clouds:\n    - [4d-gaussian-splatting](https://fudan-zvg.github.io/4d-gaussian-splatting/)\n        - [4dgs ply-export](https://gist.github.com/mosure/d9d4d271e05a106157ce39db62ec4f84)\n    - [easy-volcap](https://github.com/zju3dv/EasyVolcap)\n\n\n## compatible bevy versions\n\n| `bevy_gaussian_splatting` | `bevy` |\n| :--                       | :--    |\n| `7.0`                     | `0.18` |\n| `6.0`                     | `0.17` |\n| `5.0`                     | `0.16` |\n| `3.0`                     | `0.15` |\n| `2.3`                     | `0.14` |\n| `2.1`                     | `0.13` |\n| `0.4 - 2.0`               | `0.12` |\n| `0.1 - 0.3`               | `0.11` |\n\n\n## license\nlicensed under either of\n\n * Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0)\n * MIT license ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT)\n\nat your option.\n\n\n## contribution\n\nunless you explicitly state otherwise, any contribution intentionally submitted\nfor inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any\nadditional terms or conditions.\n\n\n## analytics\n![alt](https://repobeats.axiom.co/api/embed/4f273f05f00ec57e90be34727e85952039e1a712.svg \"analytics\")\n"
  },
  {
    "path": "benches/io.rs",
    "content": "use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};\n\nuse bevy::prelude::Transform;\nuse bevy_gaussian_splatting::{\n    CloudSettings, Gaussian3d, Gaussian4d, GaussianPrimitiveMetadata, PlanarGaussian3d,\n    PlanarGaussian4d, SceneExportCloud, io::codec::CloudCodec,\n    io::scene::encode_khr_gaussian_scene_gltf_bytes, random_gaussians_3d, random_gaussians_4d,\n};\n\nconst GAUSSIAN_COUNTS: [usize; 4] = [\n    1000, 10000, 84_348, 1_244_819,\n    // 6_131_954,\n];\n\nfn gaussian_cloud_3d_decode_benchmark(c: &mut Criterion) {\n    let mut group = c.benchmark_group(\"encode 3d gaussian clouds\");\n    for count in GAUSSIAN_COUNTS.iter() {\n        group.throughput(Throughput::Bytes(\n            *count as u64 * std::mem::size_of::<Gaussian3d>() as u64,\n        ));\n        group.bench_with_input(BenchmarkId::new(\"decode/3d\", count), &count, |b, &count| {\n            let gaussians = random_gaussians_3d(*count);\n            let bytes = gaussians.encode();\n\n            b.iter(|| PlanarGaussian3d::decode(bytes.as_slice()));\n        });\n    }\n}\n\nfn gaussian_cloud_4d_decode_benchmark(c: &mut Criterion) {\n    let mut group = c.benchmark_group(\"encode 4d gaussian clouds\");\n    for count in GAUSSIAN_COUNTS.iter() {\n        group.throughput(Throughput::Bytes(\n            *count as u64 * std::mem::size_of::<Gaussian4d>() as u64,\n        ));\n        group.bench_with_input(BenchmarkId::new(\"decode/4d\", count), &count, |b, &count| {\n            let gaussians = random_gaussians_4d(*count);\n            let bytes = gaussians.encode();\n\n            b.iter(|| PlanarGaussian4d::decode(bytes.as_slice()));\n        });\n    }\n}\n\nfn khr_gltf_scene_encode_benchmark(c: &mut Criterion) {\n    let mut group = c.benchmark_group(\"encode khr gltf gaussian scenes\");\n    for count in GAUSSIAN_COUNTS.iter() {\n        group.throughput(Throughput::Bytes(\n            *count as u64 * std::mem::size_of::<Gaussian3d>() as u64,\n        ));\n        group.bench_with_input(\n            BenchmarkId::new(\"encode/khr_gltf_scene\", count),\n            &count,\n            |b, &count| {\n                let cloud = random_gaussians_3d(*count);\n                let export_cloud = SceneExportCloud {\n                    cloud,\n                    name: \"benchmark_cloud\".to_owned(),\n                    settings: CloudSettings::default(),\n                    transform: Transform::default(),\n                    metadata: GaussianPrimitiveMetadata::default(),\n                };\n\n                b.iter(|| {\n                    encode_khr_gaussian_scene_gltf_bytes(std::slice::from_ref(&export_cloud), None)\n                        .expect(\"benchmark scene encoding should succeed\");\n                });\n            },\n        );\n    }\n}\n\ncriterion_group! {\n    name = io_benches;\n    config = Criterion::default().sample_size(10);\n    targets = gaussian_cloud_3d_decode_benchmark,\n              gaussian_cloud_4d_decode_benchmark,\n              khr_gltf_scene_encode_benchmark,\n}\ncriterion_main!(io_benches);\n"
  },
  {
    "path": "docs/credits.md",
    "content": "- [2d-gaussian-splatting](https://github.com/hbb1/2d-gaussian-splatting)\n- [4d gaussians](https://github.com/hustvl/4DGaussians)\n- [4d-gaussian-splatting](https://fudan-zvg.github.io/4d-gaussian-splatting/)\n- [bevy](https://github.com/bevyengine/bevy)\n- [bevy-hanabi](https://github.com/djeedai/bevy_hanabi)\n- [d3ga](https://zielon.github.io/d3ga/)\n- [deformable-3d-gaussians](https://github.com/ingra14m/Deformable-3D-Gaussians)\n- [diff-gaussian-rasterization](https://github.com/graphdeco-inria/diff-gaussian-rasterization)\n- [dreamgaussian](https://github.com/dreamgaussian/dreamgaussian)\n- [dynamic-3d-gaussians](https://github.com/JonathonLuiten/Dynamic3DGaussians)\n- [ewa splatting](https://www.cs.umd.edu/~zwicker/publications/EWASplatting-TVCG02.pdf)\n- [gaussian-grouping](https://github.com/lkeab/gaussian-grouping)\n- [gaussian-splatting](https://github.com/graphdeco-inria/gaussian-splatting)\n- [gaussian-splatting-viewer](https://github.com/limacv/GaussianSplattingViewer/tree/main)\n- [gaussian-splatting-web](https://github.com/cvlab-epfl/gaussian-splatting-web)\n- [gir](https://3dgir.github.io/)\n- [making gaussian splats smaller](https://aras-p.info/blog/2023/09/13/Making-Gaussian-Splats-smaller/)\n- [masked-spacetime-hashing](https://github.com/masked-spacetime-hashing/msth)\n- [onesweep](https://arxiv.org/ftp/arxiv/papers/2206/2206.01784.pdf)\n- [pasture](https://github.com/Mortano/pasture)\n- [phys-gaussian](https://xpandora.github.io/PhysGaussian/)\n- [point-visualizer](https://github.com/mosure/point-visualizer)\n- [rusty-automata](https://github.com/mosure/rusty-automata)\n- [scaffold-gs](https://city-super.github.io/scaffold-gs/)\n- [shader-one-sweep](https://github.com/b0nes164/ShaderOneSweep)\n- [spacetime-gaussians](https://github.com/oppo-us-research/SpacetimeGaussians)\n- [splat](https://github.com/antimatter15/splat)\n- [splatter](https://github.com/Lichtso/splatter)\n- [sturdy-dollop](https://github.com/mosure/sturdy-dollop)\n- [sugar](https://github.com/Anttwo/SuGaR)\n- [taichi_3d_gaussian_splatting](https://github.com/wanmeihuali/taichi_3d_gaussian_splatting)\n- [temporal-gaussian-hierarchy](https://zju3dv.github.io/longvolcap/)\n"
  },
  {
    "path": "examples/headless.rs",
    "content": "//! Headless rendering for gaussian splatting\n//!\n//! Renders gaussian splatting to images without creating a window.\n//! Based on Bevy's headless_renderer example.\n//!\n//! Usage: cargo run --example headless --no-default-features --features \"headless\" -- [filename]\n\nuse bevy::{\n    app::{AppExit, ScheduleRunnerPlugin},\n    camera::RenderTarget,\n    core_pipeline::tonemapping::Tonemapping,\n    image::TextureFormatPixelInfo,\n    prelude::*,\n    render::{\n        Extract, Render, RenderApp, RenderSystems,\n        render_asset::RenderAssets,\n        render_graph::{self, NodeRunError, RenderGraph, RenderGraphContext, RenderLabel},\n        render_resource::{\n            Buffer, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, Extent3d, MapMode,\n            PollType, TexelCopyBufferInfo, TexelCopyBufferLayout, TextureFormat, TextureUsages,\n        },\n        renderer::{RenderContext, RenderDevice, RenderQueue},\n        texture::GpuImage,\n    },\n    window::ExitCondition,\n    winit::WinitPlugin,\n};\nuse bevy_args::BevyArgsPlugin;\nuse bevy_gaussian_splatting::{\n    CloudSettings, GaussianCamera, GaussianMode, GaussianSplattingPlugin, PlanarGaussian3d,\n    PlanarGaussian3dHandle, PlanarGaussian4d, PlanarGaussian4dHandle,\n    gaussian::interface::TestCloud, random_gaussians_3d, random_gaussians_3d_seeded,\n    random_gaussians_4d, random_gaussians_4d_seeded, utils::GaussianSplattingViewer,\n};\nuse crossbeam_channel::{Receiver, Sender};\nuse std::{\n    path::PathBuf,\n    sync::{\n        Arc,\n        atomic::{AtomicBool, Ordering},\n    },\n    time::Duration,\n};\n\n#[derive(Resource, Deref)]\nstruct MainWorldReceiver(Receiver<Vec<u8>>);\n\n#[derive(Resource, Deref)]\nstruct RenderWorldSender(Sender<Vec<u8>>);\n\n#[derive(Debug, Default, Resource)]\nstruct CaptureController {\n    frames_to_wait: u32,\n    width: u32,\n    height: u32,\n}\n\nimpl CaptureController {\n    pub fn new(width: u32, height: u32) -> Self {\n        Self {\n            frames_to_wait: 40,\n            width,\n            height,\n        }\n    }\n}\n\nfn main() {\n    App::new()\n        .insert_resource(CaptureController::new(1920, 1080))\n        .insert_resource(ClearColor(Color::srgb_u8(0, 0, 0)))\n        .add_plugins(\n            DefaultPlugins\n                .set(ImagePlugin::default_nearest())\n                .set(WindowPlugin {\n                    primary_window: None,\n                    exit_condition: ExitCondition::DontExit,\n                    ..default()\n                })\n                // Disable WinitPlugin for headless environments\n                .disable::<WinitPlugin>(),\n        )\n        .add_plugins(BevyArgsPlugin::<GaussianSplattingViewer>::default())\n        .add_plugins(ImageCopyPlugin)\n        .add_plugins(CaptureFramePlugin)\n        .add_plugins(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(\n            1.0 / 60.0,\n        )))\n        .add_plugins(GaussianSplattingPlugin)\n        .add_systems(Startup, setup_gaussian_cloud)\n        .run();\n}\n\n#[allow(clippy::too_many_arguments)]\nfn setup_gaussian_cloud(\n    mut commands: Commands,\n    asset_server: Res<AssetServer>,\n    args: Res<GaussianSplattingViewer>,\n    mut gaussian_assets: ResMut<Assets<PlanarGaussian3d>>,\n    mut gaussian_4d_assets: ResMut<Assets<PlanarGaussian4d>>,\n    mut images: ResMut<Assets<Image>>,\n    render_device: Res<RenderDevice>,\n    controller: Res<CaptureController>,\n) {\n    let cloud_transform = args.cloud_transform();\n    let cloud_settings = CloudSettings {\n        gaussian_mode: args.gaussian_mode,\n        playback_mode: args.playback_mode,\n        rasterize_mode: args.rasterization_mode,\n        ..default()\n    };\n\n    // Setup render target\n    let size = Extent3d {\n        width: controller.width,\n        height: controller.height,\n        ..default()\n    };\n\n    let mut render_target_image =\n        Image::new_target_texture(size.width, size.height, TextureFormat::bevy_default(), None);\n    render_target_image.texture_descriptor.usage |= TextureUsages::COPY_SRC;\n    let render_target_handle = images.add(render_target_image);\n\n    let cpu_image =\n        Image::new_target_texture(size.width, size.height, TextureFormat::bevy_default(), None);\n    let cpu_image_handle = images.add(cpu_image);\n\n    match args.gaussian_mode {\n        GaussianMode::Gaussian2d | GaussianMode::Gaussian3d => {\n            // Load or generate gaussian cloud\n            let cloud = if args.gaussian_count > 0 {\n                println!(\"Generating {} gaussians\", args.gaussian_count);\n                if let Some(seed) = args.gaussian_seed {\n                    gaussian_assets.add(random_gaussians_3d_seeded(args.gaussian_count, seed))\n                } else {\n                    gaussian_assets.add(random_gaussians_3d(args.gaussian_count))\n                }\n            } else if args.input_cloud.is_some() && !args.input_cloud.as_ref().unwrap().is_empty() {\n                println!(\"Loading {:?}\", args.input_cloud);\n                asset_server.load(args.input_cloud.as_ref().unwrap())\n            } else {\n                gaussian_assets.add(PlanarGaussian3d::test_model())\n            };\n\n            commands.spawn((\n                PlanarGaussian3dHandle(cloud),\n                cloud_settings.clone(),\n                Name::new(\"gaussian_cloud\"),\n                cloud_transform,\n            ));\n        }\n        GaussianMode::Gaussian4d => {\n            let cloud = if args.gaussian_count > 0 {\n                println!(\"Generating {} gaussians\", args.gaussian_count);\n                if let Some(seed) = args.gaussian_seed {\n                    gaussian_4d_assets.add(random_gaussians_4d_seeded(args.gaussian_count, seed))\n                } else {\n                    gaussian_4d_assets.add(random_gaussians_4d(args.gaussian_count))\n                }\n            } else if args.input_cloud.is_some() && !args.input_cloud.as_ref().unwrap().is_empty() {\n                println!(\"Loading {:?}\", args.input_cloud);\n                asset_server.load(args.input_cloud.as_ref().unwrap())\n            } else {\n                gaussian_4d_assets.add(PlanarGaussian4d::test_model())\n            };\n\n            commands.spawn((\n                PlanarGaussian4dHandle(cloud),\n                cloud_settings,\n                Name::new(\"gaussian_cloud\"),\n                cloud_transform,\n            ));\n        }\n    }\n\n    commands.spawn((\n        Camera3d::default(),\n        Camera::default(),\n        RenderTarget::Image(render_target_handle.clone().into()),\n        Transform::from_translation(Vec3::new(0.0, 1.5, 5.0)),\n        Tonemapping::None,\n        GaussianCamera::default(),\n    ));\n\n    // Spawn image copier for GPU->CPU transfer\n    commands.spawn(ImageCopier::new(render_target_handle, size, &render_device));\n\n    // Spawn image to save\n    commands.spawn(ImageToSave(cpu_image_handle));\n}\n\n/// Plugin for copying images from GPU to CPU\npub struct ImageCopyPlugin;\n\nimpl Plugin for ImageCopyPlugin {\n    fn build(&self, app: &mut App) {\n        let (sender, receiver) = crossbeam_channel::unbounded();\n\n        let render_app = app\n            .insert_resource(MainWorldReceiver(receiver))\n            .sub_app_mut(RenderApp);\n\n        let mut graph = render_app.world_mut().resource_mut::<RenderGraph>();\n        graph.add_node(ImageCopy, ImageCopyDriver);\n        graph.add_node_edge(bevy::render::graph::CameraDriverLabel, ImageCopy);\n\n        render_app\n            .insert_resource(RenderWorldSender(sender))\n            .add_systems(ExtractSchedule, extract_image_copiers)\n            .add_systems(\n                Render,\n                receive_image_from_buffer.after(RenderSystems::Render),\n            );\n    }\n}\n\npub struct CaptureFramePlugin;\n\nimpl Plugin for CaptureFramePlugin {\n    fn build(&self, app: &mut App) {\n        app.add_systems(PostUpdate, save_captured_frame);\n    }\n}\n\n#[derive(Clone, Component)]\nstruct ImageCopier {\n    buffer: Buffer,\n    enabled: Arc<AtomicBool>,\n    src_image: Handle<Image>,\n}\n\nimpl ImageCopier {\n    pub fn new(src_image: Handle<Image>, size: Extent3d, render_device: &RenderDevice) -> Self {\n        let padded_bytes_per_row = RenderDevice::align_copy_bytes_per_row(size.width as usize) * 4;\n\n        let buffer = render_device.create_buffer(&BufferDescriptor {\n            label: Some(\"image_copier_buffer\"),\n            size: padded_bytes_per_row as u64 * size.height as u64,\n            usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,\n            mapped_at_creation: false,\n        });\n\n        Self {\n            buffer,\n            src_image,\n            enabled: Arc::new(AtomicBool::new(true)),\n        }\n    }\n\n    pub fn enabled(&self) -> bool {\n        self.enabled.load(Ordering::Relaxed)\n    }\n}\n\n#[derive(Clone, Default, Resource, Deref)]\nstruct ImageCopiers(Vec<ImageCopier>);\n\nfn extract_image_copiers(mut commands: Commands, image_copiers: Extract<Query<&ImageCopier>>) {\n    commands.insert_resource(ImageCopiers(image_copiers.iter().cloned().collect()));\n}\n\n/// RenderGraph label\n#[derive(Debug, PartialEq, Eq, Clone, Hash, RenderLabel)]\nstruct ImageCopy;\n\n#[derive(Default)]\nstruct ImageCopyDriver;\n\nimpl render_graph::Node for ImageCopyDriver {\n    fn run(\n        &self,\n        _graph: &mut RenderGraphContext,\n        render_context: &mut RenderContext,\n        world: &World,\n    ) -> Result<(), NodeRunError> {\n        let image_copiers = world.get_resource::<ImageCopiers>().unwrap();\n        let gpu_images = world.get_resource::<RenderAssets<GpuImage>>().unwrap();\n\n        for image_copier in image_copiers.iter() {\n            if !image_copier.enabled() {\n                continue;\n            }\n\n            let Some(src_image) = gpu_images.get(&image_copier.src_image) else {\n                continue;\n            };\n\n            let mut encoder = render_context\n                .render_device()\n                .create_command_encoder(&CommandEncoderDescriptor::default());\n\n            let block_dimensions = src_image.texture_format.block_dimensions();\n            let block_size = src_image.texture_format.block_copy_size(None).unwrap();\n\n            let padded_bytes_per_row = RenderDevice::align_copy_bytes_per_row(\n                (src_image.size.width as usize / block_dimensions.0 as usize) * block_size as usize,\n            );\n\n            encoder.copy_texture_to_buffer(\n                src_image.texture.as_image_copy(),\n                TexelCopyBufferInfo {\n                    buffer: &image_copier.buffer,\n                    layout: TexelCopyBufferLayout {\n                        offset: 0,\n                        bytes_per_row: Some(\n                            std::num::NonZero::<u32>::new(padded_bytes_per_row as u32)\n                                .unwrap()\n                                .into(),\n                        ),\n                        rows_per_image: None,\n                    },\n                },\n                src_image.size,\n            );\n\n            let render_queue = world.get_resource::<RenderQueue>().unwrap();\n            render_queue.submit(std::iter::once(encoder.finish()));\n        }\n\n        Ok(())\n    }\n}\n\nfn receive_image_from_buffer(\n    image_copiers: Res<ImageCopiers>,\n    render_device: Res<RenderDevice>,\n    sender: Res<RenderWorldSender>,\n) {\n    for image_copier in image_copiers.0.iter() {\n        if !image_copier.enabled() {\n            continue;\n        }\n\n        let buffer_slice = image_copier.buffer.slice(..);\n        let (tx, rx) = crossbeam_channel::bounded(1);\n\n        buffer_slice.map_async(MapMode::Read, move |result| match result {\n            Ok(()) => tx.send(()).expect(\"Failed to send map result\"),\n            Err(err) => panic!(\"Failed to map buffer: {err}\"),\n        });\n\n        render_device\n            .poll(PollType::wait_indefinitely())\n            .expect(\"Failed to poll device\");\n\n        rx.recv().expect(\"Failed to receive buffer map\");\n\n        let _ = sender.send(buffer_slice.get_mapped_range().to_vec());\n        image_copier.buffer.unmap();\n    }\n}\n\n#[derive(Component, Deref)]\nstruct ImageToSave(Handle<Image>);\n\nfn save_captured_frame(\n    images_to_save: Query<&ImageToSave>,\n    receiver: Res<MainWorldReceiver>,\n    mut images: ResMut<Assets<Image>>,\n    mut controller: ResMut<CaptureController>,\n    mut app_exit: MessageWriter<AppExit>,\n) {\n    if controller.frames_to_wait > 0 {\n        controller.frames_to_wait -= 1;\n        while receiver.try_recv().is_ok() {}\n        return;\n    }\n\n    // Try to receive image data\n    let mut image_data = Vec::new();\n    while let Ok(data) = receiver.try_recv() {\n        image_data = data;\n    }\n\n    if image_data.is_empty() {\n        return;\n    }\n\n    for image_handle in images_to_save.iter() {\n        let Some(image) = images.get_mut(image_handle.id()) else {\n            continue;\n        };\n\n        let row_bytes =\n            image.width() as usize * image.texture_descriptor.format.pixel_size().unwrap();\n        let aligned_row_bytes = RenderDevice::align_copy_bytes_per_row(row_bytes);\n\n        if row_bytes == aligned_row_bytes {\n            image.data.as_mut().unwrap().clone_from(&image_data);\n        } else {\n            // Shrink to original size\n            image.data = Some(\n                image_data\n                    .chunks(aligned_row_bytes)\n                    .take(image.height() as usize)\n                    .flat_map(|row| &row[..row_bytes.min(row.len())])\n                    .cloned()\n                    .collect(),\n            );\n        }\n\n        let img = match image.clone().try_into_dynamic() {\n            Ok(img) => img.to_rgba8(),\n            Err(e) => panic!(\"Failed to create image: {e:?}\"),\n        };\n\n        let output_dir = PathBuf::from(env!(\"CARGO_MANIFEST_DIR\")).join(\"headless_output\");\n        std::fs::create_dir_all(&output_dir).unwrap();\n        let output_path = output_dir.join(\"0.png\");\n\n        info!(\"Saving screenshot to {:?}\", output_path);\n        if let Err(e) = img.save(&output_path) {\n            panic!(\"Failed to save image: {e}\");\n        }\n    }\n\n    app_exit.write(AppExit::Success);\n}\n"
  },
  {
    "path": "examples/minimal.rs",
    "content": "// TODO: minimal app\n\nfn main() {\n    println!(\"Hello, world!\");\n}\n"
  },
  {
    "path": "examples/multi_camera.rs",
    "content": "use bevy::{\n    app::AppExit, camera::Viewport, core_pipeline::tonemapping::Tonemapping, prelude::*,\n    window::WindowResized,\n};\nuse bevy_args::{BevyArgsPlugin, parse_args};\nuse bevy_inspector_egui::{bevy_egui::EguiPlugin, quick::WorldInspectorPlugin};\nuse bevy_panorbit_camera::{PanOrbitCamera, PanOrbitCameraPlugin};\n\nuse bevy_gaussian_splatting::{\n    CloudSettings, Gaussian3d, GaussianCamera, GaussianMode, GaussianSplattingPlugin,\n    PlanarGaussian3d, PlanarGaussian3dHandle, SphericalHarmonicCoefficients,\n    gaussian::f32::Rotation,\n    utils::{GaussianSplattingViewer, setup_hooks},\n};\n\nfn multi_camera_app() {\n    let config = parse_args::<GaussianSplattingViewer>();\n    let mut app = App::new();\n\n    // setup for gaussian viewer app\n    app.insert_resource(ClearColor(Color::srgb_u8(0, 0, 0)));\n    app.add_plugins(\n        DefaultPlugins\n            .set(ImagePlugin::default_nearest())\n            .set(WindowPlugin {\n                primary_window: Some(Window {\n                    mode: bevy::window::WindowMode::Windowed,\n                    present_mode: bevy::window::PresentMode::AutoVsync,\n                    prevent_default_event_handling: false,\n                    resolution: bevy::window::WindowResolution::new(\n                        config.width as u32,\n                        config.height as u32,\n                    ),\n                    title: config.name.clone(),\n                    ..default()\n                }),\n                ..default()\n            }),\n    );\n    app.add_plugins(BevyArgsPlugin::<GaussianSplattingViewer>::default());\n    app.add_plugins(PanOrbitCameraPlugin);\n\n    if config.editor {\n        app.add_plugins(EguiPlugin::default());\n        app.add_plugins(WorldInspectorPlugin::new());\n    }\n\n    if config.press_esc_close {\n        app.add_systems(Update, esc_close);\n    }\n\n    app.add_plugins(GaussianSplattingPlugin);\n    app.add_systems(Startup, setup_multi_camera);\n    app.add_systems(Update, (press_s_to_spawn_camera, set_camera_viewports));\n\n    app.run();\n}\n\npub fn setup_multi_camera(\n    mut commands: Commands,\n    _asset_server: Res<AssetServer>,\n    mut gaussian_assets: ResMut<Assets<PlanarGaussian3d>>,\n) {\n    let grid_size_x = 10;\n    let grid_size_y = 10;\n    let spacing = 12.0;\n\n    // let mut blue_gaussians = Vec::new();\n    // let mut blue_sh = SphericalHarmonicCoefficients::default();\n    // blue_sh.set(2, 5.0);\n\n    // for i in 0..grid_size_x {\n    //     for j in 0..grid_size_y {\n    //         let x = i as f32 * spacing - (grid_size_x as f32 * spacing) / 2.0;\n    //         let y = j as f32 * spacing - (grid_size_y as f32 * spacing) / 2.0;\n    //         let position = [x, y, 0.0, 1.0];\n    //         let scale = [2.0, 1.0, 0.01, 0.5];\n\n    //         let angle = std::f32::consts::PI / 2.0 * i as f32 / grid_size_x as f32;\n    //         let rotation = Quat::from_rotation_z(angle).to_array();\n    //         let rotation = [3usize, 0usize, 1usize, 2usize]\n    //             .iter()\n    //             .map(|i| rotation[*i])\n    //             .collect::<Vec<_>>()\n    //             .try_into()\n    //             .unwrap();\n\n    //         let gaussian = Gaussian {\n    //             position_visibility: position.into(),\n    //             rotation: Rotation {\n    //                 rotation,\n    //             },\n    //             scale_opacity: scale.into(),\n    //             spherical_harmonic: blue_sh,\n    //         };\n    //         blue_gaussians.push(gaussian);\n    //     }\n    // }\n\n    // let cloud = asset_server.load(\"office.ply\");\n    // commands.spawn((\n    //     GaussianSplattingBundle {\n    //         cloud,//: gaussian_assets.add(blue_gaussians.into()),\n    //         ..default()\n    //     },\n    //     Name::new(\"gaussian_cloud_3dgs\"),\n    // ));\n\n    let mut red_gaussians = Vec::new();\n    let mut red_sh = SphericalHarmonicCoefficients::default();\n    red_sh.set(0, 5.0);\n\n    for i in 0..grid_size_x {\n        for j in 0..grid_size_y {\n            let x = i as f32 * spacing - (grid_size_x as f32 * spacing) / 2.0;\n            let y = j as f32 * spacing - (grid_size_y as f32 * spacing) / 2.0;\n            let position = [x, y, 0.0, 1.0];\n            let scale = [2.0, 1.0, 0.01, 0.5];\n\n            let angle = std::f32::consts::PI / 2.0 * (i + 1) as f32 / grid_size_x as f32;\n            let rotation = Quat::from_rotation_z(angle).to_array();\n            let rotation = [3usize, 0usize, 1usize, 2usize]\n                .iter()\n                .map(|i| rotation[*i])\n                .collect::<Vec<_>>()\n                .try_into()\n                .unwrap();\n\n            let gaussian = Gaussian3d {\n                position_visibility: position.into(),\n                rotation: Rotation { rotation },\n                scale_opacity: scale.into(),\n                spherical_harmonic: red_sh,\n            };\n            red_gaussians.push(gaussian);\n        }\n    }\n\n    commands.spawn((\n        Transform::from_translation(Vec3::new(spacing, spacing, 0.0)),\n        PlanarGaussian3dHandle(gaussian_assets.add(red_gaussians)),\n        CloudSettings {\n            aabb: true,\n            gaussian_mode: GaussianMode::Gaussian2d,\n            ..default()\n        },\n        Name::new(\"gaussian_cloud_2dgs\"),\n    ));\n\n    commands.spawn((\n        GaussianCamera { warmup: true },\n        Camera3d::default(),\n        Camera {\n            order: 0,\n            ..default()\n        },\n        Transform::from_translation(Vec3::new(0.0, 1.5, 20.0)),\n        Tonemapping::None,\n        CameraPosition {\n            pos: UVec2::new(0, 0),\n        },\n        PanOrbitCamera {\n            allow_upside_down: true,\n            ..default()\n        },\n    ));\n\n    // commands.spawn((\n    //     GaussianCamera,\n    //     Camera3dBundle {\n    //         camera: Camera{\n    //             order: 1,\n    //             ..default()\n    //         },\n    //         transform: Transform::from_translation(Vec3::new(0.0, 0.0, 40.0)),\n    //         tonemapping: Tonemapping::None,\n    //         ..default()\n    //     },\n    //     CameraPosition {\n    //         pos: UVec2::new(1, 0),\n    //     },\n    //     PanOrbitCamera {\n    //         allow_upside_down: true,\n    //         ..default()\n    //     },\n    // ));\n}\n\nfn press_s_to_spawn_camera(\n    keys: Res<ButtonInput<KeyCode>>,\n    mut commands: Commands,\n    windows: Query<&Window>,\n) {\n    if keys.just_pressed(KeyCode::KeyS) {\n        let window = windows.single().unwrap();\n        let size = window.physical_size() / UVec2::new(2, 1);\n        let pos = UVec2::new(1, 0);\n\n        commands.spawn((\n            GaussianCamera { warmup: true },\n            Camera3d::default(),\n            Camera {\n                order: 1,\n                viewport: Viewport {\n                    physical_position: pos * size,\n                    physical_size: size,\n                    ..default()\n                }\n                .into(),\n                ..default()\n            },\n            Transform::from_translation(Vec3::new(0.0, 0.0, 40.0)),\n            Tonemapping::None,\n            CameraPosition { pos },\n            PanOrbitCamera {\n                allow_upside_down: true,\n                ..default()\n            },\n        ));\n    }\n}\n\n#[derive(Component)]\nstruct CameraPosition {\n    pos: UVec2,\n}\n\nfn set_camera_viewports(\n    windows: Query<&Window>,\n    mut resize_events: MessageReader<WindowResized>,\n    mut cameras: Query<(&CameraPosition, &mut Camera), With<GaussianCamera>>,\n) {\n    for resize_event in resize_events.read() {\n        let window = windows.get(resize_event.window).unwrap();\n        let size = window.physical_size() / UVec2::new(2, 1);\n\n        for (position, mut camera) in &mut cameras {\n            camera.viewport = Some(Viewport {\n                physical_position: position.pos * size,\n                physical_size: size,\n                ..default()\n            });\n        }\n    }\n}\n\nfn esc_close(keys: Res<ButtonInput<KeyCode>>, mut exit: MessageWriter<AppExit>) {\n    if keys.just_pressed(KeyCode::Escape) {\n        exit.write(AppExit::Success);\n    }\n}\n\npub fn main() {\n    setup_hooks();\n    multi_camera_app();\n}\n"
  },
  {
    "path": "src/camera.rs",
    "content": "use bevy::{\n    prelude::*,\n    render::extract_component::{ExtractComponent, ExtractComponentPlugin},\n};\n\n#[derive(Clone, Component, Debug, Default, ExtractComponent, Reflect)]\npub struct GaussianCamera {\n    pub warmup: bool,\n}\n\n#[derive(Default)]\npub struct GaussianCameraPlugin;\n\nimpl Plugin for GaussianCameraPlugin {\n    fn build(&self, app: &mut App) {\n        app.add_plugins(ExtractComponentPlugin::<GaussianCamera>::default());\n\n        app.add_systems(Update, apply_camera_warmup);\n    }\n}\n\n// TODO: remove camera warmup when extracted view dynamic uniform offset synchronization is fixed\nfn apply_camera_warmup(mut cameras: Query<&mut GaussianCamera>) {\n    for mut camera in cameras.iter_mut() {\n        if camera.warmup {\n            camera.warmup = false;\n        }\n    }\n}\n"
  },
  {
    "path": "src/gaussian/cloud.rs",
    "content": "use bevy::{\n    camera::{\n        primitives::Aabb,\n        visibility::{NoFrustumCulling, VisibilityClass, VisibilitySystems, add_visibility_class},\n    },\n    ecs::{lifecycle::HookContext, world::DeferredWorld},\n    math::bounding::BoundingVolume,\n    prelude::*,\n};\nuse bevy_interleave::prelude::*;\n\nuse crate::gaussian::interface::CommonCloud;\n\n#[derive(Default)]\npub struct CloudPlugin<R: PlanarSync> {\n    _phantom: std::marker::PhantomData<R>,\n}\n\npub struct CloudVisibilityClass;\n\nfn add_planar_class(world: DeferredWorld, ctx: HookContext) {\n    add_visibility_class::<CloudVisibilityClass>(world, ctx);\n}\n\nimpl<R: PlanarSync + Reflect + TypePath> Plugin for CloudPlugin<R>\nwhere\n    R::PlanarType: CommonCloud,\n    R::PlanarTypeHandle: FromReflect + bevy::reflect::Typed,\n{\n    fn build(&self, app: &mut App) {\n        app.register_required_components::<R::PlanarTypeHandle, VisibilityClass>();\n        app.world_mut()\n            .register_component_hooks::<R::PlanarTypeHandle>()\n            .on_add(add_planar_class);\n\n        app.add_systems(\n            PostUpdate,\n            (calculate_bounds::<R>.in_set(VisibilitySystems::CalculateBounds),),\n        );\n    }\n}\n\n// TODO: handle aabb updates (e.g. gaussian particle movements)\n#[allow(clippy::type_complexity)]\npub fn calculate_bounds<R: PlanarSync>(\n    mut commands: Commands,\n    gaussian_clouds: Res<Assets<R::PlanarType>>,\n    without_aabb: Query<(Entity, &R::PlanarTypeHandle), (Without<Aabb>, Without<NoFrustumCulling>)>,\n) where\n    R::PlanarType: CommonCloud,\n{\n    for (entity, cloud_handle) in &without_aabb {\n        if let Some(cloud) = gaussian_clouds.get(cloud_handle.handle())\n            && let Some(aabb3d) = cloud.compute_aabb()\n        {\n            commands.entity(entity).try_insert(Aabb {\n                center: aabb3d.center(),\n                half_extents: aabb3d.half_size(),\n            });\n        }\n    }\n}\n"
  },
  {
    "path": "src/gaussian/covariance.rs",
    "content": "use bevy::math::{Mat3, Vec3, Vec4};\n\n#[allow(non_snake_case)]\npub fn compute_covariance_3d(rotation: Vec4, scale: Vec3) -> [f32; 6] {\n    let S = Mat3::from_diagonal(scale);\n\n    let r = rotation.x;\n    let x = rotation.y;\n    let y = rotation.z;\n    let z = rotation.w;\n\n    let R = Mat3::from_cols(\n        Vec3::new(\n            1.0 - 2.0 * (y * y + z * z),\n            2.0 * (x * y - r * z),\n            2.0 * (x * z + r * y),\n        ),\n        Vec3::new(\n            2.0 * (x * y + r * z),\n            1.0 - 2.0 * (x * x + z * z),\n            2.0 * (y * z - r * x),\n        ),\n        Vec3::new(\n            2.0 * (x * z - r * y),\n            2.0 * (y * z + r * x),\n            1.0 - 2.0 * (x * x + y * y),\n        ),\n    );\n\n    let M = S * R;\n    let Sigma = M.transpose() * M;\n\n    [\n        Sigma.row(0).x,\n        Sigma.row(0).y,\n        Sigma.row(0).z,\n        Sigma.row(1).y,\n        Sigma.row(1).z,\n        Sigma.row(2).z,\n    ]\n}\n"
  },
  {
    "path": "src/gaussian/f16.rs",
    "content": "#![allow(dead_code)] // ShaderType derives emit unused check helpers\nuse std::marker::Copy;\n\nuse half::f16;\n\nuse bevy::{prelude::*, render::render_resource::ShaderType};\nuse bytemuck::{Pod, Zeroable};\nuse serde::{Deserialize, Serialize};\n\nuse crate::gaussian::{\n    f32::{Covariance3dOpacity, Rotation, ScaleOpacity},\n    formats::{planar_3d::Gaussian3d, planar_4d::Gaussian4d},\n};\n\n#[allow(dead_code)]\n#[derive(\n    Clone,\n    Debug,\n    Default,\n    Copy,\n    PartialEq,\n    Reflect,\n    ShaderType,\n    Pod,\n    Zeroable,\n    Serialize,\n    Deserialize,\n)]\n#[repr(C)]\npub struct RotationScaleOpacityPacked128 {\n    #[reflect(ignore)]\n    pub rotation: [u32; 2],\n    #[reflect(ignore)]\n    pub scale_opacity: [u32; 2],\n}\n\nimpl RotationScaleOpacityPacked128 {\n    pub fn from_gaussian(gaussian: &Gaussian3d) -> Self {\n        Self {\n            rotation: [\n                pack_f32s_to_u32(gaussian.rotation.rotation[0], gaussian.rotation.rotation[1]),\n                pack_f32s_to_u32(gaussian.rotation.rotation[2], gaussian.rotation.rotation[3]),\n            ],\n            scale_opacity: [\n                pack_f32s_to_u32(\n                    gaussian.scale_opacity.scale[0],\n                    gaussian.scale_opacity.scale[1],\n                ),\n                pack_f32s_to_u32(\n                    gaussian.scale_opacity.scale[2],\n                    gaussian.scale_opacity.opacity,\n                ),\n            ],\n        }\n    }\n\n    pub fn rotation(&self) -> Rotation {\n        let (u0, l0) = unpack_u32_to_f32s(self.rotation[0]);\n        let (u1, l1) = unpack_u32_to_f32s(self.rotation[1]);\n\n        Rotation {\n            rotation: [u0, l0, u1, l1],\n        }\n    }\n\n    pub fn scale_opacity(&self) -> ScaleOpacity {\n        let (u0, l0) = unpack_u32_to_f32s(self.scale_opacity[0]);\n        let (u1, l1) = unpack_u32_to_f32s(self.scale_opacity[1]);\n\n        ScaleOpacity {\n            scale: [u0, l0, u1],\n            opacity: l1,\n        }\n    }\n}\n\nimpl From<[f32; 8]> for RotationScaleOpacityPacked128 {\n    fn from(rotation_scale_opacity: [f32; 8]) -> Self {\n        Self {\n            rotation: [\n                pack_f32s_to_u32(rotation_scale_opacity[0], rotation_scale_opacity[1]),\n                pack_f32s_to_u32(rotation_scale_opacity[2], rotation_scale_opacity[3]),\n            ],\n            scale_opacity: [\n                pack_f32s_to_u32(rotation_scale_opacity[4], rotation_scale_opacity[5]),\n                pack_f32s_to_u32(rotation_scale_opacity[6], rotation_scale_opacity[7]),\n            ],\n        }\n    }\n}\n\nimpl From<[f16; 8]> for RotationScaleOpacityPacked128 {\n    fn from(rotation_scale_opacity: [f16; 8]) -> Self {\n        Self {\n            rotation: [\n                pack_f16s_to_u32(rotation_scale_opacity[0], rotation_scale_opacity[1]),\n                pack_f16s_to_u32(rotation_scale_opacity[2], rotation_scale_opacity[3]),\n            ],\n            scale_opacity: [\n                pack_f16s_to_u32(rotation_scale_opacity[4], rotation_scale_opacity[5]),\n                pack_f16s_to_u32(rotation_scale_opacity[6], rotation_scale_opacity[7]),\n            ],\n        }\n    }\n}\n\nimpl From<[u32; 4]> for RotationScaleOpacityPacked128 {\n    fn from(rotation_scale_opacity: [u32; 4]) -> Self {\n        Self {\n            rotation: [rotation_scale_opacity[0], rotation_scale_opacity[1]],\n            scale_opacity: [rotation_scale_opacity[2], rotation_scale_opacity[3]],\n        }\n    }\n}\n\n#[allow(dead_code)]\n#[derive(\n    Clone,\n    Debug,\n    Default,\n    Copy,\n    PartialEq,\n    Reflect,\n    ShaderType,\n    Pod,\n    Zeroable,\n    Serialize,\n    Deserialize,\n)]\n#[repr(C)]\npub struct Covariance3dOpacityPacked128 {\n    #[reflect(ignore)]\n    pub cov3d: [u32; 3],\n    pub opacity: u32,\n}\n\nimpl Covariance3dOpacityPacked128 {\n    pub fn from_gaussian(gaussian: &Gaussian3d) -> Self {\n        let cov3d: Covariance3dOpacity = gaussian.into();\n        let cov3d = cov3d.cov3d;\n\n        let opacity = gaussian.scale_opacity.opacity;\n\n        Self {\n            cov3d: [\n                pack_f32s_to_u32(cov3d[0], cov3d[1]),\n                pack_f32s_to_u32(cov3d[2], cov3d[3]),\n                pack_f32s_to_u32(cov3d[4], cov3d[5]),\n            ],\n            opacity: pack_f32s_to_u32(opacity, opacity), // TODO: benefit from 32-bit opacity\n        }\n    }\n\n    pub fn covariance_3d_opacity(&self) -> Covariance3dOpacity {\n        let (c0, c1) = unpack_u32_to_f32s(self.cov3d[0]);\n        let (c2, c3) = unpack_u32_to_f32s(self.cov3d[1]);\n        let (c4, c5) = unpack_u32_to_f32s(self.cov3d[2]);\n\n        let (opacity, _) = unpack_u32_to_f32s(self.opacity);\n\n        let cov3d: [f32; 6] = [c0, c1, c2, c3, c4, c5];\n\n        Covariance3dOpacity {\n            cov3d,\n            opacity,\n            pad: 0.0,\n        }\n    }\n}\n\nimpl From<[u32; 4]> for Covariance3dOpacityPacked128 {\n    fn from(cov3d_opacity: [u32; 4]) -> Self {\n        Self {\n            cov3d: [cov3d_opacity[0], cov3d_opacity[1], cov3d_opacity[2]],\n            opacity: cov3d_opacity[3],\n        }\n    }\n}\n\n#[allow(dead_code)]\n#[derive(\n    Clone,\n    Debug,\n    Default,\n    Copy,\n    PartialEq,\n    Reflect,\n    ShaderType,\n    Pod,\n    Zeroable,\n    Serialize,\n    Deserialize,\n)]\n#[repr(C)]\npub struct IsotropicRotations {\n    pub rotation: [u32; 2],\n    pub rotation_r: [u32; 2],\n}\n\nimpl IsotropicRotations {\n    pub fn from_gaussian(gaussian: &Gaussian4d) -> Self {\n        let rotation = gaussian.isotropic_rotations.rotation;\n        let rotation_r = gaussian.isotropic_rotations.rotation_r;\n\n        Self {\n            rotation: [\n                pack_f32s_to_u32(rotation[0], rotation[1]),\n                pack_f32s_to_u32(rotation[2], rotation[3]),\n            ],\n            rotation_r: [\n                pack_f32s_to_u32(rotation_r[0], rotation_r[1]),\n                pack_f32s_to_u32(rotation_r[2], rotation_r[3]),\n            ],\n        }\n    }\n\n    pub fn rotations(&self) -> [Rotation; 2] {\n        let (u0, l0) = unpack_u32_to_f32s(self.rotation[0]);\n        let (u1, l1) = unpack_u32_to_f32s(self.rotation[1]);\n\n        let (u0_r, l0_r) = unpack_u32_to_f32s(self.rotation_r[0]);\n        let (u1_r, l1_r) = unpack_u32_to_f32s(self.rotation_r[1]);\n\n        [\n            Rotation {\n                rotation: [u0, l0, u1, l1],\n            },\n            Rotation {\n                rotation: [u0_r, l0_r, u1_r, l1_r],\n            },\n        ]\n    }\n}\n\nimpl From<[u32; 4]> for IsotropicRotations {\n    fn from(rotations: [u32; 4]) -> Self {\n        Self {\n            rotation: [rotations[0], rotations[1]],\n            rotation_r: [rotations[2], rotations[3]],\n        }\n    }\n}\n\npub fn pack_f32s_to_u32(upper: f32, lower: f32) -> u32 {\n    pack_f16s_to_u32(f16::from_f32(upper), f16::from_f32(lower))\n}\n\npub fn pack_f16s_to_u32(upper: f16, lower: f16) -> u32 {\n    let upper_bits = (upper.to_bits() as u32) << 16;\n    let lower_bits = lower.to_bits() as u32;\n    upper_bits | lower_bits\n}\n\npub fn unpack_u32_to_f16s(value: u32) -> (f16, f16) {\n    let upper = f16::from_bits((value >> 16) as u16);\n    let lower = f16::from_bits((value & 0xFFFF) as u16);\n    (upper, lower)\n}\n\npub fn unpack_u32_to_f32s(value: u32) -> (f32, f32) {\n    let (upper, lower) = unpack_u32_to_f16s(value);\n    (upper.to_f32(), lower.to_f32())\n}\n"
  },
  {
    "path": "src/gaussian/f32.rs",
    "content": "#![allow(dead_code)] // ShaderType derives emit unused check helpers\nuse std::marker::Copy;\n\nuse bevy::{prelude::*, render::render_resource::ShaderType};\nuse bytemuck::{Pod, Zeroable};\nuse serde::{Deserialize, Serialize};\n\nuse crate::gaussian::{\n    covariance::compute_covariance_3d,\n    formats::{planar_3d::Gaussian3d, planar_4d::Gaussian4d},\n};\n\npub type Position = [f32; 3];\n\n#[allow(dead_code)]\n#[derive(\n    Clone,\n    Debug,\n    Default,\n    Copy,\n    PartialEq,\n    Reflect,\n    ShaderType,\n    Pod,\n    Zeroable,\n    Serialize,\n    Deserialize,\n)]\n#[repr(C)]\npub struct PositionTimestamp {\n    pub position: Position,\n    pub timestamp: f32,\n}\n\nimpl From<[f32; 4]> for PositionTimestamp {\n    fn from(position_timestamp: [f32; 4]) -> Self {\n        Self {\n            position: [\n                position_timestamp[0],\n                position_timestamp[1],\n                position_timestamp[2],\n            ],\n            timestamp: position_timestamp[3],\n        }\n    }\n}\n\n#[allow(dead_code)]\n#[derive(\n    Clone, Debug, Copy, PartialEq, Reflect, ShaderType, Pod, Zeroable, Serialize, Deserialize,\n)]\n#[repr(C)]\npub struct PositionVisibility {\n    pub position: Position,\n    pub visibility: f32,\n}\n\nimpl Default for PositionVisibility {\n    fn default() -> Self {\n        Self {\n            position: Position::default(),\n            visibility: 1.0,\n        }\n    }\n}\n\nimpl From<[f32; 4]> for PositionVisibility {\n    fn from(position_visibility: [f32; 4]) -> Self {\n        Self {\n            position: [\n                position_visibility[0],\n                position_visibility[1],\n                position_visibility[2],\n            ],\n            visibility: position_visibility[3],\n        }\n    }\n}\n\n#[allow(dead_code)]\n#[derive(\n    Clone,\n    Debug,\n    Default,\n    Copy,\n    PartialEq,\n    Reflect,\n    ShaderType,\n    Pod,\n    Zeroable,\n    Serialize,\n    Deserialize,\n)]\n#[repr(C)]\npub struct Rotation {\n    pub rotation: [f32; 4],\n}\n\nimpl From<[f32; 4]> for Rotation {\n    fn from(rotation: [f32; 4]) -> Self {\n        Self { rotation }\n    }\n}\n\n#[allow(dead_code)]\n#[derive(\n    Clone,\n    Debug,\n    Default,\n    Copy,\n    PartialEq,\n    Reflect,\n    ShaderType,\n    Pod,\n    Zeroable,\n    Serialize,\n    Deserialize,\n)]\n#[repr(C)]\npub struct IsotropicRotations {\n    pub rotation: [f32; 4],\n    pub rotation_r: [f32; 4],\n}\n\nimpl IsotropicRotations {\n    pub fn from_gaussian(gaussian: &Gaussian4d) -> Self {\n        let rotation = gaussian.isotropic_rotations.rotation;\n        let rotation_r = gaussian.isotropic_rotations.rotation_r;\n\n        Self {\n            rotation,\n            rotation_r,\n        }\n    }\n\n    pub fn rotations(&self) -> [Rotation; 2] {\n        [\n            Rotation {\n                rotation: self.rotation,\n            },\n            Rotation {\n                rotation: self.rotation_r,\n            },\n        ]\n    }\n}\n\nimpl From<[f32; 8]> for IsotropicRotations {\n    fn from(rotations: [f32; 8]) -> Self {\n        Self {\n            rotation: [rotations[0], rotations[1], rotations[2], rotations[3]],\n            rotation_r: [rotations[4], rotations[5], rotations[6], rotations[7]],\n        }\n    }\n}\n\n#[allow(dead_code)]\n#[derive(\n    Clone,\n    Debug,\n    Default,\n    Copy,\n    PartialEq,\n    Reflect,\n    ShaderType,\n    Pod,\n    Zeroable,\n    Serialize,\n    Deserialize,\n)]\n#[repr(C)]\npub struct ScaleOpacity {\n    pub scale: [f32; 3],\n    pub opacity: f32,\n}\n\nimpl From<[f32; 4]> for ScaleOpacity {\n    fn from(scale_opacity: [f32; 4]) -> Self {\n        Self {\n            scale: [scale_opacity[0], scale_opacity[1], scale_opacity[2]],\n            opacity: scale_opacity[3],\n        }\n    }\n}\n\n#[allow(dead_code)]\n#[derive(\n    Clone,\n    Debug,\n    Default,\n    Copy,\n    PartialEq,\n    Reflect,\n    ShaderType,\n    Pod,\n    Zeroable,\n    Serialize,\n    Deserialize,\n)]\n#[repr(C)]\npub struct TimestampTimescale {\n    pub timestamp: f32,\n    pub timescale: f32,\n    pub _pad: [f32; 2],\n}\n\nimpl From<[f32; 4]> for TimestampTimescale {\n    fn from(timestamp_timescale: [f32; 4]) -> Self {\n        Self {\n            timestamp: timestamp_timescale[0],\n            timescale: timestamp_timescale[1],\n            _pad: [0.0, 0.0],\n        }\n    }\n}\n\n#[allow(dead_code)]\n#[derive(\n    Clone,\n    Debug,\n    Default,\n    Copy,\n    PartialEq,\n    Reflect,\n    ShaderType,\n    Pod,\n    Zeroable,\n    Serialize,\n    Deserialize,\n)]\n#[repr(C)]\npub struct Covariance3dOpacity {\n    pub cov3d: [f32; 6],\n    pub opacity: f32,\n    pub pad: f32,\n}\n\nimpl From<&Gaussian3d> for Covariance3dOpacity {\n    fn from(gaussian: &Gaussian3d) -> Self {\n        let cov3d = compute_covariance_3d(\n            Vec4::from_slice(gaussian.rotation.rotation.as_slice()),\n            Vec3::from_slice(gaussian.scale_opacity.scale.as_slice()),\n        );\n\n        Covariance3dOpacity {\n            cov3d,\n            opacity: gaussian.scale_opacity.opacity,\n            pad: 0.0,\n        }\n    }\n}\n"
  },
  {
    "path": "src/gaussian/formats/mod.rs",
    "content": "// TODO: move all format specific code here (e.g. rand, packed)\n\npub mod planar_3d;\npub mod planar_3d_chunked;\npub mod planar_3d_lod;\npub mod planar_3d_quantized;\npub mod planar_3d_spz;\npub mod planar_4d;\npub mod planar_4d_hierarchy;\npub mod planar_4d_quantized;\npub mod spacetime;\n"
  },
  {
    "path": "src/gaussian/formats/planar_3d.rs",
    "content": "use rand::{\n    Rng, SeedableRng,\n    distr::{Distribution, StandardUniform},\n    rng,\n    rngs::StdRng,\n    seq::SliceRandom,\n};\nuse std::marker::Copy;\n\nuse bevy::prelude::*;\nuse bevy_interleave::prelude::*;\nuse bytemuck::{Pod, Zeroable};\nuse serde::{Deserialize, Serialize};\n\n#[allow(unused_imports)]\nuse crate::{\n    gaussian::{\n        f32::{Covariance3dOpacity, PositionVisibility, Rotation, ScaleOpacity},\n        interface::{CommonCloud, TestCloud},\n        iter::PositionIter,\n        settings::CloudSettings,\n    },\n    material::spherical_harmonics::{\n        HALF_SH_COEFF_COUNT, SH_COEFF_COUNT, SphericalHarmonicCoefficients,\n    },\n};\n\n#[derive(\n    Clone,\n    Debug,\n    Default,\n    Copy,\n    PartialEq,\n    Planar,\n    ReflectInterleaved,\n    StorageBindings,\n    Reflect,\n    Pod,\n    Zeroable,\n    Serialize,\n    Deserialize,\n)]\n#[serde(default)]\n#[repr(C)]\npub struct Gaussian3d {\n    #[serde(default)]\n    pub position_visibility: PositionVisibility,\n    #[serde(default)]\n    pub spherical_harmonic: SphericalHarmonicCoefficients,\n    #[serde(default)]\n    pub rotation: Rotation,\n    #[serde(default)]\n    pub scale_opacity: ScaleOpacity,\n}\n\npub type Gaussian2d = Gaussian3d; // GaussianMode::Gaussian2d /w Gaussian3d structure\n\n// #[allow(unused_imports)]\n// #[cfg(feature = \"f16\")]\n// use crate::gaussian::f16::{\n//     Covariance3dOpacityPacked128,\n//     RotationScaleOpacityPacked128,\n//     pack_f32s_to_u32,\n// };\n\n// #[cfg(feature = \"f16\")]\n// #[derive(\n//     Debug,\n//     Default,\n//     PartialEq,\n//     Reflect,\n//     Serialize,\n//     Deserialize,\n// )]\n// pub struct Cloud3d {\n//     pub position_visibility: Vec<PositionVisibility>,\n\n//     pub spherical_harmonic: Vec<SphericalHarmonicCoefficients>,\n\n//     #[cfg(not(feature = \"precompute_covariance_3d\"))]\n//     pub rotation_scale_opacity_packed128: Vec<RotationScaleOpacityPacked128>,\n\n//     #[cfg(feature = \"precompute_covariance_3d\")]\n//     pub covariance_3d_opacity_packed128: Vec<Covariance3dOpacityPacked128>,\n// }\n\nimpl CommonCloud for PlanarGaussian3d {\n    type PackedType = Gaussian3d;\n\n    fn visibility(&self, index: usize) -> f32 {\n        self.position_visibility[index].visibility\n    }\n\n    fn visibility_mut(&mut self, index: usize) -> &mut f32 {\n        &mut self.position_visibility[index].visibility\n    }\n\n    fn position_iter(&self) -> PositionIter<'_> {\n        PositionIter::new(&self.position_visibility)\n    }\n\n    #[cfg(feature = \"sort_rayon\")]\n    fn position_par_iter(&self) -> crate::gaussian::iter::PositionParIter<'_> {\n        crate::gaussian::iter::PositionParIter::new(&self.position_visibility)\n    }\n}\n\nimpl FromIterator<Gaussian3d> for PlanarGaussian3d {\n    fn from_iter<I: IntoIterator<Item = Gaussian3d>>(iter: I) -> Self {\n        iter.into_iter().collect::<Vec<Gaussian3d>>().into()\n    }\n}\n\nimpl From<Vec<Gaussian3d>> for PlanarGaussian3d {\n    fn from(packed: Vec<Gaussian3d>) -> Self {\n        Self::from_interleaved(packed)\n    }\n}\n\nimpl Distribution<Gaussian3d> for StandardUniform {\n    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> Gaussian3d {\n        Gaussian3d {\n            rotation: [\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n            ]\n            .into(),\n            position_visibility: [\n                rng.random_range(-20.0..20.0),\n                rng.random_range(-20.0..20.0),\n                rng.random_range(-20.0..20.0),\n                1.0,\n            ]\n            .into(),\n            scale_opacity: [\n                rng.random_range(0.0..1.0),\n                rng.random_range(0.0..1.0),\n                rng.random_range(0.0..1.0),\n                rng.random_range(0.0..0.8),\n            ]\n            .into(),\n            spherical_harmonic: SphericalHarmonicCoefficients {\n                coefficients: {\n                    // #[cfg(feature = \"f16\")]\n                    // {\n                    //     let mut coefficients: [u32; HALF_SH_COEFF_COUNT] = [0; HALF_SH_COEFF_COUNT];\n                    //     for coefficient in coefficients.iter_mut() {\n                    //         let upper = rng.gen_range(-1.0..1.0);\n                    //         let lower = rng.gen_range(-1.0..1.0);\n\n                    //         *coefficient = pack_f32s_to_u32(upper, lower);\n                    //     }\n                    //     coefficients\n                    // }\n\n                    {\n                        let mut coefficients = [0.0; SH_COEFF_COUNT];\n                        for coefficient in coefficients.iter_mut() {\n                            *coefficient = rng.random_range(-1.0..1.0);\n                        }\n                        coefficients\n                    }\n                },\n            },\n        }\n    }\n}\n\npub fn random_gaussians_3d(n: usize) -> PlanarGaussian3d {\n    let mut rng = rng();\n    let mut gaussians: Vec<Gaussian3d> = Vec::with_capacity(n);\n\n    for _ in 0..n {\n        gaussians.push(rng.random());\n    }\n\n    PlanarGaussian3d::from_interleaved(gaussians)\n}\n\npub fn random_gaussians_3d_seeded(n: usize, seed: u64) -> PlanarGaussian3d {\n    let mut rng = StdRng::seed_from_u64(seed);\n    let mut gaussians: Vec<Gaussian3d> = Vec::with_capacity(n);\n\n    for _ in 0..n {\n        gaussians.push(StandardUniform.sample(&mut rng));\n    }\n\n    PlanarGaussian3d::from_interleaved(gaussians)\n}\n\nimpl TestCloud for PlanarGaussian3d {\n    fn test_model() -> Self {\n        let mut rng = rng();\n\n        let origin = Gaussian3d {\n            rotation: [1.0, 0.0, 0.0, 0.0].into(),\n            position_visibility: [0.0, 0.0, 0.0, 1.0].into(),\n            scale_opacity: [0.125, 0.125, 0.125, 0.125].into(),\n            spherical_harmonic: SphericalHarmonicCoefficients {\n                coefficients: {\n                    // #[cfg(feature = \"f16\")]\n                    // {\n                    //     let mut coefficients = [0_u32; HALF_SH_COEFF_COUNT];\n\n                    //     for coefficient in coefficients.iter_mut() {\n                    //         let upper = rng.gen_range(-1.0..1.0);\n                    //         let lower = rng.gen_range(-1.0..1.0);\n\n                    //         *coefficient = pack_f32s_to_u32(upper, lower);\n                    //     }\n\n                    //     coefficients\n                    // }\n\n                    {\n                        let mut coefficients = [0.0; SH_COEFF_COUNT];\n\n                        for coefficient in coefficients.iter_mut() {\n                            *coefficient = rng.random_range(-1.0..1.0);\n                        }\n\n                        coefficients\n                    }\n                },\n            },\n        };\n        let mut gaussians: Vec<Gaussian3d> = Vec::new();\n\n        for &x in [-0.5, 0.5].iter() {\n            for &y in [-0.5, 0.5].iter() {\n                for &z in [-0.5, 0.5].iter() {\n                    let mut g = origin;\n                    g.position_visibility = [x, y, z, 1.0].into();\n                    gaussians.push(g);\n\n                    gaussians\n                        .last_mut()\n                        .unwrap()\n                        .spherical_harmonic\n                        .coefficients\n                        .shuffle(&mut rng);\n                }\n            }\n        }\n\n        gaussians.push(gaussians[0]);\n        gaussians.into()\n    }\n}\n\n// TODO: attempt iter() on the Planar trait\nimpl PlanarGaussian3d {\n    pub fn iter(&self) -> impl Iterator<Item = Gaussian3d> + '_ {\n        self.position_visibility\n            .iter()\n            .zip(self.spherical_harmonic.iter())\n            .zip(self.rotation.iter())\n            .zip(self.scale_opacity.iter())\n            .map(\n                |(((position_visibility, spherical_harmonic), rotation), scale_opacity)| {\n                    Gaussian3d {\n                        position_visibility: *position_visibility,\n                        spherical_harmonic: *spherical_harmonic,\n\n                        rotation: *rotation,\n                        scale_opacity: *scale_opacity,\n                    }\n                },\n            )\n    }\n}\n"
  },
  {
    "path": "src/gaussian/formats/planar_3d_chunked.rs",
    "content": "\n"
  },
  {
    "path": "src/gaussian/formats/planar_3d_lod.rs",
    "content": "// TODO: gaussian cloud 3d with level of detail\n"
  },
  {
    "path": "src/gaussian/formats/planar_3d_quantized.rs",
    "content": "// TODO: gaussian_3d and gaussian_3d_quantized conversions\n// TODO: packed quantized gaussian 3d\n"
  },
  {
    "path": "src/gaussian/formats/planar_3d_spz.rs",
    "content": "// TODO: spz quantized format https://github.com/nianticlabs/spz\n"
  },
  {
    "path": "src/gaussian/formats/planar_4d.rs",
    "content": "use std::marker::Copy;\n\nuse bevy::prelude::*;\nuse bevy_interleave::prelude::*;\nuse bytemuck::{Pod, Zeroable};\nuse rand::{\n    Rng, SeedableRng,\n    distr::{Distribution, StandardUniform},\n    rng,\n    rngs::StdRng,\n};\nuse serde::{Deserialize, Serialize};\n\nuse crate::{\n    gaussian::{\n        f32::{IsotropicRotations, PositionVisibility, ScaleOpacity, TimestampTimescale},\n        interface::{CommonCloud, TestCloud},\n        iter::PositionIter,\n    },\n    material::spherindrical_harmonics::{SH_4D_COEFF_COUNT, SpherindricalHarmonicCoefficients},\n};\n\n#[derive(\n    Clone,\n    Debug,\n    Default,\n    Copy,\n    PartialEq,\n    Planar,\n    ReflectInterleaved,\n    StorageBindings,\n    Reflect,\n    Pod,\n    Zeroable,\n    Serialize,\n    Deserialize,\n)]\n#[serde(default)]\n#[repr(C)]\npub struct Gaussian4d {\n    #[serde(default)]\n    pub position_visibility: PositionVisibility,\n    #[serde(default)]\n    pub spherindrical_harmonic: SpherindricalHarmonicCoefficients,\n    #[serde(default)]\n    pub isotropic_rotations: IsotropicRotations,\n    #[serde(default)]\n    pub scale_opacity: ScaleOpacity,\n    #[serde(default)]\n    pub timestamp_timescale: TimestampTimescale,\n}\n\n// // TODO: GaussianSpacetime, determine temporal position/rotation structure\n// pub struct GaussianSpacetime {\n//     pub position_visibility: PositionVisibility,\n//     pub color_mlp: ColorMlp,\n//     pub isotropic_rotations: IsotropicRotations,\n//     pub scale_opacity: ScaleOpacity,\n//     pub timestamp_timescale: TimestampTimescale,\n// }\n\n// TODO: quantize 4d representation\n// #[derive(\n//     Debug,\n//     Default,\n//     PartialEq,\n//     Reflect,\n//     Serialize,\n//     Deserialize,\n// )]\n// pub struct HalfCloud4d {\n//     pub isotropic_rotations: Vec<IsotropicRotations>,\n//     pub position_visibility: Vec<PositionVisibility>,\n//     pub scale_opacity: Vec<ScaleOpacity>,\n//     pub spherindrical_harmonic: Vec<SpherindricalHarmonicCoefficients>,\n//     pub timestamp_timescale: Vec<TimestampTimescale>,\n// }\n\n// impl CommonCloud for HalfCloud4d {\n//     fn len(&self) -> usize {\n//         self.position_visibility.len()\n//     }\n\n//     fn position_iter(&self) -> impl Iterator<Item = &Position> {\n//         self.position_visibility.iter()\n//             .map(|position_visibility| &position_visibility.position)\n//     }\n\n//     #[cfg(feature = \"sort_rayon\")]\n//     fn position_par_iter(&self) -> impl IndexedParallelIterator<Item = &Position> + '_ {\n//         self.position_visibility.par_iter()\n//             .map(|position_visibility| &position_visibility.position)\n//     }\n\n//     fn subset(&self, indicies: &[usize]) -> Self {\n//         let mut isotropic_rotations = Vec::with_capacity(indicies.len());\n//         let mut position_visibility = Vec::with_capacity(indicies.len());\n//         let mut scale_opacity = Vec::with_capacity(indicies.len());\n//         let mut spherindrical_harmonic = Vec::with_capacity(indicies.len());\n//         let mut timestamp_timescale = Vec::with_capacity(indicies.len());\n\n//         for &index in indicies.iter() {\n//             position_visibility.push(self.position_visibility[index]);\n//             spherindrical_harmonic.push(self.spherindrical_harmonic[index]);\n//             rotation.push(self.rotation[index]);\n//             scale_opacity.push(self.scale_opacity[index]);\n//             timestamp_timescale.push(self.timestamp_timescale[index]);\n//         }\n\n//         Self {\n//             isotropic_rotations,\n//             position_visibility,\n//             spherindrical_harmonic,\n//             scale_opacity,\n//             timestamp_timescale,\n//         }\n//     }\n// }\n\n// impl TestCloud for HalfCloud4d {\n//     fn test_model() -> Self {\n//         let mut rng = rand::rng();\n\n//         let origin = Gaussian {\n//             isotropic_rotations: [\n//                 1.0,\n//                 0.0,\n//                 0.0,\n//                 0.0,\n//                 1.0,\n//                 0.0,\n//                 0.0,\n//                 0.0,\n//             ].into(),\n//             position_visibility: [\n//                 0.0,\n//                 0.0,\n//                 0.0,\n//                 1.0,\n//             ].into(),\n//             scale_opacity: [\n//                 0.5,\n//                 0.5,\n//                 0.5,\n//                 0.5,\n//             ].into(),\n//             spherindrical_harmonic: SpherindricalHarmonicCoefficients {\n//                 coefficients: {\n//                     let mut coefficients = [0.0; SH_4D_COEFF_COUNT];\n\n//                     for coefficient in coefficients.iter_mut() {\n//                         *coefficient = rng.gen_range(-1.0..1.0);\n//                     }\n\n//                     coefficients\n//                 },\n//             },\n//         };\n//         let mut gaussians: Vec<Gaussian4d> = Vec::new();\n\n//         for &x in [-0.5, 0.5].iter() {\n//             for &y in [-0.5, 0.5].iter() {\n//                 for &z in [-0.5, 0.5].iter() {\n//                     let mut g = origin;\n//                     g.position_visibility = [x, y, z, 0.5].into();\n//                     gaussians.push(g);\n\n//                     gaussians.last_mut().unwrap().spherindrical_harmonic.coefficients.shuffle(&mut rng);\n//                 }\n//             }\n//         }\n\n//         gaussians.push(gaussians[0]);\n\n//         Cloud4d::from_packed(gaussians)\n//     }\n// }\n\n// impl HalfCloud4d {\n//     fn from_packed(gaussians: Vec<Gaussian4d>) -> Self {\n//         let mut isotropic_rotations = Vec::with_capacity(gaussians.len());\n//         let mut position_visibility = Vec::with_capacity(gaussians.len());\n//         let mut scale_opacity = Vec::with_capacity(gaussians.len());\n//         let mut spherindrical_harmonic = Vec::with_capacity(gaussians.len());\n//         let mut timestamp_timescale = Vec::with_capacity(gaussians.len());\n\n//         for gaussian in gaussians {\n//             isotropic_rotations.push(gaussian.isotropic_rotations);\n//             position_visibility.push(gaussian.position_visibility);\n//             scale_opacity.push(gaussian.scale_opacity);\n//             spherindrical_harmonic.push(gaussian.spherindrical_harmonic);\n//             timestamp_timescale.push(gaussian.timestamp_timescale);\n//         }\n\n//         Self {\n//             isotropic_rotations,\n//             position_visibility,\n//             scale_opacity,\n//             spherindrical_harmonic,\n//             timestamp_timescale,\n//         }\n//     }\n// }\n\n// impl FromIterator<Gaussian4d> for HalfCloud4d {\n//     fn from_iter<I: IntoIterator<Item=Gaussian4d>>(iter: I) -> Self {\n//         let gaussians = iter.into_iter().collect::<Vec<Gaussian4d>>();\n//         HalfCloud4d::from_packed(gaussians)\n//     }\n// }\n\nimpl CommonCloud for PlanarGaussian4d {\n    type PackedType = Gaussian4d;\n\n    fn visibility(&self, index: usize) -> f32 {\n        self.position_visibility[index].visibility\n    }\n\n    fn visibility_mut(&mut self, index: usize) -> &mut f32 {\n        &mut self.position_visibility[index].visibility\n    }\n\n    fn position_iter(&self) -> PositionIter<'_> {\n        PositionIter::new(&self.position_visibility)\n    }\n\n    #[cfg(feature = \"sort_rayon\")]\n    fn position_par_iter(&self) -> crate::gaussian::iter::PositionParIter<'_> {\n        crate::gaussian::iter::PositionParIter::new(&self.position_visibility)\n    }\n}\n\nimpl FromIterator<Gaussian4d> for PlanarGaussian4d {\n    fn from_iter<I: IntoIterator<Item = Gaussian4d>>(iter: I) -> Self {\n        iter.into_iter().collect::<Vec<Gaussian4d>>().into()\n    }\n}\n\nimpl From<Vec<Gaussian4d>> for PlanarGaussian4d {\n    fn from(packed: Vec<Gaussian4d>) -> Self {\n        Self::from_interleaved(packed)\n    }\n}\n\nimpl Distribution<Gaussian4d> for StandardUniform {\n    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> Gaussian4d {\n        let mut coefficients = [0.0; SH_4D_COEFF_COUNT];\n        for coefficient in coefficients.iter_mut() {\n            *coefficient = rng.random_range(-1.0..1.0);\n        }\n\n        Gaussian4d {\n            isotropic_rotations: [\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n            ]\n            .into(),\n            position_visibility: [\n                rng.random_range(-20.0..20.0),\n                rng.random_range(-20.0..20.0),\n                rng.random_range(-20.0..20.0),\n                1.0,\n            ]\n            .into(),\n            scale_opacity: [\n                rng.random_range(0.0..1.0),\n                rng.random_range(0.0..1.0),\n                rng.random_range(0.0..1.0),\n                rng.random_range(0.0..0.8),\n            ]\n            .into(),\n            spherindrical_harmonic: coefficients.into(),\n            timestamp_timescale: [\n                rng.random_range(0.0..1.0),\n                rng.random_range(-1.0..1.0),\n                0.0,\n                0.0,\n            ]\n            .into(),\n        }\n    }\n}\n\npub fn random_gaussians_4d(n: usize) -> PlanarGaussian4d {\n    let mut rng = rng();\n    let mut gaussians: Vec<Gaussian4d> = Vec::with_capacity(n);\n\n    for _ in 0..n {\n        gaussians.push(rng.random());\n    }\n\n    PlanarGaussian4d::from_interleaved(gaussians)\n}\n\npub fn random_gaussians_4d_seeded(n: usize, seed: u64) -> PlanarGaussian4d {\n    let mut rng = StdRng::seed_from_u64(seed);\n    let mut gaussians: Vec<Gaussian4d> = Vec::with_capacity(n);\n\n    for _ in 0..n {\n        gaussians.push(StandardUniform.sample(&mut rng));\n    }\n\n    PlanarGaussian4d::from_interleaved(gaussians)\n}\n\nimpl TestCloud for PlanarGaussian4d {\n    fn test_model() -> Self {\n        random_gaussians_4d(512)\n    }\n}\n"
  },
  {
    "path": "src/gaussian/formats/planar_4d_hierarchy.rs",
    "content": "// TODO: gaussian cloud 4d with temporal hierarchy\nuse crate::gaussian::formats::planar_4d::PlanarGaussian4dHandle;\n\npub struct TemporalGaussianLevel {\n    pub instance_count: usize,\n    // TODO: swap buffer slicing\n}\n\n// TODO: make this an asset\npub struct TemporalGaussianHierarchy {\n    pub flat_cloud: PlanarGaussian4dHandle,\n    pub levels: Vec<TemporalGaussianLevel>,\n    // TODO: level descriptor validation\n}\n\n// TODO: implement level streaming utilities in src/stream/hierarchy.rs\n// TODO: implement GPU slice utilities in src/stream/slice.rs\n"
  },
  {
    "path": "src/gaussian/formats/planar_4d_quantized.rs",
    "content": "\n"
  },
  {
    "path": "src/gaussian/formats/spacetime.rs",
    "content": "// https://github.com/oppo-us-research/SpacetimeGaussians\n\n// property float x\n// property float y\n// property float z\n// property float trbf_center\n// property float trbf_scale\n// property float nx\n// property float ny\n// property float nz\n// property float motion_0\n\n// property float motion_2\n// property float motion_3\n// property float motion_4\n// property float motion_5\n// property float motion_6\n// property float motion_7\n// property float motion_8\n// property float f_dc_0\n// property float f_dc_1\n// property float f_dc_2\n// property float opacity\n// property float scale_0\n// property float scale_1\n// property float scale_2\n// property float rot_0\n// property float rot_1\n// property float rot_2\n// property float rot_3\n// property float omega_0\n// property float omega_1\n// property float omega_2\n// property float omega_3\n"
  },
  {
    "path": "src/gaussian/interface.rs",
    "content": "use bevy::{math::bounding::Aabb3d, prelude::*};\nuse bevy_interleave::prelude::Planar;\n\n#[cfg(feature = \"sort_rayon\")]\nuse rayon::prelude::*;\n\nuse crate::gaussian::iter::PositionIter;\n\npub trait CommonCloud\nwhere\n    Self: Planar,\n{\n    type PackedType;\n\n    fn len_sqrt_ceil(&self) -> usize {\n        (self.len() as f32).sqrt().ceil() as usize\n    }\n    fn square_len(&self) -> usize {\n        self.len_sqrt_ceil().pow(2)\n    }\n\n    fn compute_aabb(&self) -> Option<Aabb3d> {\n        if self.is_empty() {\n            return None;\n        }\n\n        let mut min = Vec3::splat(f32::INFINITY);\n        let mut max = Vec3::splat(f32::NEG_INFINITY);\n\n        // TODO: find a more correct aabb bound derived from scalar max gaussian scale\n        let max_scale = 0.1;\n\n        #[cfg(feature = \"sort_rayon\")]\n        {\n            (min, max) = self\n                .position_par_iter()\n                .fold(\n                    || (min, max),\n                    |(curr_min, curr_max), position| {\n                        let pos = Vec3::from(*position);\n                        let offset = Vec3::splat(max_scale);\n                        (curr_min.min(pos - offset), curr_max.max(pos + offset))\n                    },\n                )\n                .reduce(\n                    || (min, max),\n                    |(a_min, a_max), (b_min, b_max)| (a_min.min(b_min), a_max.max(b_max)),\n                );\n        }\n\n        #[cfg(not(feature = \"sort_rayon\"))]\n        {\n            for position in self.position_iter() {\n                min = min.min(Vec3::from(*position) - Vec3::splat(max_scale));\n                max = max.max(Vec3::from(*position) + Vec3::splat(max_scale));\n            }\n        }\n\n        Some(Aabb3d {\n            min: min.into(),\n            max: max.into(),\n        })\n    }\n\n    fn visibility(&self, index: usize) -> f32;\n    fn visibility_mut(&mut self, index: usize) -> &mut f32;\n\n    // TODO: type erasure for position iterators\n    fn position_iter(&self) -> PositionIter<'_>;\n\n    #[cfg(feature = \"sort_rayon\")]\n    fn position_par_iter(&self) -> crate::gaussian::iter::PositionParIter<'_>;\n}\n\npub trait TestCloud {\n    fn test_model() -> Self;\n}\n\n// TODO: CloudSlice and CloudStream traits\n"
  },
  {
    "path": "src/gaussian/iter.rs",
    "content": "#[cfg(feature = \"sort_rayon\")]\nuse rayon::iter::plumbing::{Consumer, UnindexedConsumer};\n#[cfg(feature = \"sort_rayon\")]\nuse rayon::prelude::*;\n\nuse crate::gaussian::f32::{Position, PositionVisibility};\n\npub struct PositionIter<'a> {\n    slice_iter: std::slice::Iter<'a, PositionVisibility>,\n}\n\nimpl<'a> PositionIter<'a> {\n    pub fn new(slice: &'a [PositionVisibility]) -> Self {\n        Self {\n            slice_iter: slice.iter(),\n        }\n    }\n}\n\nimpl<'a> Iterator for PositionIter<'a> {\n    type Item = &'a Position;\n\n    fn next(&mut self) -> Option<Self::Item> {\n        self.slice_iter.next().map(|pv| &pv.position)\n    }\n}\n\n#[cfg(feature = \"sort_rayon\")]\npub struct PositionParIter<'a> {\n    slice_par_iter: rayon::slice::Iter<'a, PositionVisibility>,\n}\n\n#[cfg(feature = \"sort_rayon\")]\nimpl<'a> PositionParIter<'a> {\n    pub fn new(slice: &'a [PositionVisibility]) -> Self {\n        Self {\n            slice_par_iter: slice.par_iter(),\n        }\n    }\n}\n\n#[cfg(feature = \"sort_rayon\")]\nimpl<'a> ParallelIterator for PositionParIter<'a> {\n    type Item = &'a Position;\n\n    fn drive_unindexed<C>(self, consumer: C) -> C::Result\n    where\n        C: UnindexedConsumer<Self::Item>,\n    {\n        self.slice_par_iter\n            .map(|pv| &pv.position)\n            .drive_unindexed(consumer)\n    }\n}\n\n#[cfg(feature = \"sort_rayon\")]\nimpl IndexedParallelIterator for PositionParIter<'_> {\n    fn len(&self) -> usize {\n        self.slice_par_iter.len()\n    }\n\n    fn drive<C>(self, consumer: C) -> <C as Consumer<Self::Item>>::Result\n    where\n        C: Consumer<Self::Item>,\n    {\n        self.slice_par_iter.map(|pv| &pv.position).drive(consumer)\n    }\n\n    fn with_producer<CB>(self, callback: CB) -> CB::Output\n    where\n        CB: rayon::iter::plumbing::ProducerCallback<Self::Item>,\n    {\n        self.slice_par_iter\n            .map(|pv| &pv.position)\n            .with_producer(callback)\n    }\n}\n"
  },
  {
    "path": "src/gaussian/mod.rs",
    "content": "use static_assertions::assert_cfg;\n\npub mod cloud;\npub mod covariance;\npub mod f16;\npub mod f32;\npub mod formats;\npub mod interface;\npub mod iter;\npub mod settings;\n\nassert_cfg!(\n    any(feature = \"packed\", feature = \"planar\",),\n    \"specify one of the following features: packed, planar\",\n);\n"
  },
  {
    "path": "src/gaussian/settings.rs",
    "content": "use bevy::prelude::*;\nuse bevy_args::{Deserialize, Serialize, ValueEnum};\n\nuse crate::sort::SortMode;\n\n#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Reflect, Serialize, Deserialize)]\npub enum DrawMode {\n    #[default]\n    All,\n    Selected,\n    HighlightSelected,\n}\n\n#[derive(\n    Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Reflect, Serialize, Deserialize, ValueEnum,\n)]\npub enum GaussianMode {\n    Gaussian2d,\n    #[default]\n    Gaussian3d,\n    Gaussian4d,\n}\n\n#[derive(\n    Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Reflect, Serialize, Deserialize, ValueEnum,\n)]\npub enum PlaybackMode {\n    Loop,\n    Once,\n    Sin,\n    #[default]\n    Still,\n}\n\n#[derive(\n    Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Reflect, Serialize, Deserialize, ValueEnum,\n)]\npub enum RasterizeMode {\n    Classification,\n    #[default]\n    Color,\n    Depth,\n    Normal,\n    OpticalFlow,\n    Position,\n    Velocity,\n}\n\n#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Reflect, Serialize, Deserialize)]\npub enum GaussianColorSpace {\n    #[default]\n    SrgbRec709Display,\n    LinRec709Display,\n}\n\n// TODO: breakdown into components\n#[derive(Component, Clone, Debug, Reflect, Serialize, Deserialize)]\n#[reflect(Component)]\n#[serde(default)]\npub struct CloudSettings {\n    pub aabb: bool,\n    pub global_opacity: f32,\n    pub global_scale: f32,\n    pub opacity_adaptive_radius: bool,\n    pub visualize_bounding_box: bool,\n    pub sort_mode: SortMode,\n    pub draw_mode: DrawMode,\n    pub gaussian_mode: GaussianMode,\n    pub playback_mode: PlaybackMode,\n    pub rasterize_mode: RasterizeMode,\n    pub color_space: GaussianColorSpace,\n    pub num_classes: usize,\n    pub time: f32,\n    pub time_scale: f32,\n    pub time_start: f32,\n    pub time_stop: f32,\n}\n\nimpl Default for CloudSettings {\n    fn default() -> Self {\n        Self {\n            aabb: false,\n            global_opacity: 1.0,\n            global_scale: 1.0,\n            opacity_adaptive_radius: true,\n            visualize_bounding_box: false,\n            sort_mode: SortMode::default(),\n            draw_mode: DrawMode::default(),\n            gaussian_mode: GaussianMode::default(),\n            rasterize_mode: RasterizeMode::default(),\n            color_space: GaussianColorSpace::default(),\n            num_classes: 1,\n            playback_mode: PlaybackMode::default(),\n            time: 0.0,\n            time_scale: 1.0,\n            time_start: 0.0,\n            time_stop: 1.0,\n        }\n    }\n}\n\n#[derive(Default)]\npub struct SettingsPlugin;\nimpl Plugin for SettingsPlugin {\n    fn build(&self, app: &mut App) {\n        app.register_type::<CloudSettings>();\n\n        app.add_systems(Update, (playback_update,));\n    }\n}\n\nfn playback_update(time: Res<Time>, mut query: Query<(&mut CloudSettings,)>) {\n    for (mut settings,) in query.iter_mut() {\n        if settings.time_scale == 0.0 {\n            continue;\n        }\n\n        // bail condition\n        match settings.playback_mode {\n            PlaybackMode::Loop => {}\n            PlaybackMode::Once => {\n                if settings.time >= settings.time_stop {\n                    continue;\n                }\n            }\n            PlaybackMode::Sin => {}\n            PlaybackMode::Still => {\n                continue;\n            }\n        }\n\n        // forward condition\n        match settings.playback_mode {\n            PlaybackMode::Loop | PlaybackMode::Once => {\n                settings.time += time.delta_secs() * settings.time_scale;\n            }\n            PlaybackMode::Sin => {\n                let theta = settings.time_scale * time.elapsed_secs();\n                let y = (theta * 2.0 * std::f32::consts::PI).sin();\n                settings.time = settings.time_start\n                    + (settings.time_stop - settings.time_start) * (y + 1.0) / 2.0;\n            }\n            PlaybackMode::Still => {}\n        }\n\n        // reset condition\n        match settings.playback_mode {\n            PlaybackMode::Loop => {\n                if settings.time > settings.time_stop {\n                    settings.time = settings.time_start;\n                }\n            }\n            PlaybackMode::Once => {}\n            PlaybackMode::Sin => {}\n            PlaybackMode::Still => {}\n        }\n    }\n}\n"
  },
  {
    "path": "src/io/codec.rs",
    "content": "use std::io::Write;\n\n// TODO: support streamed codecs\npub trait CloudCodec {\n    fn encode(&self) -> Vec<u8>;\n    fn decode(data: &[u8]) -> Self;\n\n    fn write_to_file(&self, path: &str) {\n        let gcloud_file = std::fs::File::create(path).expect(\"failed to create file\");\n        let mut gcloud_writer = std::io::BufWriter::new(gcloud_file);\n\n        let data = self.encode();\n        gcloud_writer\n            .write_all(data.as_slice())\n            .expect(\"failed to write to gcloud file\");\n    }\n}\n"
  },
  {
    "path": "src/io/gcloud/bincode2.rs",
    "content": "use bincode2::{deserialize_from, serialize_into};\nuse flate2::{Compression, read::GzDecoder, write::GzEncoder};\nuse serde::de::DeserializeOwned;\nuse std::io::Cursor;\n\nuse crate::{\n    gaussian::formats::{planar_3d::PlanarGaussian3d, planar_4d::PlanarGaussian4d},\n    io::codec::CloudCodec,\n};\n\nimpl CloudCodec for PlanarGaussian3d {\n    fn encode(&self) -> Vec<u8> {\n        let mut output = Vec::new();\n\n        {\n            let mut gz_encoder = GzEncoder::new(&mut output, Compression::default());\n            serialize_into(&mut gz_encoder, &self).expect(\"failed to encode cloud\");\n        }\n\n        output\n    }\n\n    fn decode(data: &[u8]) -> Self {\n        if let Ok(cloud) = decode_gzip(data) {\n            return cloud;\n        }\n\n        decode_raw(data)\n    }\n}\n\nimpl CloudCodec for PlanarGaussian4d {\n    fn encode(&self) -> Vec<u8> {\n        let mut output = Vec::new();\n\n        {\n            let mut gz_encoder = GzEncoder::new(&mut output, Compression::default());\n            serialize_into(&mut gz_encoder, &self).expect(\"failed to encode cloud\");\n        }\n\n        output\n    }\n\n    fn decode(data: &[u8]) -> Self {\n        if let Ok(cloud) = decode_gzip(data) {\n            return cloud;\n        }\n\n        decode_raw(data)\n    }\n}\n\nfn decode_gzip<T>(data: &[u8]) -> Result<T, bincode2::Error>\nwhere\n    T: DeserializeOwned,\n{\n    let decompressed = GzDecoder::new(data);\n    deserialize_from(decompressed)\n}\n\nfn decode_raw<T>(data: &[u8]) -> T\nwhere\n    T: DeserializeOwned,\n{\n    deserialize_from(Cursor::new(data)).expect(\"failed to decode cloud\")\n}\n"
  },
  {
    "path": "src/io/gcloud/flexbuffers.rs",
    "content": "use flexbuffers::{FlexbufferSerializer, Reader};\nuse serde::{Deserialize, Serialize};\n\nuse crate::{\n    gaussian::formats::{planar_3d::PlanarGaussian3d, planar_4d::PlanarGaussian4d},\n    io::codec::CloudCodec,\n};\n\nimpl CloudCodec for PlanarGaussian3d {\n    fn encode(&self) -> Vec<u8> {\n        let mut serializer = FlexbufferSerializer::new();\n        self.serialize(&mut serializer)\n            .expect(\"failed to serialize cloud\");\n\n        serializer.view().to_vec()\n    }\n\n    fn decode(data: &[u8]) -> Self {\n        let reader = Reader::get_root(data).expect(\"failed to read flexbuffer\");\n        Self::deserialize(reader).expect(\"deserialization failed\")\n    }\n}\n\nimpl CloudCodec for PlanarGaussian4d {\n    fn encode(&self) -> Vec<u8> {\n        let mut serializer = FlexbufferSerializer::new();\n        self.serialize(&mut serializer)\n            .expect(\"failed to serialize cloud\");\n\n        serializer.view().to_vec()\n    }\n\n    fn decode(data: &[u8]) -> Self {\n        let reader = Reader::get_root(data).expect(\"failed to read flexbuffer\");\n        Self::deserialize(reader).expect(\"deserialization failed\")\n    }\n}\n"
  },
  {
    "path": "src/io/gcloud/mod.rs",
    "content": "use static_assertions::assert_cfg;\n\n// If both codecs are enabled, prefer flexbuffers.\n#[cfg(all(feature = \"io_bincode2\", not(feature = \"io_flexbuffers\")))]\npub mod bincode2;\n\n#[cfg(feature = \"io_flexbuffers\")]\npub mod flexbuffers;\n\nassert_cfg!(\n    any(feature = \"io_bincode2\", feature = \"io_flexbuffers\",),\n    \"no gcloud io enabled\",\n);\n"
  },
  {
    "path": "src/io/gcloud/texture.rs",
    "content": "// TODO: convert gcloud to compressed texture format\n"
  },
  {
    "path": "src/io/loader.rs",
    "content": "#[allow(unused_imports)]\nuse std::io::{BufReader, Cursor, ErrorKind};\n\nuse bevy::{\n    asset::{AssetLoader, LoadContext, io::Reader},\n    reflect::TypePath,\n};\n\nuse crate::{\n    gaussian::formats::planar_3d::PlanarGaussian3d, gaussian::formats::planar_4d::PlanarGaussian4d,\n    io::codec::CloudCodec,\n};\n\n#[derive(Default, TypePath)]\npub struct Gaussian3dLoader;\n\nimpl AssetLoader for Gaussian3dLoader {\n    type Asset = PlanarGaussian3d;\n    type Settings = ();\n    type Error = std::io::Error;\n\n    async fn load(\n        &self,\n        reader: &mut dyn Reader,\n        _: &Self::Settings,\n        load_context: &mut LoadContext<'_>,\n    ) -> Result<Self::Asset, Self::Error> {\n        let mut bytes = Vec::new();\n        reader.read_to_end(&mut bytes).await?;\n\n        let extension = load_context\n            .path()\n            .path()\n            .extension()\n            .and_then(|ext| ext.to_str());\n\n        match extension {\n            Some(\"ply\") => {\n                #[cfg(feature = \"io_ply\")]\n                {\n                    let cursor = Cursor::new(bytes);\n                    let mut f = BufReader::new(cursor);\n\n                    Ok(crate::io::ply::parse_ply_3d(&mut f)?)\n                }\n\n                #[cfg(not(feature = \"io_ply\"))]\n                {\n                    Err(std::io::Error::other(\n                        \"ply support not enabled, enable with io_ply feature\",\n                    ))\n                }\n            }\n            Some(\"gcloud\") => {\n                let cloud = PlanarGaussian3d::decode(bytes.as_slice());\n\n                Ok(cloud)\n            }\n            _ => Err(std::io::Error::other(\"only .ply and .gcloud supported\")),\n        }\n    }\n\n    fn extensions(&self) -> &[&str] {\n        &[\"ply\", \"gcloud\"]\n    }\n}\n\n#[derive(Default, TypePath)]\npub struct Gaussian4dLoader;\n\nimpl AssetLoader for Gaussian4dLoader {\n    type Asset = PlanarGaussian4d;\n    type Settings = ();\n    type Error = std::io::Error;\n\n    async fn load(\n        &self,\n        reader: &mut dyn Reader,\n        _: &Self::Settings,\n        load_context: &mut LoadContext<'_>,\n    ) -> Result<Self::Asset, Self::Error> {\n        let mut bytes = Vec::new();\n        reader.read_to_end(&mut bytes).await?;\n\n        let extension = load_context\n            .path()\n            .path()\n            .extension()\n            .and_then(|ext| ext.to_str());\n\n        match extension {\n            Some(\"ply4d\") => {\n                #[cfg(feature = \"io_ply\")]\n                {\n                    let cursor = Cursor::new(bytes);\n                    let mut f = BufReader::new(cursor);\n\n                    Ok(crate::io::ply::parse_ply_4d(&mut f)?)\n                }\n\n                #[cfg(not(feature = \"io_ply\"))]\n                {\n                    Err(std::io::Error::other(\n                        \"ply4d support not enabled, enable with io_ply feature\",\n                    ))\n                }\n            }\n            Some(\"gc4d\") => Ok(PlanarGaussian4d::decode(bytes.as_slice())),\n            _ => Err(std::io::Error::other(\"only .ply4d and .gc4d supported\")),\n        }\n    }\n\n    fn extensions(&self) -> &[&str] {\n        &[\"ply4d\", \"gc4d\"]\n    }\n}\n"
  },
  {
    "path": "src/io/mod.rs",
    "content": "use bevy::prelude::*;\n\npub mod codec;\npub mod gcloud;\npub mod loader;\npub mod scene;\n\n#[cfg(feature = \"io_ply\")]\npub mod ply;\n\n#[derive(Default)]\npub struct IoPlugin;\nimpl Plugin for IoPlugin {\n    fn build(&self, app: &mut App) {\n        app.init_asset_loader::<loader::Gaussian3dLoader>();\n        app.init_asset_loader::<loader::Gaussian4dLoader>();\n\n        app.add_plugins(scene::GaussianScenePlugin);\n    }\n}\n"
  },
  {
    "path": "src/io/ply.rs",
    "content": "use core::panic;\nuse std::io::BufRead;\n\nuse bevy_interleave::prelude::Planar;\nuse ply_rs::{\n    parser::Parser,\n    ply::{Property, PropertyAccess},\n};\n\nuse crate::{\n    gaussian::formats::{\n        planar_3d::{Gaussian3d, PlanarGaussian3d},\n        planar_4d::{Gaussian4d, PlanarGaussian4d},\n    },\n    material::{\n        spherical_harmonics::{SH_CHANNELS, SH_COEFF_COUNT, SH_COEFF_COUNT_PER_CHANNEL},\n        spherindrical_harmonics::SH_4D_COEFF_COUNT,\n    },\n};\n\npub const MAX_SIZE_VARIANCE: f32 = 4.0;\n\nimpl PropertyAccess for Gaussian3d {\n    fn new() -> Self {\n        Gaussian3d::default()\n    }\n\n    fn set_property(&mut self, key: String, property: Property) {\n        match (key.as_ref(), property) {\n            (\"x\", Property::Float(v)) => self.position_visibility.position[0] = v,\n            (\"y\", Property::Float(v)) => self.position_visibility.position[1] = v,\n            (\"z\", Property::Float(v)) => self.position_visibility.position[2] = v,\n            (\"visibility\", Property::Float(v)) => self.position_visibility.visibility = v,\n            (\"f_dc_0\", Property::Float(v)) => self.spherical_harmonic.set(0, v),\n            (\"f_dc_1\", Property::Float(v)) => self.spherical_harmonic.set(1, v),\n            (\"f_dc_2\", Property::Float(v)) => self.spherical_harmonic.set(2, v),\n            (\"scale_0\", Property::Float(v)) => self.scale_opacity.scale[0] = v,\n            (\"scale_1\", Property::Float(v)) => self.scale_opacity.scale[1] = v,\n            (\"scale_2\", Property::Float(v)) => self.scale_opacity.scale[2] = v,\n            (\"opacity\", Property::Float(v)) => {\n                self.scale_opacity.opacity = 1.0 / (1.0 + (-v).exp())\n            }\n            (\"rot_0\", Property::Float(v)) => self.rotation.rotation[0] = v,\n            (\"rot_1\", Property::Float(v)) => self.rotation.rotation[1] = v,\n            (\"rot_2\", Property::Float(v)) => self.rotation.rotation[2] = v,\n            (\"rot_3\", Property::Float(v)) => self.rotation.rotation[3] = v,\n            (_, Property::Float(v)) if key.starts_with(\"f_rest_\") => {\n                let i = key[7..].parse::<usize>().unwrap();\n\n                // interleaved\n                // if (i + 3) < SH_COEFF_COUNT {\n                //     self.spherical_harmonic.coefficients[i + 3] = v;\n                // }\n\n                // planar\n                let channel = i / SH_COEFF_COUNT_PER_CHANNEL;\n                let coefficient = if SH_COEFF_COUNT_PER_CHANNEL == 1 {\n                    1\n                } else {\n                    (i % (SH_COEFF_COUNT_PER_CHANNEL - 1)) + 1\n                };\n\n                let interleaved_idx = coefficient * SH_CHANNELS + channel;\n\n                if interleaved_idx < SH_COEFF_COUNT {\n                    self.spherical_harmonic.set(interleaved_idx, v);\n                } else {\n                    // TODO: convert higher degree SH to lower degree SH\n                }\n            }\n            (_, _) => {}\n        }\n    }\n}\n\npub fn parse_ply_3d(mut reader: &mut dyn BufRead) -> Result<PlanarGaussian3d, std::io::Error> {\n    let gaussian_parser = Parser::<Gaussian3d>::new();\n    let header = gaussian_parser.read_header(&mut reader)?;\n\n    let mut cloud = Vec::new();\n\n    let required_properties = vec![\n        \"x\", \"y\", \"z\", \"f_dc_0\", \"f_dc_1\", \"f_dc_2\", \"scale_0\", \"scale_1\", \"opacity\", \"rot_0\",\n        \"rot_1\", \"rot_2\", \"rot_3\",\n    ];\n    let mut required_property_count = required_properties.len();\n\n    for (_key, element) in &header.elements {\n        if element.name == \"vertex\" {\n            for (key, _prop) in &element.properties {\n                required_property_count -= required_properties.contains(&key.as_str()) as usize;\n            }\n\n            if required_property_count > 0 {\n                return Err(std::io::Error::new(\n                    std::io::ErrorKind::InvalidData,\n                    \"missing required properties\",\n                ));\n            }\n\n            cloud = gaussian_parser.read_payload_for_element(&mut reader, element, &header)?;\n        }\n    }\n\n    for gaussian in &mut cloud {\n        // TODO: add automatic scaling normalization detection (e.g. don't normalize twice)\n        let mean_scale = (gaussian.scale_opacity.scale[0]\n            + gaussian.scale_opacity.scale[1]\n            + gaussian.scale_opacity.scale[2])\n            / 3.0;\n        for i in 0..3 {\n            gaussian.scale_opacity.scale[i] = gaussian.scale_opacity.scale[i]\n                .max(mean_scale - MAX_SIZE_VARIANCE)\n                .min(mean_scale + MAX_SIZE_VARIANCE)\n                .exp();\n        }\n\n        let norm = (0..4)\n            .map(|i| gaussian.rotation.rotation[i].powf(2.0))\n            .sum::<f32>()\n            .sqrt();\n        for i in 0..4 {\n            gaussian.rotation.rotation[i] /= norm;\n        }\n    }\n\n    // pad with empty gaussians to multiple of 32\n    let pad = 32 - (cloud.len() % 32);\n    cloud.extend(std::iter::repeat_n(Gaussian3d::default(), pad));\n\n    Ok(PlanarGaussian3d::from_interleaved(cloud))\n}\n\nimpl PropertyAccess for Gaussian4d {\n    fn new() -> Self {\n        Gaussian4d::default()\n    }\n\n    fn set_property(&mut self, key: String, property: Property) {\n        match (key.as_ref(), property) {\n            (\"x\", Property::Float(v)) => self.position_visibility.position[0] = v,\n            (\"y\", Property::Float(v)) => self.position_visibility.position[1] = v,\n            (\"z\", Property::Float(v)) => self.position_visibility.position[2] = v,\n            (\"visibility\", Property::Float(v)) => self.position_visibility.visibility = v,\n\n            (\"t\", Property::Float(v)) => self.timestamp_timescale.timestamp = v,\n            (\"st\", Property::Float(v)) => self.timestamp_timescale.timescale = v,\n\n            (_, Property::Float(v)) if key.starts_with(\"feat_\") => {\n                let channel = match key.chars().nth(5).unwrap() {\n                    'r' => 0,\n                    'g' => 1,\n                    'b' => 2,\n                    _ => panic!(\"invalid feature channel, expected r, g, or b\"),\n                };\n                let i = key[7..].parse::<usize>().unwrap();\n                let interleaved_idx = i * SH_CHANNELS + channel;\n\n                if interleaved_idx < SH_4D_COEFF_COUNT {\n                    self.spherindrical_harmonic.set(interleaved_idx, v);\n                } else {\n                    // TODO: handle higher-degree if needed\n                }\n            }\n\n            (\"sx\", Property::Float(v)) => self.scale_opacity.scale[0] = v,\n            (\"sy\", Property::Float(v)) => self.scale_opacity.scale[1] = v,\n            (\"sz\", Property::Float(v)) => self.scale_opacity.scale[2] = v,\n            (\"opacity\", Property::Float(v)) => self.scale_opacity.opacity = v,\n\n            (\"rot_x\", Property::Float(v)) => self.isotropic_rotations.rotation[0] = v,\n            (\"rot_y\", Property::Float(v)) => self.isotropic_rotations.rotation[1] = v,\n            (\"rot_z\", Property::Float(v)) => self.isotropic_rotations.rotation[2] = v,\n            (\"rot_w\", Property::Float(v)) => self.isotropic_rotations.rotation[3] = v,\n\n            (\"rot_r_x\", Property::Float(v)) => self.isotropic_rotations.rotation_r[0] = v,\n            (\"rot_r_y\", Property::Float(v)) => self.isotropic_rotations.rotation_r[1] = v,\n            (\"rot_r_z\", Property::Float(v)) => self.isotropic_rotations.rotation_r[2] = v,\n            (\"rot_r_w\", Property::Float(v)) => self.isotropic_rotations.rotation_r[3] = v,\n            _ => {}\n        }\n    }\n}\n\npub fn parse_ply_4d(mut reader: &mut dyn BufRead) -> Result<PlanarGaussian4d, std::io::Error> {\n    let parser = Parser::<Gaussian4d>::new();\n    let header = parser.read_header(&mut reader)?;\n\n    let mut cloud = Vec::new();\n\n    let required_properties = vec![\n        \"x\", \"y\", \"z\", \"t\", \"st\", \"sx\", \"sy\", \"sz\", \"opacity\", \"rot_x\", \"rot_y\", \"rot_z\", \"rot_w\",\n        \"rot_r_x\", \"rot_r_y\", \"rot_r_z\", \"rot_r_w\",\n    ];\n    let mut required_property_count = required_properties.len();\n\n    for (_key, element) in &header.elements {\n        if element.name == \"vertex\" {\n            for (key, _prop) in &element.properties {\n                required_property_count -= required_properties.contains(&key.as_str()) as usize;\n            }\n\n            if required_property_count > 0 {\n                return Err(std::io::Error::new(\n                    std::io::ErrorKind::InvalidData,\n                    \"missing required properties\",\n                ));\n            }\n\n            cloud = parser.read_payload_for_element(&mut reader, element, &header)?;\n        }\n    }\n\n    for g in &mut cloud {\n        let norm = g\n            .isotropic_rotations\n            .rotation\n            .iter()\n            .map(|v| v.powi(2))\n            .sum::<f32>()\n            .sqrt();\n\n        for v in &mut g.isotropic_rotations.rotation {\n            *v /= norm;\n        }\n\n        let norm = g\n            .isotropic_rotations\n            .rotation_r\n            .iter()\n            .map(|v| v.powi(2))\n            .sum::<f32>()\n            .sqrt();\n\n        for v in &mut g.isotropic_rotations.rotation_r {\n            *v /= norm;\n        }\n\n        // TODO: normalize timescale between 0 and 1\n    }\n\n    // pad to multiple of 32\n    let pad = 32 - (cloud.len() % 32);\n    cloud.extend(std::iter::repeat_n(Gaussian4d::default(), pad));\n\n    Ok(PlanarGaussian4d::from_interleaved(cloud))\n}\n"
  },
  {
    "path": "src/io/scene.rs",
    "content": "use std::borrow::Cow;\nuse std::collections::{BTreeMap, HashMap};\nuse std::io::ErrorKind;\nuse std::path::Path;\n\nuse base64::Engine as _;\nuse bevy::reflect::TypePath;\nuse bevy::{\n    asset::{AssetLoader, LoadContext, io::Reader},\n    prelude::*,\n};\nuse gltf::{\n    Accessor,\n    accessor::{DataType, Dimensions, Item, Iter},\n    buffer::Source,\n};\nuse serde::Deserialize;\nuse serde_json::{Value, json};\n\nuse crate::gaussian::{\n    formats::planar_3d::{Gaussian3d, PlanarGaussian3d, PlanarGaussian3dHandle},\n    settings::{CloudSettings, GaussianColorSpace, GaussianMode},\n};\nuse crate::material::spherical_harmonics::{\n    SH_CHANNELS, SH_COEFF_COUNT, SH_COEFF_COUNT_PER_CHANNEL,\n};\n\nconst KHR_GAUSSIAN_SPLATTING_EXTENSION: &str = \"KHR_gaussian_splatting\";\n\nconst ATTR_POSITION: &str = \"POSITION\";\nconst ATTR_COLOR_0: &str = \"COLOR_0\";\nconst ATTR_ROTATION: &str = \"KHR_gaussian_splatting:ROTATION\";\nconst ATTR_SCALE: &str = \"KHR_gaussian_splatting:SCALE\";\nconst ATTR_OPACITY: &str = \"KHR_gaussian_splatting:OPACITY\";\nconst ATTR_SH_PREFIX: &str = \"KHR_gaussian_splatting:SH_DEGREE_\";\nconst SH_DEGREE_ZERO_BASIS: f32 = 0.282_095;\n\n#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Reflect)]\npub enum GaussianKernel {\n    #[default]\n    Ellipse,\n}\n\n#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Reflect)]\npub enum GaussianProjection {\n    #[default]\n    Perspective,\n}\n\n#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq, Reflect)]\npub enum GaussianSortingMethod {\n    #[default]\n    CameraDistance,\n}\n\n#[derive(Clone, Debug, Reflect)]\npub struct GaussianPrimitiveSpec {\n    pub kernel: String,\n    pub color_space: String,\n    pub projection: String,\n    pub sorting_method: String,\n    #[reflect(ignore)]\n    pub extension_object: Option<Value>,\n}\n\nimpl Default for GaussianPrimitiveSpec {\n    fn default() -> Self {\n        Self {\n            kernel: \"ellipse\".to_owned(),\n            color_space: \"srgb_rec709_display\".to_owned(),\n            projection: \"perspective\".to_owned(),\n            sorting_method: \"cameraDistance\".to_owned(),\n            extension_object: None,\n        }\n    }\n}\n\n#[derive(Component, Clone, Debug, Default, Reflect)]\npub struct GaussianPrimitiveMetadata {\n    pub kernel: GaussianKernel,\n    pub projection: GaussianProjection,\n    pub sorting_method: GaussianSortingMethod,\n    pub spec: GaussianPrimitiveSpec,\n}\n\n#[derive(Clone, Debug, Default, Reflect)]\npub struct CloudBundle {\n    pub cloud: Handle<PlanarGaussian3d>,\n    pub name: String,\n    pub settings: CloudSettings,\n    pub transform: Transform,\n    pub metadata: GaussianPrimitiveMetadata,\n}\n\n#[derive(Clone, Debug, Default, Reflect)]\npub struct SceneCamera {\n    pub name: String,\n    pub transform: Transform,\n}\n\n#[derive(Asset, Clone, Debug, Default, Reflect)]\npub struct GaussianScene {\n    pub bundles: Vec<CloudBundle>,\n    pub cameras: Vec<SceneCamera>,\n}\n\n#[derive(Clone, Debug)]\npub struct SceneExportCloud {\n    pub cloud: PlanarGaussian3d,\n    pub name: String,\n    pub settings: CloudSettings,\n    pub transform: Transform,\n    pub metadata: GaussianPrimitiveMetadata,\n}\n\n#[derive(Clone, Debug)]\npub struct SceneExportCamera {\n    pub name: String,\n    pub transform: Transform,\n    pub yfov_radians: f32,\n    pub znear: f32,\n    pub zfar: Option<f32>,\n}\n\nimpl Default for SceneExportCamera {\n    fn default() -> Self {\n        Self {\n            name: \"camera\".to_owned(),\n            transform: Transform::default(),\n            yfov_radians: std::f32::consts::FRAC_PI_4,\n            znear: 0.01,\n            zfar: Some(1000.0),\n        }\n    }\n}\n\n#[derive(Component, Clone, Debug, Default, Reflect)]\n#[require(Transform, Visibility)]\npub struct GaussianSceneHandle(pub Handle<GaussianScene>);\n\n#[derive(Component, Clone, Debug, Default, Reflect)]\npub struct GaussianSceneLoaded;\n\n#[derive(Default)]\npub struct GaussianScenePlugin;\n\nimpl Plugin for GaussianScenePlugin {\n    fn build(&self, app: &mut App) {\n        app.register_type::<GaussianKernel>();\n        app.register_type::<GaussianProjection>();\n        app.register_type::<GaussianSortingMethod>();\n        app.register_type::<GaussianPrimitiveSpec>();\n        app.register_type::<GaussianPrimitiveMetadata>();\n        app.register_type::<CloudBundle>();\n        app.register_type::<SceneCamera>();\n        app.register_type::<GaussianScene>();\n        app.register_type::<GaussianSceneHandle>();\n        app.register_type::<GaussianSceneLoaded>();\n\n        app.init_asset::<GaussianScene>();\n        app.init_asset_loader::<GaussianSceneLoader>();\n\n        app.add_systems(Update, (spawn_scene,));\n    }\n}\n\nfn spawn_scene(\n    mut commands: Commands,\n    scene_handles: Query<(Entity, &GaussianSceneHandle), Without<GaussianSceneLoaded>>,\n    asset_server: Res<AssetServer>,\n    scenes: Res<Assets<GaussianScene>>,\n) {\n    for (entity, scene_handle) in scene_handles.iter() {\n        if let Some(load_state) = asset_server.get_load_state(&scene_handle.0)\n            && !load_state.is_loaded()\n        {\n            continue;\n        }\n\n        let Some(scene) = scenes.get(&scene_handle.0) else {\n            continue;\n        };\n\n        let bundles = scene.bundles.clone();\n\n        commands\n            .entity(entity)\n            .with_children(move |builder| {\n                for bundle in bundles {\n                    builder.spawn((\n                        PlanarGaussian3dHandle(bundle.cloud.clone()),\n                        Name::new(bundle.name.clone()),\n                        bundle.settings.clone(),\n                        bundle.transform,\n                        bundle.metadata.clone(),\n                    ));\n                }\n            })\n            .insert(GaussianSceneLoaded);\n    }\n}\n\n#[derive(Default, TypePath)]\npub struct GaussianSceneLoader;\n\nimpl AssetLoader for GaussianSceneLoader {\n    type Asset = GaussianScene;\n    type Settings = ();\n    type Error = std::io::Error;\n\n    async fn load(\n        &self,\n        reader: &mut dyn Reader,\n        _: &Self::Settings,\n        load_context: &mut LoadContext<'_>,\n    ) -> Result<Self::Asset, Self::Error> {\n        let mut bytes = Vec::new();\n        reader.read_to_end(&mut bytes).await?;\n\n        load_gltf_scene(&bytes, load_context).await\n    }\n\n    fn extensions(&self) -> &[&str] {\n        &[\"gltf\", \"glb\"]\n    }\n}\n\n#[derive(Clone, Debug)]\nstruct GaussianPrimitiveSource {\n    attributes: HashMap<String, usize>,\n    metadata: GaussianPrimitiveMetadata,\n    color_space: GaussianColorSpace,\n}\n\n#[derive(Debug, Default, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct RawRoot {\n    #[serde(default, rename = \"extensionsUsed\")]\n    extensions_used: Vec<String>,\n    #[serde(default)]\n    meshes: Vec<RawMesh>,\n    #[serde(default)]\n    nodes: Vec<RawNode>,\n}\n\n#[derive(Debug, Default, Deserialize)]\nstruct RawMesh {\n    #[serde(default)]\n    primitives: Vec<RawPrimitive>,\n}\n\n#[derive(Debug, Default, Deserialize)]\nstruct RawNode {\n    #[serde(default)]\n    name: Option<String>,\n}\n\n#[derive(Debug, Default, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct RawPrimitive {\n    #[serde(default)]\n    attributes: HashMap<String, usize>,\n    #[serde(default)]\n    mode: Option<u32>,\n    #[serde(default)]\n    extensions: HashMap<String, Value>,\n}\n\n#[derive(Debug, Deserialize)]\n#[serde(rename_all = \"camelCase\")]\nstruct RawGaussianExtension {\n    kernel: String,\n    color_space: String,\n    #[serde(default = \"default_projection\")]\n    projection: String,\n    #[serde(default = \"default_sorting_method\")]\n    sorting_method: String,\n}\n\nfn default_projection() -> String {\n    \"perspective\".to_owned()\n}\n\nfn default_sorting_method() -> String {\n    \"cameraDistance\".to_owned()\n}\n\nasync fn load_gltf_scene(\n    bytes: &[u8],\n    load_context: &mut LoadContext<'_>,\n) -> Result<GaussianScene, std::io::Error> {\n    let raw_root = parse_raw_root(bytes)?;\n\n    let gltf = gltf::Gltf::from_slice_without_validation(bytes).map_err(|err| {\n        std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\"failed to parse glTF document: {err}\"),\n        )\n    })?;\n\n    let primitive_sources = collect_gaussian_primitives(&raw_root)?;\n    if primitive_sources.is_empty() {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            \"no KHR_gaussian_splatting primitives found\",\n        ));\n    }\n    ensure_gaussian_extension_used(&raw_root.extensions_used)?;\n\n    let buffers = load_buffers(&gltf, load_context).await?;\n    let scene = gltf.default_scene().or_else(|| gltf.scenes().next());\n    let Some(scene) = scene else {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            \"glTF does not contain any scenes\",\n        ));\n    };\n\n    let mut bundles = Vec::new();\n    let mut cameras = Vec::new();\n    let mut bundle_index = 0usize;\n\n    for node in scene.nodes() {\n        collect_node_bundles(\n            &node,\n            Mat4::IDENTITY,\n            &raw_root,\n            &gltf.document,\n            &buffers,\n            &primitive_sources,\n            load_context,\n            &mut bundle_index,\n            &mut bundles,\n            &mut cameras,\n        )?;\n    }\n\n    if bundles.is_empty() {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            \"KHR_gaussian_splatting scene contained no loadable gaussian primitives\",\n        ));\n    }\n\n    Ok(GaussianScene { bundles, cameras })\n}\n\nfn ensure_gaussian_extension_used(extensions_used: &[String]) -> Result<(), std::io::Error> {\n    if extensions_used\n        .iter()\n        .any(|extension| extension == KHR_GAUSSIAN_SPLATTING_EXTENSION)\n    {\n        return Ok(());\n    }\n\n    Err(std::io::Error::new(\n        ErrorKind::InvalidData,\n        \"KHR_gaussian_splatting primitives are present but the extension is missing from extensionsUsed\",\n    ))\n}\n\nfn parse_raw_root(bytes: &[u8]) -> Result<RawRoot, std::io::Error> {\n    if bytes.starts_with(b\"glTF\") {\n        let glb = gltf::binary::Glb::from_slice(bytes).map_err(|err| {\n            std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\"failed to parse GLB binary container: {err}\"),\n            )\n        })?;\n\n        serde_json::from_slice(glb.json.as_ref()).map_err(|err| {\n            std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\"failed to parse GLB JSON chunk: {err}\"),\n            )\n        })\n    } else {\n        serde_json::from_slice(bytes).map_err(|err| {\n            std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\"failed to parse glTF JSON: {err}\"),\n            )\n        })\n    }\n}\n\nfn collect_gaussian_primitives(\n    raw_root: &RawRoot,\n) -> Result<HashMap<(usize, usize), GaussianPrimitiveSource>, std::io::Error> {\n    let mut sources = HashMap::new();\n\n    for (mesh_index, mesh) in raw_root.meshes.iter().enumerate() {\n        for (primitive_index, primitive) in mesh.primitives.iter().enumerate() {\n            let Some(extension_value) = primitive.extensions.get(KHR_GAUSSIAN_SPLATTING_EXTENSION)\n            else {\n                continue;\n            };\n\n            let mode = primitive.mode.unwrap_or(4);\n            if mode != 0 {\n                return Err(std::io::Error::new(\n                    ErrorKind::InvalidData,\n                    format!(\n                        \"mesh {mesh_index} primitive {primitive_index} has KHR_gaussian_splatting but mode={mode}; mode must be POINTS (0)\"\n                    ),\n                ));\n            }\n\n            let extension: RawGaussianExtension =\n                serde_json::from_value(extension_value.clone()).map_err(|err| {\n                    std::io::Error::new(\n                        ErrorKind::InvalidData,\n                        format!(\n                            \"mesh {mesh_index} primitive {primitive_index} has invalid KHR_gaussian_splatting extension payload: {err}\"\n                        ),\n                    )\n                })?;\n\n            let kernel = parse_kernel(&extension.kernel, mesh_index, primitive_index)?;\n            let color_space =\n                parse_color_space(&extension.color_space, mesh_index, primitive_index)?;\n            let projection = parse_projection(&extension.projection, mesh_index, primitive_index)?;\n            let sorting_method =\n                parse_sorting_method(&extension.sorting_method, mesh_index, primitive_index)?;\n\n            sources.insert(\n                (mesh_index, primitive_index),\n                GaussianPrimitiveSource {\n                    attributes: primitive.attributes.clone(),\n                    metadata: GaussianPrimitiveMetadata {\n                        kernel,\n                        projection,\n                        sorting_method,\n                        spec: GaussianPrimitiveSpec {\n                            kernel: extension.kernel.clone(),\n                            color_space: extension.color_space.clone(),\n                            projection: extension.projection.clone(),\n                            sorting_method: extension.sorting_method.clone(),\n                            extension_object: Some(extension_value.clone()),\n                        },\n                    },\n                    color_space,\n                },\n            );\n        }\n    }\n\n    Ok(sources)\n}\n\nfn parse_kernel(\n    value: &str,\n    mesh_index: usize,\n    primitive_index: usize,\n) -> Result<GaussianKernel, std::io::Error> {\n    if value.trim().is_empty() {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"mesh {mesh_index} primitive {primitive_index} has an empty KHR_gaussian_splatting kernel value\"\n            ),\n        ));\n    }\n\n    match value {\n        \"ellipse\" => Ok(GaussianKernel::Ellipse),\n        _ => {\n            warn!(\n                \"mesh {} primitive {} uses extension kernel '{}'; falling back to base kernel 'ellipse'\",\n                mesh_index, primitive_index, value\n            );\n            Ok(GaussianKernel::Ellipse)\n        }\n    }\n}\n\nfn parse_color_space(\n    value: &str,\n    mesh_index: usize,\n    primitive_index: usize,\n) -> Result<GaussianColorSpace, std::io::Error> {\n    if value.trim().is_empty() {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"mesh {mesh_index} primitive {primitive_index} has an empty KHR_gaussian_splatting colorSpace value\"\n            ),\n        ));\n    }\n\n    match value {\n        \"srgb_rec709_display\" => Ok(GaussianColorSpace::SrgbRec709Display),\n        \"lin_rec709_display\" => Ok(GaussianColorSpace::LinRec709Display),\n        _ => {\n            warn!(\n                \"mesh {} primitive {} uses extension colorSpace '{}'; falling back to 'srgb_rec709_display'\",\n                mesh_index, primitive_index, value\n            );\n            Ok(GaussianColorSpace::SrgbRec709Display)\n        }\n    }\n}\n\nfn parse_projection(\n    value: &str,\n    mesh_index: usize,\n    primitive_index: usize,\n) -> Result<GaussianProjection, std::io::Error> {\n    if value.trim().is_empty() {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"mesh {mesh_index} primitive {primitive_index} has an empty KHR_gaussian_splatting projection value\"\n            ),\n        ));\n    }\n\n    match value {\n        \"perspective\" => Ok(GaussianProjection::Perspective),\n        _ => {\n            warn!(\n                \"mesh {} primitive {} uses extension projection '{}'; falling back to 'perspective'\",\n                mesh_index, primitive_index, value\n            );\n            Ok(GaussianProjection::Perspective)\n        }\n    }\n}\n\nfn parse_sorting_method(\n    value: &str,\n    mesh_index: usize,\n    primitive_index: usize,\n) -> Result<GaussianSortingMethod, std::io::Error> {\n    if value.trim().is_empty() {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"mesh {mesh_index} primitive {primitive_index} has an empty KHR_gaussian_splatting sortingMethod value\"\n            ),\n        ));\n    }\n\n    match value {\n        \"cameraDistance\" => Ok(GaussianSortingMethod::CameraDistance),\n        _ => {\n            warn!(\n                \"mesh {} primitive {} uses extension sortingMethod '{}'; falling back to 'cameraDistance'\",\n                mesh_index, primitive_index, value\n            );\n            Ok(GaussianSortingMethod::CameraDistance)\n        }\n    }\n}\n\nasync fn load_buffers(\n    gltf: &gltf::Gltf,\n    load_context: &mut LoadContext<'_>,\n) -> Result<Vec<Vec<u8>>, std::io::Error> {\n    let mut buffers = Vec::new();\n    let mut blob = gltf.blob.clone();\n\n    for buffer in gltf.buffers() {\n        let mut data = match buffer.source() {\n            Source::Bin => blob.take().ok_or_else(|| {\n                std::io::Error::new(\n                    ErrorKind::InvalidData,\n                    \"glTF buffer references BIN chunk but binary data is missing\",\n                )\n            })?,\n            Source::Uri(uri) => {\n                if let Some(decoded) = decode_data_uri(uri) {\n                    decoded?\n                } else {\n                    let path = load_context.path().resolve_embed(uri).map_err(|err| {\n                        std::io::Error::new(\n                            ErrorKind::InvalidData,\n                            format!(\"failed to resolve external buffer URI '{uri}': {err}\"),\n                        )\n                    })?;\n\n                    load_context.read_asset_bytes(path).await.map_err(|err| {\n                        std::io::Error::new(\n                            ErrorKind::NotFound,\n                            format!(\"failed to read external buffer '{uri}': {err}\"),\n                        )\n                    })?\n                }\n            }\n        };\n\n        if data.len() < buffer.length() {\n            return Err(std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\n                    \"buffer {} length mismatch: expected at least {} bytes, got {} bytes\",\n                    buffer.index(),\n                    buffer.length(),\n                    data.len()\n                ),\n            ));\n        }\n\n        while data.len() % 4 != 0 {\n            data.push(0);\n        }\n\n        buffers.push(data);\n    }\n\n    Ok(buffers)\n}\n\nfn decode_data_uri(uri: &str) -> Option<Result<Vec<u8>, std::io::Error>> {\n    let rest = uri.strip_prefix(\"data:\")?;\n\n    let Some((metadata, payload)) = rest.split_once(',') else {\n        return Some(Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            \"malformed data URI; expected a ',' separator\",\n        )));\n    };\n\n    let is_base64 = metadata\n        .split(';')\n        .any(|part| part.eq_ignore_ascii_case(\"base64\"));\n\n    if is_base64 {\n        return Some(\n            base64::engine::general_purpose::STANDARD\n                .decode(payload)\n                .map_err(|err| {\n                    std::io::Error::new(\n                        ErrorKind::InvalidData,\n                        format!(\"failed to decode base64 data URI: {err}\"),\n                    )\n                }),\n        );\n    }\n\n    Some(decode_percent_encoded_data_uri(payload))\n}\n\nfn decode_percent_encoded_data_uri(payload: &str) -> Result<Vec<u8>, std::io::Error> {\n    let bytes = payload.as_bytes();\n    let mut decoded = Vec::with_capacity(bytes.len());\n    let mut index = 0usize;\n\n    while index < bytes.len() {\n        if bytes[index] == b'%' {\n            if index + 2 >= bytes.len() {\n                return Err(std::io::Error::new(\n                    ErrorKind::InvalidData,\n                    \"malformed percent-encoded data URI payload\",\n                ));\n            }\n\n            let high = decode_hex(bytes[index + 1])?;\n            let low = decode_hex(bytes[index + 2])?;\n            decoded.push((high << 4) | low);\n            index += 3;\n            continue;\n        }\n\n        decoded.push(bytes[index]);\n        index += 1;\n    }\n\n    Ok(decoded)\n}\n\nfn decode_hex(value: u8) -> Result<u8, std::io::Error> {\n    match value {\n        b'0'..=b'9' => Ok(value - b'0'),\n        b'a'..=b'f' => Ok(value - b'a' + 10),\n        b'A'..=b'F' => Ok(value - b'A' + 10),\n        _ => Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"malformed percent-encoded data URI payload: invalid hex digit '{}'\",\n                value as char\n            ),\n        )),\n    }\n}\n\n#[allow(clippy::too_many_arguments)]\nfn collect_node_bundles(\n    node: &gltf::Node<'_>,\n    parent_transform: Mat4,\n    raw_root: &RawRoot,\n    document: &gltf::Document,\n    buffers: &[Vec<u8>],\n    primitive_sources: &HashMap<(usize, usize), GaussianPrimitiveSource>,\n    load_context: &mut LoadContext<'_>,\n    bundle_index: &mut usize,\n    bundles: &mut Vec<CloudBundle>,\n    cameras: &mut Vec<SceneCamera>,\n) -> Result<(), std::io::Error> {\n    let local_transform = Mat4::from_cols_array_2d(&node.transform().matrix());\n    let world_transform = parent_transform * local_transform;\n    let node_name = raw_root\n        .nodes\n        .get(node.index())\n        .and_then(|raw_node| raw_node.name.as_deref())\n        .unwrap_or(\"gaussian_node\");\n\n    if node.camera().is_some() {\n        cameras.push(SceneCamera {\n            name: node_name.to_owned(),\n            transform: Transform::from_matrix(world_transform),\n        });\n    }\n\n    if let Some(mesh) = node.mesh() {\n        for primitive in mesh.primitives() {\n            let key = (mesh.index(), primitive.index());\n            let Some(source) = primitive_sources.get(&key) else {\n                continue;\n            };\n\n            let cloud = decode_gaussian_primitive(document, buffers, source)?;\n            let cloud_handle =\n                load_context.add_labeled_asset(format!(\"gltf_gaussian_{}\", *bundle_index), cloud);\n\n            let settings = CloudSettings {\n                gaussian_mode: GaussianMode::Gaussian3d,\n                color_space: source.color_space,\n                ..default()\n            };\n\n            bundles.push(CloudBundle {\n                cloud: cloud_handle,\n                name: format!(\n                    \"{node_name}_mesh{}_primitive{}\",\n                    mesh.index(),\n                    primitive.index()\n                ),\n                settings,\n                transform: Transform::from_matrix(world_transform),\n                metadata: source.metadata.clone(),\n            });\n            *bundle_index += 1;\n        }\n    }\n\n    for child in node.children() {\n        collect_node_bundles(\n            &child,\n            world_transform,\n            raw_root,\n            document,\n            buffers,\n            primitive_sources,\n            load_context,\n            bundle_index,\n            bundles,\n            cameras,\n        )?;\n    }\n\n    Ok(())\n}\n\npub fn encode_khr_gaussian_scene_gltf_bytes(\n    clouds: &[SceneExportCloud],\n    camera: Option<&SceneExportCamera>,\n) -> Result<Vec<u8>, std::io::Error> {\n    if clouds.is_empty() {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidInput,\n            \"cannot export an empty KHR_gaussian_splatting scene\",\n        ));\n    }\n\n    let mut binary = Vec::<u8>::new();\n    let mut buffer_views = Vec::<Value>::new();\n    let mut accessors = Vec::<Value>::new();\n    let mut meshes = Vec::<Value>::new();\n    let mut nodes = Vec::<Value>::new();\n    let mut scene_nodes = Vec::<usize>::new();\n    let mut cameras_json = Vec::<Value>::new();\n\n    let export_sh_degree = max_export_sh_degree().min(3);\n    let export_coeff_count = (export_sh_degree + 1) * (export_sh_degree + 1);\n\n    for cloud in clouds {\n        let source_gaussian_count = cloud.cloud.position_visibility.len();\n        if source_gaussian_count == 0 {\n            continue;\n        }\n\n        let mut positions = Vec::<f32>::with_capacity(source_gaussian_count * 3);\n        let mut rotations = Vec::<f32>::with_capacity(source_gaussian_count * 4);\n        let mut scales = Vec::<f32>::with_capacity(source_gaussian_count * 3);\n        let mut opacities = Vec::<f32>::with_capacity(source_gaussian_count);\n        let mut sh_channels = (0..export_coeff_count)\n            .map(|_| Vec::<f32>::with_capacity(source_gaussian_count * 3))\n            .collect::<Vec<_>>();\n\n        let mut position_min = [f32::INFINITY; 3];\n        let mut position_max = [f32::NEG_INFINITY; 3];\n        let mut dropped_gaussians = 0usize;\n\n        for gaussian in cloud.cloud.iter() {\n            let rotation = gaussian.rotation.rotation;\n            let rotation_length_sq = rotation\n                .iter()\n                .map(|component| component * component)\n                .sum::<f32>();\n            if rotation_length_sq <= f32::EPSILON || !rotation_length_sq.is_finite() {\n                dropped_gaussians += 1;\n                continue;\n            }\n            let inv_rotation_length = rotation_length_sq.sqrt().recip();\n            let normalized_rotation = [\n                rotation[0] * inv_rotation_length,\n                rotation[1] * inv_rotation_length,\n                rotation[2] * inv_rotation_length,\n                rotation[3] * inv_rotation_length,\n            ];\n\n            let position = gaussian.position_visibility.position;\n            positions.extend_from_slice(&position);\n            for axis in 0..3 {\n                position_min[axis] = position_min[axis].min(position[axis]);\n                position_max[axis] = position_max[axis].max(position[axis]);\n            }\n\n            rotations.extend_from_slice(&normalized_rotation);\n\n            scales.extend_from_slice(&[\n                gaussian.scale_opacity.scale[0].max(1e-6).ln(),\n                gaussian.scale_opacity.scale[1].max(1e-6).ln(),\n                gaussian.scale_opacity.scale[2].max(1e-6).ln(),\n            ]);\n            opacities.push(gaussian.scale_opacity.opacity.clamp(0.0, 1.0));\n\n            for (coefficient_index, channel) in sh_channels.iter_mut().enumerate() {\n                let base = coefficient_index * SH_CHANNELS;\n                channel.extend_from_slice(&[\n                    gaussian.spherical_harmonic.coefficients[base],\n                    gaussian.spherical_harmonic.coefficients[base + 1],\n                    gaussian.spherical_harmonic.coefficients[base + 2],\n                ]);\n            }\n        }\n\n        let gaussian_count = positions.len() / 3;\n        if gaussian_count == 0 {\n            warn!(\n                \"skipping cloud '{}' during KHR export because all gaussians had invalid rotations\",\n                cloud.name\n            );\n            continue;\n        }\n        if dropped_gaussians > 0 {\n            warn!(\n                \"dropped {} gaussians with invalid rotations while exporting cloud '{}'\",\n                dropped_gaussians, cloud.name\n            );\n        }\n\n        let position_accessor = push_f32_accessor(\n            &mut binary,\n            &mut buffer_views,\n            &mut accessors,\n            AccessorSpec {\n                values: &positions,\n                count: gaussian_count,\n                accessor_type: \"VEC3\",\n                min: Some(position_min.to_vec()),\n                max: Some(position_max.to_vec()),\n            },\n        );\n        let rotation_accessor = push_f32_accessor(\n            &mut binary,\n            &mut buffer_views,\n            &mut accessors,\n            AccessorSpec {\n                values: &rotations,\n                count: gaussian_count,\n                accessor_type: \"VEC4\",\n                min: None,\n                max: None,\n            },\n        );\n        let scale_accessor = push_f32_accessor(\n            &mut binary,\n            &mut buffer_views,\n            &mut accessors,\n            AccessorSpec {\n                values: &scales,\n                count: gaussian_count,\n                accessor_type: \"VEC3\",\n                min: None,\n                max: None,\n            },\n        );\n        let opacity_accessor = push_f32_accessor(\n            &mut binary,\n            &mut buffer_views,\n            &mut accessors,\n            AccessorSpec {\n                values: &opacities,\n                count: gaussian_count,\n                accessor_type: \"SCALAR\",\n                min: None,\n                max: None,\n            },\n        );\n\n        let mut attributes = serde_json::Map::new();\n        attributes.insert(ATTR_POSITION.to_owned(), json!(position_accessor));\n        attributes.insert(ATTR_ROTATION.to_owned(), json!(rotation_accessor));\n        attributes.insert(ATTR_SCALE.to_owned(), json!(scale_accessor));\n        attributes.insert(ATTR_OPACITY.to_owned(), json!(opacity_accessor));\n\n        for (coefficient_index, values) in sh_channels.iter().enumerate() {\n            let sh_accessor = push_f32_accessor(\n                &mut binary,\n                &mut buffer_views,\n                &mut accessors,\n                AccessorSpec {\n                    values,\n                    count: gaussian_count,\n                    accessor_type: \"VEC3\",\n                    min: None,\n                    max: None,\n                },\n            );\n            let (degree, coefficient) = sh_index_to_degree_coefficient(coefficient_index);\n            attributes.insert(\n                format!(\"{ATTR_SH_PREFIX}{degree}_COEF_{coefficient}\"),\n                json!(sh_accessor),\n            );\n        }\n\n        let primitive_extension =\n            gaussian_extension_object(&cloud.metadata, cloud.settings.color_space);\n        meshes.push(json!({\n            \"name\": cloud.name,\n            \"primitives\": [{\n                \"attributes\": Value::Object(attributes),\n                \"mode\": 0,\n                \"extensions\": {\n                    KHR_GAUSSIAN_SPLATTING_EXTENSION: primitive_extension\n                }\n            }]\n        }));\n\n        let node_index = nodes.len();\n        scene_nodes.push(node_index);\n        nodes.push(json!({\n            \"name\": cloud.name,\n            \"mesh\": meshes.len() - 1,\n            \"matrix\": transform_matrix_values(cloud.transform),\n        }));\n    }\n\n    if scene_nodes.is_empty() {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidInput,\n            \"cannot export a KHR_gaussian_splatting scene with zero gaussians\",\n        ));\n    }\n\n    if let Some(camera) = camera {\n        let mut perspective = serde_json::Map::new();\n        perspective.insert(\"yfov\".to_owned(), json!(camera.yfov_radians));\n        perspective.insert(\"znear\".to_owned(), json!(camera.znear));\n        if let Some(zfar) = camera.zfar {\n            perspective.insert(\"zfar\".to_owned(), json!(zfar));\n        }\n\n        cameras_json.push(json!({\n            \"name\": camera.name,\n            \"type\": \"perspective\",\n            \"perspective\": Value::Object(perspective),\n        }));\n\n        let camera_node_index = nodes.len();\n        scene_nodes.push(camera_node_index);\n        nodes.push(json!({\n            \"name\": camera.name,\n            \"camera\": cameras_json.len() - 1,\n            \"matrix\": transform_matrix_values(camera.transform),\n        }));\n    }\n\n    align_to_four_bytes(&mut binary);\n\n    let mut root = serde_json::Map::new();\n    root.insert(\"asset\".to_owned(), json!({ \"version\": \"2.0\" }));\n    root.insert(\n        \"extensionsUsed\".to_owned(),\n        json!([KHR_GAUSSIAN_SPLATTING_EXTENSION]),\n    );\n    root.insert(\n        \"extensionsRequired\".to_owned(),\n        json!([KHR_GAUSSIAN_SPLATTING_EXTENSION]),\n    );\n    root.insert(\"scene\".to_owned(), json!(0));\n    root.insert(\"scenes\".to_owned(), json!([{ \"nodes\": scene_nodes }]));\n    root.insert(\"nodes\".to_owned(), Value::Array(nodes));\n    root.insert(\"meshes\".to_owned(), Value::Array(meshes));\n    root.insert(\n        \"buffers\".to_owned(),\n        json!([{\n            \"byteLength\": binary.len(),\n            \"uri\": format!(\n                \"data:application/octet-stream;base64,{}\",\n                base64::engine::general_purpose::STANDARD.encode(&binary)\n            ),\n        }]),\n    );\n    root.insert(\"bufferViews\".to_owned(), Value::Array(buffer_views));\n    root.insert(\"accessors\".to_owned(), Value::Array(accessors));\n    if !cameras_json.is_empty() {\n        root.insert(\"cameras\".to_owned(), Value::Array(cameras_json));\n    }\n\n    serde_json::to_vec_pretty(&Value::Object(root)).map_err(|err| {\n        std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\"failed to serialize KHR_gaussian_splatting scene: {err}\"),\n        )\n    })\n}\n\npub fn write_khr_gaussian_scene_gltf(\n    path: impl AsRef<Path>,\n    clouds: &[SceneExportCloud],\n    camera: Option<&SceneExportCamera>,\n) -> Result<(), std::io::Error> {\n    let bytes = encode_khr_gaussian_scene_gltf_bytes(clouds, camera)?;\n    std::fs::write(path, bytes)\n}\n\npub fn encode_khr_gaussian_scene_glb_bytes(\n    clouds: &[SceneExportCloud],\n    camera: Option<&SceneExportCamera>,\n) -> Result<Vec<u8>, std::io::Error> {\n    let gltf_bytes = encode_khr_gaussian_scene_gltf_bytes(clouds, camera)?;\n    let mut root: Value = serde_json::from_slice(&gltf_bytes).map_err(|err| {\n        std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\"failed to parse generated KHR_gaussian_splatting glTF JSON: {err}\"),\n        )\n    })?;\n\n    let binary = extract_embedded_binary_buffer(&mut root)?;\n\n    let json = serde_json::to_vec(&root).map_err(|err| {\n        std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\"failed to serialize KHR_gaussian_splatting GLB JSON chunk: {err}\"),\n        )\n    })?;\n\n    let glb = gltf::binary::Glb {\n        header: gltf::binary::Header {\n            magic: *b\"glTF\",\n            version: 2,\n            length: 0,\n        },\n        json: Cow::Owned(json),\n        bin: Some(Cow::Owned(binary)),\n    };\n\n    glb.to_vec().map_err(|err| {\n        std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\"failed to serialize KHR_gaussian_splatting GLB: {err}\"),\n        )\n    })\n}\n\npub fn write_khr_gaussian_scene_glb(\n    path: impl AsRef<Path>,\n    clouds: &[SceneExportCloud],\n    camera: Option<&SceneExportCamera>,\n) -> Result<(), std::io::Error> {\n    let bytes = encode_khr_gaussian_scene_glb_bytes(clouds, camera)?;\n    std::fs::write(path, bytes)\n}\n\nfn extract_embedded_binary_buffer(root: &mut Value) -> Result<Vec<u8>, std::io::Error> {\n    let buffers = root\n        .get_mut(\"buffers\")\n        .and_then(Value::as_array_mut)\n        .ok_or_else(|| std::io::Error::new(ErrorKind::InvalidData, \"missing glTF buffers array\"))?;\n\n    if buffers.len() != 1 {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"KHR_gaussian_splatting export expects exactly one buffer, found {}\",\n                buffers.len()\n            ),\n        ));\n    }\n\n    let buffer = buffers[0].as_object_mut().ok_or_else(|| {\n        std::io::Error::new(ErrorKind::InvalidData, \"buffer entry must be a JSON object\")\n    })?;\n    let uri = buffer\n        .remove(\"uri\")\n        .and_then(|value| value.as_str().map(ToOwned::to_owned))\n        .ok_or_else(|| {\n            std::io::Error::new(\n                ErrorKind::InvalidData,\n                \"buffer URI missing; expected embedded data URI for GLB conversion\",\n            )\n        })?;\n\n    let binary = decode_data_uri(&uri).ok_or_else(|| {\n        std::io::Error::new(\n            ErrorKind::InvalidData,\n            \"buffer URI must be an embedded data URI for GLB conversion\",\n        )\n    })??;\n\n    buffer.insert(\"byteLength\".to_owned(), json!(binary.len()));\n\n    Ok(binary)\n}\n\nfn align_to_four_bytes(bytes: &mut Vec<u8>) {\n    while !bytes.len().is_multiple_of(4) {\n        bytes.push(0);\n    }\n}\n\nstruct AccessorSpec<'a> {\n    values: &'a [f32],\n    count: usize,\n    accessor_type: &'a str,\n    min: Option<Vec<f32>>,\n    max: Option<Vec<f32>>,\n}\n\nfn push_f32_accessor(\n    binary: &mut Vec<u8>,\n    buffer_views: &mut Vec<Value>,\n    accessors: &mut Vec<Value>,\n    spec: AccessorSpec<'_>,\n) -> usize {\n    align_to_four_bytes(binary);\n    let byte_offset = binary.len();\n    for value in spec.values {\n        binary.extend_from_slice(&value.to_le_bytes());\n    }\n    let byte_length = std::mem::size_of_val(spec.values);\n\n    let buffer_view_index = buffer_views.len();\n    buffer_views.push(json!({\n        \"buffer\": 0,\n        \"byteOffset\": byte_offset,\n        \"byteLength\": byte_length,\n    }));\n\n    let mut accessor = serde_json::Map::new();\n    accessor.insert(\"bufferView\".to_owned(), json!(buffer_view_index));\n    accessor.insert(\"componentType\".to_owned(), json!(5126u32));\n    accessor.insert(\"count\".to_owned(), json!(spec.count));\n    accessor.insert(\"type\".to_owned(), json!(spec.accessor_type));\n    if let Some(min) = spec.min {\n        accessor.insert(\"min\".to_owned(), json!(min));\n    }\n    if let Some(max) = spec.max {\n        accessor.insert(\"max\".to_owned(), json!(max));\n    }\n\n    let accessor_index = accessors.len();\n    accessors.push(Value::Object(accessor));\n    accessor_index\n}\n\nfn transform_matrix_values(transform: Transform) -> [f32; 16] {\n    transform.to_matrix().to_cols_array()\n}\n\nfn gaussian_extension_object(\n    metadata: &GaussianPrimitiveMetadata,\n    color_space: GaussianColorSpace,\n) -> Value {\n    let mut extension_object = metadata\n        .spec\n        .extension_object\n        .as_ref()\n        .and_then(Value::as_object)\n        .cloned()\n        .unwrap_or_default();\n\n    extension_object.insert(\n        \"kernel\".to_owned(),\n        Value::String(kernel_extension_identifier(metadata)),\n    );\n    extension_object.insert(\n        \"colorSpace\".to_owned(),\n        Value::String(color_space_extension_identifier(metadata, color_space)),\n    );\n    extension_object.insert(\n        \"projection\".to_owned(),\n        Value::String(projection_extension_identifier(metadata)),\n    );\n    extension_object.insert(\n        \"sortingMethod\".to_owned(),\n        Value::String(sorting_method_extension_identifier(metadata)),\n    );\n\n    Value::Object(extension_object)\n}\n\nfn kernel_extension_identifier(metadata: &GaussianPrimitiveMetadata) -> String {\n    extension_identifier(\n        &metadata.spec.kernel,\n        kernel_to_extension_value(metadata.kernel),\n        &[\"ellipse\"],\n    )\n}\n\nfn color_space_extension_identifier(\n    metadata: &GaussianPrimitiveMetadata,\n    color_space: GaussianColorSpace,\n) -> String {\n    extension_identifier(\n        &metadata.spec.color_space,\n        color_space_to_extension_value(color_space),\n        &[\"srgb_rec709_display\", \"lin_rec709_display\"],\n    )\n}\n\nfn projection_extension_identifier(metadata: &GaussianPrimitiveMetadata) -> String {\n    extension_identifier(\n        &metadata.spec.projection,\n        projection_to_extension_value(metadata.projection),\n        &[\"perspective\"],\n    )\n}\n\nfn sorting_method_extension_identifier(metadata: &GaussianPrimitiveMetadata) -> String {\n    extension_identifier(\n        &metadata.spec.sorting_method,\n        sorting_method_to_extension_value(metadata.sorting_method),\n        &[\"cameraDistance\"],\n    )\n}\n\nfn extension_identifier(spec_value: &str, fallback_value: &str, known_values: &[&str]) -> String {\n    let spec_value = spec_value.trim();\n    if spec_value.is_empty() || known_values.contains(&spec_value) {\n        fallback_value.to_owned()\n    } else {\n        spec_value.to_owned()\n    }\n}\n\nfn kernel_to_extension_value(kernel: GaussianKernel) -> &'static str {\n    match kernel {\n        GaussianKernel::Ellipse => \"ellipse\",\n    }\n}\n\nfn projection_to_extension_value(projection: GaussianProjection) -> &'static str {\n    match projection {\n        GaussianProjection::Perspective => \"perspective\",\n    }\n}\n\nfn sorting_method_to_extension_value(method: GaussianSortingMethod) -> &'static str {\n    match method {\n        GaussianSortingMethod::CameraDistance => \"cameraDistance\",\n    }\n}\n\nfn color_space_to_extension_value(color_space: GaussianColorSpace) -> &'static str {\n    match color_space {\n        GaussianColorSpace::SrgbRec709Display => \"srgb_rec709_display\",\n        GaussianColorSpace::LinRec709Display => \"lin_rec709_display\",\n    }\n}\n\nfn sh_index_to_degree_coefficient(index: usize) -> (usize, usize) {\n    let mut degree = 0usize;\n    while (degree + 1) * (degree + 1) <= index {\n        degree += 1;\n    }\n\n    let coefficient = index - (degree * degree);\n    (degree, coefficient)\n}\n\nfn max_export_sh_degree() -> usize {\n    for degree in (0..=3).rev() {\n        if (degree + 1) * (degree + 1) <= SH_COEFF_COUNT_PER_CHANNEL {\n            return degree;\n        }\n    }\n    0\n}\n\nfn decode_gaussian_primitive(\n    document: &gltf::Document,\n    buffers: &[Vec<u8>],\n    source: &GaussianPrimitiveSource,\n) -> Result<PlanarGaussian3d, std::io::Error> {\n    let position_accessor = required_accessor(document, &source.attributes, ATTR_POSITION)?;\n    let rotation_accessor = required_accessor(document, &source.attributes, ATTR_ROTATION)?;\n    let scale_accessor = required_accessor(document, &source.attributes, ATTR_SCALE)?;\n    let opacity_accessor = required_accessor(document, &source.attributes, ATTR_OPACITY)?;\n    let sh_accessors = collect_sh_accessors(document, &source.attributes)?;\n\n    let count = position_accessor.count();\n    ensure_count(&rotation_accessor, count, ATTR_ROTATION)?;\n    ensure_count(&scale_accessor, count, ATTR_SCALE)?;\n    ensure_count(&opacity_accessor, count, ATTR_OPACITY)?;\n\n    let positions = read_position_attribute(&position_accessor, buffers)?;\n    let rotations = read_rotation_attribute(&rotation_accessor, buffers)?;\n    let scales = read_scale_attribute(&scale_accessor, buffers)?;\n    let opacities = read_opacity_attribute(&opacity_accessor, buffers)?;\n    let color_fallback = if sh_accessors.is_empty() {\n        match optional_accessor(document, &source.attributes, ATTR_COLOR_0)? {\n            Some(color_accessor) => {\n                ensure_count(&color_accessor, count, ATTR_COLOR_0)?;\n                Some(read_color_attribute(&color_accessor, buffers)?)\n            }\n            None => None,\n        }\n    } else {\n        None\n    };\n\n    let mut sh_channels = Vec::with_capacity(sh_accessors.len());\n    for (coefficient_index, accessor) in sh_accessors {\n        ensure_count(\n            &accessor,\n            count,\n            &format!(\"{ATTR_SH_PREFIX}{coefficient_index}\"),\n        )?;\n        sh_channels.push((\n            coefficient_index,\n            read_sh_coefficient_attribute(&accessor, buffers)?,\n        ));\n    }\n\n    let mut gaussians = Vec::with_capacity(count);\n    for index in 0..count {\n        let mut spherical_harmonic =\n            crate::material::spherical_harmonics::SphericalHarmonicCoefficients::default();\n\n        if sh_channels.is_empty()\n            && let Some(color_values) = &color_fallback\n        {\n            let color = color_values[index];\n            spherical_harmonic.set(0, color[0] / SH_DEGREE_ZERO_BASIS);\n            spherical_harmonic.set(1, color[1] / SH_DEGREE_ZERO_BASIS);\n            spherical_harmonic.set(2, color[2] / SH_DEGREE_ZERO_BASIS);\n        }\n\n        for (coefficient_index, values) in &sh_channels {\n            let rgb = values[index];\n            let base = coefficient_index * SH_CHANNELS;\n            if (base + 2) < SH_COEFF_COUNT {\n                spherical_harmonic.set(base, rgb[0]);\n                spherical_harmonic.set(base + 1, rgb[1]);\n                spherical_harmonic.set(base + 2, rgb[2]);\n            }\n        }\n\n        gaussians.push(Gaussian3d {\n            position_visibility: [\n                positions[index][0],\n                positions[index][1],\n                positions[index][2],\n                1.0,\n            ]\n            .into(),\n            spherical_harmonic,\n            rotation: rotations[index].into(),\n            scale_opacity: [\n                scales[index][0],\n                scales[index][1],\n                scales[index][2],\n                opacities[index],\n            ]\n            .into(),\n        });\n    }\n\n    Ok(gaussians.into())\n}\n\nfn required_accessor<'a>(\n    document: &'a gltf::Document,\n    attributes: &HashMap<String, usize>,\n    semantic: &str,\n) -> Result<Accessor<'a>, std::io::Error> {\n    let index = attributes.get(semantic).ok_or_else(|| {\n        std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\"missing required attribute semantic '{semantic}'\"),\n        )\n    })?;\n\n    document.accessors().nth(*index).ok_or_else(|| {\n        std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\"attribute semantic '{semantic}' references missing accessor index {index}\"),\n        )\n    })\n}\n\nfn optional_accessor<'a>(\n    document: &'a gltf::Document,\n    attributes: &HashMap<String, usize>,\n    semantic: &str,\n) -> Result<Option<Accessor<'a>>, std::io::Error> {\n    let Some(index) = attributes.get(semantic) else {\n        return Ok(None);\n    };\n\n    let accessor = document.accessors().nth(*index).ok_or_else(|| {\n        std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\"attribute semantic '{semantic}' references missing accessor index {index}\"),\n        )\n    })?;\n\n    Ok(Some(accessor))\n}\n\nfn collect_sh_accessors<'a>(\n    document: &'a gltf::Document,\n    attributes: &HashMap<String, usize>,\n) -> Result<Vec<(usize, Accessor<'a>)>, std::io::Error> {\n    let coefficient_map = collect_sh_coefficient_map(attributes)?;\n    let mut accessors = Vec::with_capacity(coefficient_map.len());\n\n    for (coefficient_index, accessor_index) in coefficient_map {\n        let accessor = document.accessors().nth(accessor_index).ok_or_else(|| {\n            std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\"SH attribute references missing accessor index {accessor_index}\"),\n            )\n        })?;\n\n        accessors.push((coefficient_index, accessor));\n    }\n\n    Ok(accessors)\n}\n\nfn collect_sh_coefficient_map(\n    attributes: &HashMap<String, usize>,\n) -> Result<Vec<(usize, usize)>, std::io::Error> {\n    let mut degrees: BTreeMap<usize, BTreeMap<usize, usize>> = BTreeMap::new();\n\n    for (semantic, accessor_index) in attributes {\n        let Some((degree, coefficient)) = parse_sh_semantic(semantic) else {\n            continue;\n        };\n\n        degrees\n            .entry(degree)\n            .or_default()\n            .insert(coefficient, *accessor_index);\n    }\n\n    if degrees.is_empty() {\n        return Ok(Vec::new());\n    }\n\n    let Some(degree_zero) = degrees.get(&0) else {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            \"missing required spherical harmonics attribute 'KHR_gaussian_splatting:SH_DEGREE_0_COEF_0'\",\n        ));\n    };\n    if !degree_zero.contains_key(&0) {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            \"missing required spherical harmonics attribute 'KHR_gaussian_splatting:SH_DEGREE_0_COEF_0'\",\n        ));\n    }\n\n    let max_degree = *degrees.keys().max().unwrap_or(&0);\n    if max_degree > 3 {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"unsupported spherical harmonics degree {max_degree}; KHR_gaussian_splatting supports degrees up to 3\"\n            ),\n        ));\n    }\n\n    let supported_degree = max_supported_sh_degree();\n    if max_degree > supported_degree {\n        warn!(\n            \"asset uses spherical harmonics degree {max_degree}, but this build supports up to degree {supported_degree}; higher-degree coefficients will be discarded\"\n        );\n    }\n\n    for degree in 0..=max_degree {\n        let expected_count = 2 * degree + 1;\n        let Some(coefficients) = degrees.get(&degree) else {\n            return Err(std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\n                    \"spherical harmonics degree {degree} is required because higher degrees are present, but its coefficients are missing\"\n                ),\n            ));\n        };\n\n        if coefficients.len() != expected_count {\n            return Err(std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\n                    \"spherical harmonics degree {degree} must define exactly {expected_count} coefficients\"\n                ),\n            ));\n        }\n\n        for coefficient in 0..expected_count {\n            if !coefficients.contains_key(&coefficient) {\n                return Err(std::io::Error::new(\n                    ErrorKind::InvalidData,\n                    format!(\n                        \"spherical harmonics degree {degree} is partially defined; missing coefficient {coefficient}\"\n                    ),\n                ));\n            }\n        }\n    }\n\n    let mut coefficient_map = Vec::new();\n    let map_max_degree = max_degree.min(supported_degree);\n    for degree in 0..=map_max_degree {\n        let coefficients = &degrees[&degree];\n        for coefficient in 0..(2 * degree + 1) {\n            let accessor_index = coefficients[&coefficient];\n            coefficient_map.push((sh_coefficient_index(degree, coefficient), accessor_index));\n        }\n    }\n\n    Ok(coefficient_map)\n}\n\nfn parse_sh_semantic(semantic: &str) -> Option<(usize, usize)> {\n    let rest = semantic.strip_prefix(ATTR_SH_PREFIX)?;\n    let (degree, coefficient) = rest.split_once(\"_COEF_\")?;\n\n    Some((degree.parse().ok()?, coefficient.parse().ok()?))\n}\n\nfn sh_coefficient_index(degree: usize, coefficient: usize) -> usize {\n    degree * degree + coefficient\n}\n\nfn max_supported_sh_degree() -> usize {\n    let mut degree = 0usize;\n    loop {\n        let coefficient_count = (degree + 1) * (degree + 1);\n        if coefficient_count >= SH_COEFF_COUNT_PER_CHANNEL {\n            return degree;\n        }\n        degree += 1;\n    }\n}\n\nfn ensure_count(\n    accessor: &Accessor<'_>,\n    expected_count: usize,\n    semantic: &str,\n) -> Result<(), std::io::Error> {\n    let count = accessor.count();\n    if count == expected_count {\n        return Ok(());\n    }\n\n    Err(std::io::Error::new(\n        ErrorKind::InvalidData,\n        format!(\"attribute semantic '{semantic}' has {count} entries; expected {expected_count}\"),\n    ))\n}\n\nfn read_position_attribute(\n    accessor: &Accessor<'_>,\n    buffers: &[Vec<u8>],\n) -> Result<Vec<[f32; 3]>, std::io::Error> {\n    if accessor.dimensions() != Dimensions::Vec3 {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"attribute semantic '{ATTR_POSITION}' must use accessor type VEC3, got {:?}\",\n                accessor.dimensions()\n            ),\n        ));\n    }\n\n    if accessor.data_type() != DataType::F32 {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"attribute semantic '{ATTR_POSITION}' must use float components, got {:?}\",\n                accessor.data_type()\n            ),\n        ));\n    }\n\n    let values = read_items::<[f32; 3]>(accessor, buffers, ATTR_POSITION)?;\n    if values\n        .iter()\n        .flatten()\n        .any(|component| !component.is_finite())\n    {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"attribute semantic '{ATTR_POSITION}' contains non-finite values, which are invalid\"\n            ),\n        ));\n    }\n\n    Ok(values)\n}\n\nfn read_rotation_attribute(\n    accessor: &Accessor<'_>,\n    buffers: &[Vec<u8>],\n) -> Result<Vec<[f32; 4]>, std::io::Error> {\n    if accessor.dimensions() != Dimensions::Vec4 {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"attribute semantic '{ATTR_ROTATION}' must use accessor type VEC4, got {:?}\",\n                accessor.dimensions()\n            ),\n        ));\n    }\n\n    let normalized = accessor.normalized();\n    let mut values = match accessor.data_type() {\n        DataType::F32 => read_items::<[f32; 4]>(accessor, buffers, ATTR_ROTATION)?,\n        DataType::I8 if normalized => read_items::<[i8; 4]>(accessor, buffers, ATTR_ROTATION)?\n            .into_iter()\n            .map(|v| {\n                [\n                    normalize_i8(v[0]),\n                    normalize_i8(v[1]),\n                    normalize_i8(v[2]),\n                    normalize_i8(v[3]),\n                ]\n            })\n            .collect(),\n        DataType::I16 if normalized => read_items::<[i16; 4]>(accessor, buffers, ATTR_ROTATION)?\n            .into_iter()\n            .map(|v| {\n                [\n                    normalize_i16(v[0]),\n                    normalize_i16(v[1]),\n                    normalize_i16(v[2]),\n                    normalize_i16(v[3]),\n                ]\n            })\n            .collect(),\n        _ => {\n            return Err(std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\n                    \"attribute semantic '{ATTR_ROTATION}' must use float or normalized signed integer components\"\n                ),\n            ));\n        }\n    };\n\n    let mut replaced_zero_length_quaternions = 0usize;\n    for quaternion in &mut values {\n        if normalize_quaternion(quaternion) {\n            replaced_zero_length_quaternions += 1;\n        }\n    }\n    if replaced_zero_length_quaternions > 0 {\n        warn!(\n            \"attribute semantic '{}' contained {} zero-length quaternions; replacing them with identity rotations\",\n            ATTR_ROTATION, replaced_zero_length_quaternions\n        );\n    }\n\n    if values\n        .iter()\n        .flatten()\n        .any(|component| !component.is_finite())\n    {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"attribute semantic '{ATTR_ROTATION}' contains non-finite values, which are invalid\"\n            ),\n        ));\n    }\n\n    Ok(values)\n}\n\nfn read_scale_attribute(\n    accessor: &Accessor<'_>,\n    buffers: &[Vec<u8>],\n) -> Result<Vec<[f32; 3]>, std::io::Error> {\n    if accessor.dimensions() != Dimensions::Vec3 {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"attribute semantic '{ATTR_SCALE}' must use accessor type VEC3, got {:?}\",\n                accessor.dimensions()\n            ),\n        ));\n    }\n\n    let normalized = accessor.normalized();\n    let mut values = match accessor.data_type() {\n        DataType::F32 => read_items::<[f32; 3]>(accessor, buffers, ATTR_SCALE)?,\n        DataType::I8 => read_items::<[i8; 3]>(accessor, buffers, ATTR_SCALE)?\n            .into_iter()\n            .map(|v| {\n                if normalized {\n                    [normalize_i8(v[0]), normalize_i8(v[1]), normalize_i8(v[2])]\n                } else {\n                    [v[0] as f32, v[1] as f32, v[2] as f32]\n                }\n            })\n            .collect(),\n        DataType::I16 => read_items::<[i16; 3]>(accessor, buffers, ATTR_SCALE)?\n            .into_iter()\n            .map(|v| {\n                if normalized {\n                    [\n                        normalize_i16(v[0]),\n                        normalize_i16(v[1]),\n                        normalize_i16(v[2]),\n                    ]\n                } else {\n                    [v[0] as f32, v[1] as f32, v[2] as f32]\n                }\n            })\n            .collect(),\n        _ => {\n            return Err(std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\n                    \"attribute semantic '{ATTR_SCALE}' must use float or signed integer components\"\n                ),\n            ));\n        }\n    };\n\n    for scale in &mut values {\n        scale[0] = scale[0].exp();\n        scale[1] = scale[1].exp();\n        scale[2] = scale[2].exp();\n\n        if !scale[0].is_finite() || !scale[1].is_finite() || !scale[2].is_finite() {\n            return Err(std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\n                    \"attribute semantic '{ATTR_SCALE}' produces non-finite scale after exp(scale), which is invalid\"\n                ),\n            ));\n        }\n    }\n\n    Ok(values)\n}\n\nfn read_opacity_attribute(\n    accessor: &Accessor<'_>,\n    buffers: &[Vec<u8>],\n) -> Result<Vec<f32>, std::io::Error> {\n    if accessor.dimensions() != Dimensions::Scalar {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"attribute semantic '{ATTR_OPACITY}' must use accessor type SCALAR, got {:?}\",\n                accessor.dimensions()\n            ),\n        ));\n    }\n\n    let normalized = accessor.normalized();\n    let values: Vec<f32> = match accessor.data_type() {\n        DataType::F32 => read_items::<f32>(accessor, buffers, ATTR_OPACITY)?,\n        DataType::U8 if normalized => read_items::<u8>(accessor, buffers, ATTR_OPACITY)?\n            .into_iter()\n            .map(normalize_u8)\n            .collect(),\n        DataType::U16 if normalized => read_items::<u16>(accessor, buffers, ATTR_OPACITY)?\n            .into_iter()\n            .map(normalize_u16)\n            .collect(),\n        _ => {\n            return Err(std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\n                    \"attribute semantic '{ATTR_OPACITY}' must use float or normalized unsigned integer components\"\n                ),\n            ));\n        }\n    };\n\n    if values\n        .iter()\n        .any(|opacity| !opacity.is_finite() || *opacity < 0.0 || *opacity > 1.0)\n    {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"attribute semantic '{ATTR_OPACITY}' contains out-of-range values; opacity must be in [0, 1]\"\n            ),\n        ));\n    }\n\n    Ok(values)\n}\n\nfn read_color_attribute(\n    accessor: &Accessor<'_>,\n    buffers: &[Vec<u8>],\n) -> Result<Vec<[f32; 3]>, std::io::Error> {\n    let normalized = accessor.normalized();\n    match accessor.dimensions() {\n        Dimensions::Vec3 => {}\n        Dimensions::Vec4 => {}\n        _ => {\n            return Err(std::io::Error::new(\n                ErrorKind::InvalidData,\n                format!(\n                    \"attribute semantic '{ATTR_COLOR_0}' must use accessor type VEC3 or VEC4, got {:?}\",\n                    accessor.dimensions()\n                ),\n            ));\n        }\n    }\n\n    match (accessor.dimensions(), accessor.data_type()) {\n        (Dimensions::Vec3, DataType::F32) => {\n            read_items::<[f32; 3]>(accessor, buffers, ATTR_COLOR_0)\n        }\n        (Dimensions::Vec4, DataType::F32) => {\n            Ok(read_items::<[f32; 4]>(accessor, buffers, ATTR_COLOR_0)?\n                .into_iter()\n                .map(|v| [v[0], v[1], v[2]])\n                .collect())\n        }\n        (Dimensions::Vec3, DataType::U8) => {\n            if !normalized {\n                warn!(\n                    \"attribute semantic '{}' uses non-normalized U8 values; interpreting as normalized colors\",\n                    ATTR_COLOR_0\n                );\n            }\n            Ok(read_items::<[u8; 3]>(accessor, buffers, ATTR_COLOR_0)?\n                .into_iter()\n                .map(|v| [normalize_u8(v[0]), normalize_u8(v[1]), normalize_u8(v[2])])\n                .collect())\n        }\n        (Dimensions::Vec4, DataType::U8) => {\n            if !normalized {\n                warn!(\n                    \"attribute semantic '{}' uses non-normalized U8 values; interpreting as normalized colors\",\n                    ATTR_COLOR_0\n                );\n            }\n            Ok(read_items::<[u8; 4]>(accessor, buffers, ATTR_COLOR_0)?\n                .into_iter()\n                .map(|v| [normalize_u8(v[0]), normalize_u8(v[1]), normalize_u8(v[2])])\n                .collect())\n        }\n        (Dimensions::Vec3, DataType::U16) => {\n            if !normalized {\n                warn!(\n                    \"attribute semantic '{}' uses non-normalized U16 values; interpreting as normalized colors\",\n                    ATTR_COLOR_0\n                );\n            }\n            Ok(read_items::<[u16; 3]>(accessor, buffers, ATTR_COLOR_0)?\n                .into_iter()\n                .map(|v| {\n                    [\n                        normalize_u16(v[0]),\n                        normalize_u16(v[1]),\n                        normalize_u16(v[2]),\n                    ]\n                })\n                .collect())\n        }\n        (Dimensions::Vec4, DataType::U16) => {\n            if !normalized {\n                warn!(\n                    \"attribute semantic '{}' uses non-normalized U16 values; interpreting as normalized colors\",\n                    ATTR_COLOR_0\n                );\n            }\n            Ok(read_items::<[u16; 4]>(accessor, buffers, ATTR_COLOR_0)?\n                .into_iter()\n                .map(|v| {\n                    [\n                        normalize_u16(v[0]),\n                        normalize_u16(v[1]),\n                        normalize_u16(v[2]),\n                    ]\n                })\n                .collect())\n        }\n        _ => Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"attribute semantic '{ATTR_COLOR_0}' must use float, normalized unsigned byte, or normalized unsigned short components\"\n            ),\n        )),\n    }\n}\n\nfn read_sh_coefficient_attribute(\n    accessor: &Accessor<'_>,\n    buffers: &[Vec<u8>],\n) -> Result<Vec<[f32; 3]>, std::io::Error> {\n    if accessor.dimensions() != Dimensions::Vec3 {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            \"spherical harmonics attributes must use accessor type VEC3\",\n        ));\n    }\n\n    if accessor.data_type() != DataType::F32 {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            \"spherical harmonics attributes must use float components\",\n        ));\n    }\n\n    let values = read_items::<[f32; 3]>(accessor, buffers, \"KHR_gaussian_splatting:SH\")?;\n    if values\n        .iter()\n        .flatten()\n        .any(|component| !component.is_finite())\n    {\n        return Err(std::io::Error::new(\n            ErrorKind::InvalidData,\n            \"spherical harmonics attributes contain non-finite values, which are invalid\",\n        ));\n    }\n    Ok(values)\n}\n\nfn read_items<T: Item + Copy>(\n    accessor: &Accessor<'_>,\n    buffers: &[Vec<u8>],\n    semantic: &str,\n) -> Result<Vec<T>, std::io::Error> {\n    let iter = Iter::<T>::new(accessor.clone(), |buffer| {\n        buffers.get(buffer.index()).map(Vec::as_slice)\n    })\n    .ok_or_else(|| {\n        std::io::Error::new(\n            ErrorKind::InvalidData,\n            format!(\n                \"failed to decode accessor data for attribute semantic '{semantic}' (accessor index {})\",\n                accessor.index()\n            ),\n        )\n    })?;\n\n    Ok(iter.collect())\n}\n\nfn normalize_quaternion(quaternion: &mut [f32; 4]) -> bool {\n    let length_sq = quaternion\n        .iter()\n        .map(|component| component * component)\n        .sum::<f32>();\n    if length_sq <= f32::EPSILON {\n        quaternion[0] = 1.0;\n        quaternion[1] = 0.0;\n        quaternion[2] = 0.0;\n        quaternion[3] = 0.0;\n        return true;\n    }\n\n    let inv_length = length_sq.sqrt().recip();\n    quaternion[0] *= inv_length;\n    quaternion[1] *= inv_length;\n    quaternion[2] *= inv_length;\n    quaternion[3] *= inv_length;\n\n    false\n}\n\nfn normalize_i8(value: i8) -> f32 {\n    (value as f32 / 127.0).max(-1.0)\n}\n\nfn normalize_i16(value: i16) -> f32 {\n    (value as f32 / 32767.0).max(-1.0)\n}\n\nfn normalize_u8(value: u8) -> f32 {\n    value as f32 / 255.0\n}\n\nfn normalize_u16(value: u16) -> f32 {\n    value as f32 / 65535.0\n}\n\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn validates_complete_sh_coefficients_for_supported_degree() {\n        let mut attributes = HashMap::new();\n        attributes.insert(\n            \"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\".to_owned(),\n            0usize,\n        );\n\n        let supported_degree = max_supported_sh_degree().min(3);\n        let mut index = 1usize;\n        for degree in 1..=supported_degree {\n            for coefficient in 0..(2 * degree + 1) {\n                attributes.insert(\n                    format!(\"KHR_gaussian_splatting:SH_DEGREE_{degree}_COEF_{coefficient}\"),\n                    index,\n                );\n                index += 1;\n            }\n        }\n\n        let result = collect_sh_coefficient_map(&attributes).unwrap();\n        assert_eq!(\n            result.len(),\n            (supported_degree + 1) * (supported_degree + 1)\n        );\n        assert_eq!(result[0].0, 0);\n        assert_eq!(result.last().unwrap().0, result.len() - 1);\n    }\n\n    #[test]\n    fn allows_no_sh_coefficients_for_extension_defined_lighting() {\n        let attributes = HashMap::new();\n        let result = collect_sh_coefficient_map(&attributes).unwrap();\n        assert!(result.is_empty());\n    }\n\n    #[test]\n    fn rejects_partial_sh_degree() {\n        let mut attributes = HashMap::new();\n        attributes.insert(\n            \"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\".to_owned(),\n            0usize,\n        );\n        attributes.insert(\n            \"KHR_gaussian_splatting:SH_DEGREE_1_COEF_0\".to_owned(),\n            1usize,\n        );\n        attributes.insert(\n            \"KHR_gaussian_splatting:SH_DEGREE_1_COEF_2\".to_owned(),\n            2usize,\n        );\n\n        let err = collect_sh_coefficient_map(&attributes).unwrap_err();\n        let message = err.to_string();\n        assert!(message.contains(\"must define exactly\"));\n    }\n\n    #[test]\n    fn clips_sh_coefficients_above_supported_degree() {\n        let supported_degree = max_supported_sh_degree();\n        if supported_degree >= 3 {\n            return;\n        }\n\n        let mut attributes = HashMap::new();\n        attributes.insert(\n            \"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\".to_owned(),\n            0usize,\n        );\n\n        let mut index = 1usize;\n        for degree in 1..=3 {\n            for coefficient in 0..(2 * degree + 1) {\n                attributes.insert(\n                    format!(\"KHR_gaussian_splatting:SH_DEGREE_{degree}_COEF_{coefficient}\"),\n                    index,\n                );\n                index += 1;\n            }\n        }\n\n        let result = collect_sh_coefficient_map(&attributes).unwrap();\n        assert_eq!(\n            result.len(),\n            (supported_degree + 1) * (supported_degree + 1)\n        );\n        assert!(result\n            .iter()\n            .all(|(index, _)| *index < (supported_degree + 1) * (supported_degree + 1)));\n    }\n\n    #[test]\n    fn preserves_unknown_extension_identifiers_on_export() {\n        let mut metadata = GaussianPrimitiveMetadata::default();\n        metadata.spec.kernel = \"customShape\".to_owned();\n        metadata.spec.color_space = \"custom_space_display\".to_owned();\n        metadata.spec.projection = \"customProjection\".to_owned();\n        metadata.spec.sorting_method = \"customSort\".to_owned();\n\n        assert_eq!(kernel_extension_identifier(&metadata), \"customShape\");\n        assert_eq!(\n            color_space_extension_identifier(&metadata, GaussianColorSpace::SrgbRec709Display),\n            \"custom_space_display\"\n        );\n        assert_eq!(\n            projection_extension_identifier(&metadata),\n            \"customProjection\"\n        );\n        assert_eq!(sorting_method_extension_identifier(&metadata), \"customSort\");\n    }\n\n    #[test]\n    fn uses_runtime_color_space_for_known_identifiers() {\n        let mut metadata = GaussianPrimitiveMetadata::default();\n        metadata.spec.color_space = \"lin_rec709_display\".to_owned();\n\n        assert_eq!(\n            color_space_extension_identifier(&metadata, GaussianColorSpace::SrgbRec709Display),\n            \"srgb_rec709_display\"\n        );\n        assert_eq!(\n            color_space_extension_identifier(&metadata, GaussianColorSpace::LinRec709Display),\n            \"lin_rec709_display\"\n        );\n    }\n\n    #[test]\n    fn decodes_base64_data_uri() {\n        let uri = \"data:application/octet-stream;base64,AAECAwQF\";\n        let data = decode_data_uri(uri).unwrap().unwrap();\n        assert_eq!(data, vec![0, 1, 2, 3, 4, 5]);\n    }\n\n    #[test]\n    fn decodes_percent_encoded_data_uri() {\n        let uri = \"data:application/octet-stream,%00%01%02%03\";\n        let data = decode_data_uri(uri).unwrap().unwrap();\n        assert_eq!(data, vec![0, 1, 2, 3]);\n    }\n\n    #[test]\n    fn requires_khr_extension_in_extensions_used() {\n        let err = ensure_gaussian_extension_used(&[]).unwrap_err();\n        assert!(err.to_string().contains(\"extensionsUsed\"));\n\n        ensure_gaussian_extension_used(&[KHR_GAUSSIAN_SPLATTING_EXTENSION.to_owned()]).unwrap();\n    }\n\n    #[test]\n    fn encodes_khr_scene_with_camera_node() {\n        let cloud: PlanarGaussian3d = vec![Gaussian3d {\n            position_visibility: [1.0, 2.0, 3.0, 1.0].into(),\n            spherical_harmonic:\n                crate::material::spherical_harmonics::SphericalHarmonicCoefficients::default(),\n            rotation: [1.0, 0.0, 0.0, 0.0].into(),\n            scale_opacity: [1.0, 1.0, 1.0, 0.5].into(),\n        }]\n        .into();\n\n        let export_cloud = SceneExportCloud {\n            cloud,\n            name: \"cloud\".to_owned(),\n            settings: CloudSettings::default(),\n            transform: Transform::default(),\n            metadata: GaussianPrimitiveMetadata::default(),\n        };\n        let export_camera = SceneExportCamera {\n            name: \"camera\".to_owned(),\n            transform: Transform::from_xyz(4.0, 5.0, 6.0),\n            ..default()\n        };\n\n        let bytes =\n            encode_khr_gaussian_scene_gltf_bytes(&[export_cloud], Some(&export_camera)).unwrap();\n        let root: Value = serde_json::from_slice(&bytes).unwrap();\n\n        assert_eq!(\n            root[\"extensionsUsed\"]\n                .as_array()\n                .unwrap()\n                .iter()\n                .filter_map(Value::as_str)\n                .next(),\n            Some(KHR_GAUSSIAN_SPLATTING_EXTENSION)\n        );\n        assert_eq!(root[\"meshes\"].as_array().unwrap().len(), 1);\n        assert_eq!(root[\"cameras\"].as_array().unwrap().len(), 1);\n        assert_eq!(root[\"nodes\"].as_array().unwrap().len(), 2);\n    }\n\n    #[test]\n    fn encodes_khr_scene_as_glb_with_bin_chunk() {\n        let cloud: PlanarGaussian3d = vec![Gaussian3d {\n            position_visibility: [1.0, 2.0, 3.0, 1.0].into(),\n            spherical_harmonic:\n                crate::material::spherical_harmonics::SphericalHarmonicCoefficients::default(),\n            rotation: [1.0, 0.0, 0.0, 0.0].into(),\n            scale_opacity: [1.0, 1.0, 1.0, 0.5].into(),\n        }]\n        .into();\n\n        let export_cloud = SceneExportCloud {\n            cloud,\n            name: \"cloud\".to_owned(),\n            settings: CloudSettings::default(),\n            transform: Transform::default(),\n            metadata: GaussianPrimitiveMetadata::default(),\n        };\n        let export_camera = SceneExportCamera {\n            name: \"camera\".to_owned(),\n            transform: Transform::from_xyz(4.0, 5.0, 6.0),\n            ..default()\n        };\n\n        let glb_bytes =\n            encode_khr_gaussian_scene_glb_bytes(&[export_cloud], Some(&export_camera)).unwrap();\n        let glb = gltf::binary::Glb::from_slice(&glb_bytes).unwrap();\n\n        assert!(glb.bin.is_some());\n\n        let root: Value = serde_json::from_slice(glb.json.as_ref()).unwrap();\n        assert_eq!(root[\"meshes\"].as_array().unwrap().len(), 1);\n        assert_eq!(root[\"cameras\"].as_array().unwrap().len(), 1);\n        assert_eq!(root[\"nodes\"].as_array().unwrap().len(), 2);\n\n        let buffer = root[\"buffers\"][0].as_object().unwrap();\n        assert!(buffer.get(\"uri\").is_none());\n        assert!(buffer.get(\"byteLength\").and_then(Value::as_u64).unwrap() > 0);\n    }\n\n    #[test]\n    fn normalizes_zero_length_quaternion_to_identity() {\n        let mut quaternion = [0.0, 0.0, 0.0, 0.0];\n        let replaced = normalize_quaternion(&mut quaternion);\n        assert!(replaced);\n        assert_eq!(quaternion, [1.0, 0.0, 0.0, 0.0]);\n    }\n\n    fn build_gltf_with_accessors(\n        buffer: Vec<u8>,\n        buffer_views: Vec<(usize, usize)>,\n        accessors: Vec<Value>,\n    ) -> gltf::Gltf {\n        let buffer_views_json: Vec<Value> = buffer_views\n            .into_iter()\n            .map(|(byte_offset, byte_length)| {\n                json!({\n                    \"buffer\": 0,\n                    \"byteOffset\": byte_offset,\n                    \"byteLength\": byte_length,\n                })\n            })\n            .collect();\n\n        let root = json!({\n            \"asset\": { \"version\": \"2.0\" },\n            \"buffers\": [\n                { \"byteLength\": buffer.len() }\n            ],\n            \"bufferViews\": buffer_views_json,\n            \"accessors\": accessors,\n        });\n\n        let bytes = serde_json::to_vec(&root).expect(\"failed to serialize glTF\");\n        let gltf = gltf::Gltf::from_slice_without_validation(&bytes)\n            .expect(\"failed to parse glTF\");\n\n        assert!(gltf.blob.is_none());\n        gltf\n    }\n\n    #[test]\n    fn reads_quantized_rotation_scale_opacity() {\n        let rotation_bytes = [127i8, 0, 0, 0].map(|v| v as u8);\n        let scale_bytes = [-127i8, 0, 127].map(|v| v as u8);\n        let opacity_bytes = [128u8];\n\n        let mut buffer = Vec::new();\n        buffer.extend_from_slice(&rotation_bytes);\n        let rotation_offset = 0usize;\n        let scale_offset = buffer.len();\n        buffer.extend_from_slice(&scale_bytes);\n        buffer.push(0);\n        buffer.push(0);\n        buffer.push(0);\n        let opacity_offset = buffer.len();\n        buffer.extend_from_slice(&opacity_bytes);\n        buffer.resize(buffer.len() + 3, 0);\n\n        let gltf = build_gltf_with_accessors(\n            buffer.clone(),\n            vec![\n                (rotation_offset, rotation_bytes.len()),\n                (scale_offset, scale_bytes.len()),\n                (opacity_offset, opacity_bytes.len()),\n            ],\n            vec![\n                json!({\n                    \"bufferView\": 0,\n                    \"componentType\": 5120,\n                    \"count\": 1,\n                    \"type\": \"VEC4\",\n                    \"normalized\": true,\n                }),\n                json!({\n                    \"bufferView\": 1,\n                    \"componentType\": 5120,\n                    \"count\": 1,\n                    \"type\": \"VEC3\",\n                    \"normalized\": true,\n                }),\n                json!({\n                    \"bufferView\": 2,\n                    \"componentType\": 5121,\n                    \"count\": 1,\n                    \"type\": \"SCALAR\",\n                    \"normalized\": true,\n                }),\n            ],\n        );\n\n        let accessors: Vec<_> = gltf.document.accessors().collect();\n        let buffers = vec![buffer];\n\n        let rotations = read_rotation_attribute(&accessors[0], &buffers).unwrap();\n        assert!((rotations[0][0] - 1.0).abs() < 1e-6);\n        assert!(rotations[0][1].abs() < 1e-6);\n        assert!(rotations[0][2].abs() < 1e-6);\n        assert!(rotations[0][3].abs() < 1e-6);\n\n        let scales = read_scale_attribute(&accessors[1], &buffers).unwrap();\n        assert!((scales[0][0] - (-1.0f32).exp()).abs() < 1e-6);\n        assert!((scales[0][1] - 0.0f32.exp()).abs() < 1e-6);\n        assert!((scales[0][2] - 1.0f32.exp()).abs() < 1e-6);\n\n        let opacities = read_opacity_attribute(&accessors[2], &buffers).unwrap();\n        assert!((opacities[0] - (128.0 / 255.0)).abs() < 1e-6);\n    }\n\n    #[test]\n    fn reads_unnormalized_scale_i16() {\n        let scale_values: [i16; 3] = [-2, 0, 2];\n        let mut buffer = Vec::new();\n        for value in scale_values {\n            buffer.extend_from_slice(&value.to_le_bytes());\n        }\n        buffer.resize(buffer.len() + 2, 0);\n\n        let gltf = build_gltf_with_accessors(\n            buffer.clone(),\n            vec![(0, scale_values.len() * std::mem::size_of::<i16>())],\n            vec![json!({\n                \"bufferView\": 0,\n                \"componentType\": 5122,\n                \"count\": 1,\n                \"type\": \"VEC3\",\n            })],\n        );\n\n        let accessors: Vec<_> = gltf.document.accessors().collect();\n        let scales = read_scale_attribute(&accessors[0], &[buffer]).unwrap();\n        assert!((scales[0][0] - (-2.0f32).exp()).abs() < 1e-6);\n        assert!((scales[0][1] - 0.0f32.exp()).abs() < 1e-6);\n        assert!((scales[0][2] - 2.0f32.exp()).abs() < 1e-6);\n    }\n\n    #[test]\n    fn skips_invalid_rotation_gaussians_during_export() {\n        let invalid = Gaussian3d {\n            position_visibility: [0.0, 0.0, 0.0, 1.0].into(),\n            spherical_harmonic:\n                crate::material::spherical_harmonics::SphericalHarmonicCoefficients::default(),\n            rotation: [0.0, 0.0, 0.0, 0.0].into(),\n            scale_opacity: [1.0, 1.0, 1.0, 1.0].into(),\n        };\n        let valid = Gaussian3d {\n            position_visibility: [1.0, 2.0, 3.0, 1.0].into(),\n            spherical_harmonic:\n                crate::material::spherical_harmonics::SphericalHarmonicCoefficients::default(),\n            rotation: [1.0, 0.0, 0.0, 0.0].into(),\n            scale_opacity: [1.0, 1.0, 1.0, 1.0].into(),\n        };\n        let cloud: PlanarGaussian3d = vec![invalid, valid].into();\n\n        let export_cloud = SceneExportCloud {\n            cloud,\n            name: \"cloud\".to_owned(),\n            settings: CloudSettings::default(),\n            transform: Transform::default(),\n            metadata: GaussianPrimitiveMetadata::default(),\n        };\n\n        let bytes = encode_khr_gaussian_scene_gltf_bytes(&[export_cloud], None).unwrap();\n        let root: Value = serde_json::from_slice(&bytes).unwrap();\n        let rotation_accessor_index = root[\"meshes\"][0][\"primitives\"][0][\"attributes\"]\n            [ATTR_ROTATION]\n            .as_u64()\n            .unwrap() as usize;\n        let position_accessor_index = root[\"meshes\"][0][\"primitives\"][0][\"attributes\"]\n            [ATTR_POSITION]\n            .as_u64()\n            .unwrap() as usize;\n\n        assert_eq!(\n            root[\"accessors\"][rotation_accessor_index][\"count\"]\n                .as_u64()\n                .unwrap(),\n            1\n        );\n        assert_eq!(\n            root[\"accessors\"][position_accessor_index][\"count\"]\n                .as_u64()\n                .unwrap(),\n            1\n        );\n    }\n}\n"
  },
  {
    "path": "src/lib.rs",
    "content": "#![allow(incomplete_features)]\n#![cfg_attr(feature = \"nightly_generic_alias\", feature(lazy_type_alias))]\n\nuse bevy::prelude::*;\npub use bevy_interleave::prelude::*;\n\npub use camera::GaussianCamera;\n\npub use gaussian::{\n    formats::{\n        planar_3d::{\n            Gaussian3d, PlanarGaussian3d, PlanarGaussian3dHandle, random_gaussians_3d,\n            random_gaussians_3d_seeded,\n        },\n        planar_4d::{\n            Gaussian4d, PlanarGaussian4d, PlanarGaussian4dHandle, random_gaussians_4d,\n            random_gaussians_4d_seeded,\n        },\n    },\n    settings::{CloudSettings, GaussianMode, RasterizeMode},\n};\n\npub use io::scene::{\n    GaussianKernel, GaussianPrimitiveMetadata, GaussianPrimitiveSpec, GaussianProjection,\n    GaussianScene, GaussianSceneHandle, GaussianSortingMethod, SceneCamera, SceneExportCamera,\n    SceneExportCloud, write_khr_gaussian_scene_glb, write_khr_gaussian_scene_gltf,\n};\n\npub use material::spherical_harmonics::SphericalHarmonicCoefficients;\n\nuse io::IoPlugin;\n\npub mod camera;\npub mod gaussian;\npub mod io;\npub mod material;\npub mod math;\npub mod morph;\npub mod query;\npub mod render;\npub mod sort;\npub mod stream;\npub mod utils;\n\n#[cfg(feature = \"noise\")]\npub mod noise;\n\npub struct GaussianSplattingPlugin;\n\nimpl Plugin for GaussianSplattingPlugin {\n    fn build(&self, app: &mut App) {\n        // TODO: allow hot reloading of Cloud handle through inspector UI\n        app.register_type::<SphericalHarmonicCoefficients>();\n\n        app.add_plugins(IoPlugin);\n\n        app.add_plugins((\n            camera::GaussianCameraPlugin,\n            gaussian::settings::SettingsPlugin,\n            gaussian::cloud::CloudPlugin::<Gaussian3d>::default(),\n            gaussian::cloud::CloudPlugin::<Gaussian4d>::default(),\n        ));\n\n        // TODO: add half types\n        app.add_plugins((\n            PlanarStoragePlugin::<Gaussian3d>::default(),\n            PlanarStoragePlugin::<Gaussian4d>::default(),\n        ));\n\n        app.add_plugins((\n            render::RenderPipelinePlugin::<Gaussian3d>::default(),\n            render::RenderPipelinePlugin::<Gaussian4d>::default(),\n        ));\n\n        app.add_plugins((material::MaterialPlugin, query::QueryPlugin));\n\n        #[cfg(feature = \"noise\")]\n        app.add_plugins(noise::NoisePlugin);\n    }\n}\n"
  },
  {
    "path": "src/lighting/environmental.rs",
    "content": ""
  },
  {
    "path": "src/lighting/mod.rs",
    "content": ""
  },
  {
    "path": "src/material/classification.rs",
    "content": "use bevy::{\n    asset::{load_internal_asset, uuid_handle},\n    prelude::*,\n};\n\nconst CLASSIFICATION_SHADER_HANDLE: Handle<Shader> =\n    uuid_handle!(\"8b453dba-5095-47f2-9c60-ae369fe51579\");\n\npub struct ClassificationMaterialPlugin;\n\nimpl Plugin for ClassificationMaterialPlugin {\n    fn build(&self, app: &mut App) {\n        load_internal_asset!(\n            app,\n            CLASSIFICATION_SHADER_HANDLE,\n            \"classification.wgsl\",\n            Shader::from_wgsl\n        );\n    }\n}\n"
  },
  {
    "path": "src/material/classification.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::classification\n\n#import bevy_render::color_operations::{\n    hsv_to_rgb,\n    rgb_to_hsv,\n}\n#import bevy_gaussian_splatting::bindings::gaussian_uniforms\n\nfn class_to_rgb(\n    visualization: f32,\n    sh_color: vec3<f32>,\n) -> vec3<f32> {\n    if visualization < 2.0 {\n        return sh_color;\n    }\n\n    let class_idx = visualization - 2.0;\n    let hue = (class_idx / f32(gaussian_uniforms.num_classes)) * 6.283185307;\n\n    return mix(\n        sh_color,\n        hsv_to_rgb(\n            vec3<f32>(hue, 1.0, 1.0)\n        ),\n        0.5\n    );\n}\n"
  },
  {
    "path": "src/material/depth.rs",
    "content": "use bevy::{\n    asset::{load_internal_asset, uuid_handle},\n    prelude::*,\n};\n\nconst DEPTH_SHADER_HANDLE: Handle<Shader> = uuid_handle!(\"72e596c7-6226-4366-af26-2acceb34c8a4\");\n\npub struct DepthMaterialPlugin;\n\nimpl Plugin for DepthMaterialPlugin {\n    fn build(&self, app: &mut App) {\n        load_internal_asset!(app, DEPTH_SHADER_HANDLE, \"depth.wgsl\", Shader::from_wgsl);\n    }\n}\n"
  },
  {
    "path": "src/material/depth.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::depth\n\nfn depth_to_rgb(depth: f32, min_depth: f32, max_depth: f32) -> vec3<f32> {\n    let normalized_depth = clamp((depth - min_depth) / (max_depth - min_depth), 0.0, 1.0);\n\n    let r = smoothstep(0.5, 1.0, normalized_depth);\n    let g = 1.0 - abs(normalized_depth - 0.5) * 2.0;\n    let b = 1.0 - smoothstep(0.0, 0.5, normalized_depth);\n\n    return vec3<f32>(r, g, b);\n}\n"
  },
  {
    "path": "src/material/mod.rs",
    "content": "use bevy::prelude::*;\n\npub mod classification;\npub mod depth;\npub mod optical_flow;\npub mod position;\npub mod spherical_harmonics;\npub mod spherindrical_harmonics;\n\n#[cfg(feature = \"material_noise\")]\npub mod noise;\n\n#[derive(Default)]\npub struct MaterialPlugin;\n\nimpl Plugin for MaterialPlugin {\n    #[allow(unused)]\n    fn build(&self, app: &mut App) {\n        #[cfg(feature = \"material_noise\")]\n        app.add_plugins(noise::NoiseMaterialPlugin);\n\n        app.add_plugins((\n            classification::ClassificationMaterialPlugin,\n            depth::DepthMaterialPlugin,\n            optical_flow::OpticalFlowMaterialPlugin,\n            position::PositionMaterialPlugin,\n            spherical_harmonics::SphericalHarmonicCoefficientsPlugin,\n            spherindrical_harmonics::SpherindricalHarmonicCoefficientsPlugin,\n        ));\n    }\n}\n"
  },
  {
    "path": "src/material/noise.rs",
    "content": "use bevy::prelude::*;\nuse bevy_interleave::prelude::{Planar, PlanarHandle};\nuse noise::{NoiseFn, RidgedMulti, Simplex};\n\nuse crate::{PlanarGaussian3dHandle, gaussian::formats::planar_3d::PlanarGaussian3d};\n\n#[derive(Component, Debug, Reflect)]\npub struct NoiseMaterial {\n    pub scale: f32,\n}\n\nimpl Default for NoiseMaterial {\n    fn default() -> Self {\n        Self { scale: 1.0 }\n    }\n}\n\n#[derive(Default)]\npub struct NoiseMaterialPlugin;\n\nimpl Plugin for NoiseMaterialPlugin {\n    fn build(&self, app: &mut App) {\n        app.register_type::<NoiseMaterial>();\n        app.add_systems(Update, apply_noise_cpu);\n    }\n}\n\nfn apply_noise_cpu(\n    mut gaussian_clouds_res: ResMut<Assets<PlanarGaussian3d>>,\n    selections: Query<(&PlanarGaussian3dHandle, &NoiseMaterial), Changed<NoiseMaterial>>,\n) {\n    for (cloud_handle, noise_material) in selections.iter() {\n        let Some(cloud) = gaussian_clouds_res.get_mut(cloud_handle.handle()) else {\n            continue;\n        };\n\n        let rigid_multi = RidgedMulti::<Simplex>::default();\n        let scale = noise_material.scale as f64;\n\n        for index in 0..cloud.len() {\n            let position = cloud.position_visibility[index].position;\n            let x = position[0] as f64 * scale;\n            let y = position[1] as f64 * scale;\n            let z = position[2] as f64 * scale;\n\n            for (coefficient_index, coefficient) in cloud.spherical_harmonic[index]\n                .coefficients\n                .iter_mut()\n                .enumerate()\n            {\n                let noise = rigid_multi.get([x, y, z, coefficient_index as f64]);\n                *coefficient = noise as f32;\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "src/material/noise.wgsl",
    "content": "\n"
  },
  {
    "path": "src/material/optical_flow.rs",
    "content": "use bevy::{\n    asset::{load_internal_asset, uuid_handle},\n    prelude::*,\n};\n\nconst OPTICAL_FLOW_SHADER_HANDLE: Handle<Shader> =\n    uuid_handle!(\"e25fefbf-dd95-46f2-89bb-91175f6bb4a6\");\n\npub struct OpticalFlowMaterialPlugin;\n\nimpl Plugin for OpticalFlowMaterialPlugin {\n    fn build(&self, app: &mut App) {\n        load_internal_asset!(\n            app,\n            OPTICAL_FLOW_SHADER_HANDLE,\n            \"optical_flow.wgsl\",\n            Shader::from_wgsl\n        );\n    }\n}\n"
  },
  {
    "path": "src/material/optical_flow.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::optical_flow\n\n#import bevy_pbr::{\n    forward_io::VertexOutput,\n    prepass_utils,\n}\n#import bevy_render::color_operations::hsv_to_rgb\n#import bevy_render::maths::PI_2\n\n#import bevy_gaussian_splatting::bindings::{\n    globals,\n    previous_view_uniforms,\n    view,\n}\n\nfn calculate_motion_vector(\n    world_position: vec3<f32>,\n    previous_world_position: vec3<f32>,\n) -> vec2<f32> {\n    let world_position_t = vec4<f32>(world_position, 1.0);\n    let previous_world_position_t = vec4<f32>(previous_world_position, 1.0);\n    let clip_position_t = view.unjittered_clip_from_world * world_position_t;\n    let clip_position = clip_position_t.xy / clip_position_t.w;\n    let previous_clip_position_t = previous_view_uniforms.clip_from_world * previous_world_position_t;\n    let previous_clip_position = previous_clip_position_t.xy / previous_clip_position_t.w;\n    // These motion vectors are used as offsets to UV positions and are stored\n    // in the range -1,1 to allow offsetting from the one corner to the\n    // diagonally-opposite corner in UV coordinates, in either direction.\n    // A difference between diagonally-opposite corners of clip space is in the\n    // range -2,2, so this needs to be scaled by 0.5. And the V direction goes\n    // down where clip space y goes up, so y needs to be flipped.\n    return (clip_position - previous_clip_position) * vec2(0.5, -0.5);\n}\n\nfn optical_flow_to_rgb(\n    motion_vector: vec2<f32>,\n) -> vec3<f32> {\n    let flow = motion_vector / globals.delta_time;\n\n    let radius = length(flow);\n    var angle = atan2(flow.y, flow.x);\n    if (angle < 0.0) {\n        angle += PI_2;\n    }\n\n    // let sigma: f32 = 0.15;\n    // let norm_factor = sigma * 2.0;\n    // let m = clamp(radius / norm_factor, 0.0, 1.0);\n    let m = clamp(radius, 0.0, 1.0);\n\n    let rgb = hsv_to_rgb(vec3<f32>(angle, m, 1.0));\n    return rgb;\n}\n\n// TODO: support immediate vs. persistent previous_view, aiding with no-smoothness on the pan-orbit camera (required by cpu sort)\n// TODO: set clear color to white in optical flow render mode\n"
  },
  {
    "path": "src/material/pbr.rs",
    "content": ""
  },
  {
    "path": "src/material/pbr.wgsl",
    "content": "\n"
  },
  {
    "path": "src/material/position.rs",
    "content": "use bevy::{\n    asset::{load_internal_asset, uuid_handle},\n    prelude::*,\n};\n\nconst POSITION_SHADER_HANDLE: Handle<Shader> = uuid_handle!(\"91ad4ad8-5e95-4f30-a262-7d3de4abd5a8\");\n\npub struct PositionMaterialPlugin;\n\nimpl Plugin for PositionMaterialPlugin {\n    fn build(&self, app: &mut App) {\n        load_internal_asset!(\n            app,\n            POSITION_SHADER_HANDLE,\n            \"position.wgsl\",\n            Shader::from_wgsl\n        );\n    }\n}\n"
  },
  {
    "path": "src/material/position.wgsl",
    "content": "\n"
  },
  {
    "path": "src/material/spherical_harmonics.rs",
    "content": "#![allow(dead_code)] // ShaderType derives emit unused check helpers\nuse std::marker::Copy;\n\nuse bevy::{\n    asset::{load_internal_asset, uuid_handle},\n    prelude::*,\n    render::render_resource::ShaderType,\n};\nuse bytemuck::{Pod, Zeroable};\nuse serde::{Deserialize, Serialize, Serializer, ser::SerializeTuple};\n\n// #[cfg(feature = \"f16\")]\n// use half::f16;\n\nuse crate::math::pad_4;\n\nconst SPHERICAL_HARMONICS_SHADER_HANDLE: Handle<Shader> =\n    uuid_handle!(\"879b9cd3-ba20-4030-a8f3-adda0a042ffe\");\n\npub struct SphericalHarmonicCoefficientsPlugin;\n\nimpl Plugin for SphericalHarmonicCoefficientsPlugin {\n    fn build(&self, app: &mut App) {\n        load_internal_asset!(\n            app,\n            SPHERICAL_HARMONICS_SHADER_HANDLE,\n            \"spherical_harmonics.wgsl\",\n            Shader::from_wgsl\n        );\n    }\n}\n\nconst fn num_sh_coefficients(degree: usize) -> usize {\n    if degree == 0 {\n        1\n    } else {\n        2 * degree + 1 + num_sh_coefficients(degree - 1)\n    }\n}\n\n// TODO: let SH_DEGREE be a const generic parameter to SphericalHarmonicCoefficients\n// Prefer the highest enabled SH degree when multiple degree features are active.\n#[cfg(feature = \"sh4\")]\npub const SH_DEGREE: usize = 4;\n\n#[cfg(all(not(feature = \"sh4\"), feature = \"sh3\"))]\npub const SH_DEGREE: usize = 3;\n\n#[cfg(all(not(feature = \"sh4\"), not(feature = \"sh3\"), feature = \"sh2\"))]\npub const SH_DEGREE: usize = 2;\n\n#[cfg(all(\n    not(feature = \"sh4\"),\n    not(feature = \"sh3\"),\n    not(feature = \"sh2\"),\n    feature = \"sh1\"\n))]\npub const SH_DEGREE: usize = 1;\n\n#[cfg(all(\n    not(feature = \"sh4\"),\n    not(feature = \"sh3\"),\n    not(feature = \"sh2\"),\n    not(feature = \"sh1\"),\n    feature = \"sh0\"\n))]\npub const SH_DEGREE: usize = 0;\n\n#[cfg(all(\n    not(feature = \"sh4\"),\n    not(feature = \"sh3\"),\n    not(feature = \"sh2\"),\n    not(feature = \"sh1\"),\n    not(feature = \"sh0\")\n))]\npub const SH_DEGREE: usize = 0;\n\npub const SH_CHANNELS: usize = 3;\npub const SH_COEFF_COUNT_PER_CHANNEL: usize = num_sh_coefficients(SH_DEGREE);\npub const SH_COEFF_COUNT: usize = pad_4(SH_COEFF_COUNT_PER_CHANNEL * SH_CHANNELS);\n\npub const HALF_SH_COEFF_COUNT: usize = SH_COEFF_COUNT / 2;\npub const PADDED_HALF_SH_COEFF_COUNT: usize = pad_4(HALF_SH_COEFF_COUNT);\n\n// #[cfg(feature = \"f16\")]\n// pub const SH_VEC4_PLANES: usize = PADDED_HALF_SH_COEFF_COUNT / 4;\npub const SH_VEC4_PLANES: usize = SH_COEFF_COUNT / 4;\n\n// #[cfg(feature = \"f16\")]\n// #[derive(\n//     Clone,\n//     Copy,\n//     Debug,\n//     PartialEq,\n//     Reflect,\n//     ShaderType,\n//     Pod,\n//     Zeroable,\n//     Serialize,\n//     Deserialize,\n// )]\n// #[repr(C)]\n// pub struct SphericalHarmonicCoefficients {\n//     #[reflect(ignore)]\n//     #[serde(serialize_with = \"coefficients_serializer\", deserialize_with = \"coefficients_deserializer\")]\n//     pub coefficients: [u32; HALF_SH_COEFF_COUNT],\n// }\n\n#[allow(dead_code)]\n#[derive(\n    Clone, Copy, Debug, PartialEq, Reflect, ShaderType, Pod, Zeroable, Serialize, Deserialize,\n)]\n#[repr(C)]\npub struct SphericalHarmonicCoefficients {\n    #[serde(\n        serialize_with = \"coefficients_serializer\",\n        deserialize_with = \"coefficients_deserializer\"\n    )]\n    pub coefficients: [f32; SH_COEFF_COUNT],\n}\n\n// #[cfg(feature = \"f16\")]\n// impl Default for SphericalHarmonicCoefficients {\n//     fn default() -> Self {\n//         Self {\n//             coefficients: [0; HALF_SH_COEFF_COUNT],\n//         }\n//     }\n// }\n\nimpl Default for SphericalHarmonicCoefficients {\n    fn default() -> Self {\n        Self {\n            coefficients: [0.0; SH_COEFF_COUNT],\n        }\n    }\n}\n\nimpl SphericalHarmonicCoefficients {\n    // #[cfg(feature = \"f16\")]\n    // pub fn set(&mut self, index: usize, value: f32) {\n    //     let quantized = f16::from_f32(value).to_bits();\n    //     self.coefficients[index / 2] = match index % 2 {\n    //         0 => (self.coefficients[index / 2] & 0xffff0000) | (quantized as u32),\n    //         1 => (self.coefficients[index / 2] & 0x0000ffff) | ((quantized as u32) << 16),\n    //         _ => unreachable!(),\n    //     };\n    // }\n\n    pub fn set(&mut self, index: usize, value: f32) {\n        self.coefficients[index] = value;\n    }\n}\n\n// #[cfg(feature = \"f16\")]\n// fn coefficients_serializer<S>(n: &[u32; HALF_SH_COEFF_COUNT], s: S) -> Result<S::Ok, S::Error>\n// where\n//     S: Serializer,\n// {\n//     let mut tup = s.serialize_tuple(HALF_SH_COEFF_COUNT)?;\n//     for &x in n.iter() {\n//         tup.serialize_element(&x)?;\n//     }\n\n//     tup.end()\n// }\n\n// #[cfg(feature = \"f16\")]\n// fn coefficients_deserializer<'de, D>(d: D) -> Result<[u32; HALF_SH_COEFF_COUNT], D::Error>\n// where\n//     D: serde::Deserializer<'de>,\n// {\n//     struct CoefficientsVisitor;\n\n//     impl<'de> serde::de::Visitor<'de> for CoefficientsVisitor {\n//         type Value = [u32; HALF_SH_COEFF_COUNT];\n\n//         fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {\n//             formatter.write_str(\"an array of floats\")\n//         }\n\n//         fn visit_seq<A>(self, mut seq: A) -> Result<[u32; HALF_SH_COEFF_COUNT], A::Error>\n//         where\n//             A: serde::de::SeqAccess<'de>,\n//         {\n//             let mut coefficients = [0; HALF_SH_COEFF_COUNT];\n\n//             for (i, coefficient) in coefficients.iter_mut().enumerate().take(SH_COEFF_COUNT) {\n//                 *coefficient = seq\n//                     .next_element()?\n//                     .ok_or_else(|| serde::de::Error::invalid_length(i, &self))?;\n//             }\n//             Ok(coefficients)\n//         }\n//     }\n\n//     d.deserialize_tuple(HALF_SH_COEFF_COUNT, CoefficientsVisitor)\n// }\n\nfn coefficients_serializer<S>(n: &[f32; SH_COEFF_COUNT], s: S) -> Result<S::Ok, S::Error>\nwhere\n    S: Serializer,\n{\n    let mut tup = s.serialize_tuple(SH_COEFF_COUNT)?;\n    for &x in n.iter() {\n        tup.serialize_element(&x)?;\n    }\n\n    tup.end()\n}\n\nfn coefficients_deserializer<'de, D>(d: D) -> Result<[f32; SH_COEFF_COUNT], D::Error>\nwhere\n    D: serde::Deserializer<'de>,\n{\n    struct CoefficientsVisitor;\n\n    impl<'de> serde::de::Visitor<'de> for CoefficientsVisitor {\n        type Value = [f32; SH_COEFF_COUNT];\n\n        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {\n            formatter.write_str(\"an array of floats\")\n        }\n\n        fn visit_seq<A>(self, mut seq: A) -> Result<[f32; SH_COEFF_COUNT], A::Error>\n        where\n            A: serde::de::SeqAccess<'de>,\n        {\n            let mut coefficients = [0.0; SH_COEFF_COUNT];\n            let mut index = 0usize;\n\n            while let Some(value) = seq.next_element()? {\n                if index < SH_COEFF_COUNT {\n                    coefficients[index] = value;\n                }\n                index += 1;\n            }\n            Ok(coefficients)\n        }\n    }\n\n    d.deserialize_seq(CoefficientsVisitor)\n}\n"
  },
  {
    "path": "src/material/spherical_harmonics.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::spherical_harmonics\n\nconst shc = array<f32, 16>(\n    0.28209479177387814,\n    -0.4886025119029199,\n    0.4886025119029199,\n    -0.4886025119029199,\n    1.0925484305920792,\n    -1.0925484305920792,\n    0.31539156525252005,\n    -1.0925484305920792,\n    0.5462742152960396,\n    -0.5900435899266435,\n    2.890611442640554,\n    -0.4570457994644658,\n    0.3731763325901154,\n    -0.4570457994644658,\n    1.445305721320277,\n    -0.5900435899266435,\n);\n\nfn srgb_to_linear(srgb_color: vec3<f32>) -> vec3<f32> {\n    var linear_color: vec3<f32>;\n    for (var i = 0u; i < 3u; i = i + 1u) {\n        if (srgb_color[i] <= 0.04045) {\n            linear_color[i] = srgb_color[i] / 12.92;\n        } else {\n            linear_color[i] = pow((srgb_color[i] + 0.055) / 1.055, 2.4);\n        }\n    }\n    return linear_color;\n}\n\nfn spherical_harmonics_lookup(\n    ray_direction: vec3<f32>,\n    sh: array<f32, #{SH_COEFF_COUNT}>,\n) -> vec3<f32> {\n    let rds = ray_direction * ray_direction;\n    var color = vec3<f32>(0.5);\n\n    color += shc[ 0] * vec3<f32>(sh[0], sh[1], sh[2]);\n\n#if SH_COEFF_COUNT > 11\n    color += shc[ 1] * vec3<f32>(sh[ 3], sh[ 4], sh[ 5]) * ray_direction.y;\n    color += shc[ 2] * vec3<f32>(sh[ 6], sh[ 7], sh[ 8]) * ray_direction.z;\n    color += shc[ 3] * vec3<f32>(sh[ 9], sh[10], sh[11]) * ray_direction.x;\n#endif\n\n#if SH_COEFF_COUNT > 26\n    color += shc[ 4] * vec3<f32>(sh[12], sh[13], sh[14]) * ray_direction.x * ray_direction.y;\n    color += shc[ 5] * vec3<f32>(sh[15], sh[16], sh[17]) * ray_direction.y * ray_direction.z;\n    color += shc[ 6] * vec3<f32>(sh[18], sh[19], sh[20]) * (2.0 * rds.z - rds.x - rds.y);\n    color += shc[ 7] * vec3<f32>(sh[21], sh[22], sh[23]) * ray_direction.x * ray_direction.z;\n    color += shc[ 8] * vec3<f32>(sh[24], sh[25], sh[26]) * (rds.x - rds.y);\n#endif\n\n#if SH_COEFF_COUNT > 47\n    color += shc[ 9] * vec3<f32>(sh[27], sh[28], sh[29]) * ray_direction.y * (3.0 * rds.x - rds.y);\n    color += shc[10] * vec3<f32>(sh[30], sh[31], sh[32]) * ray_direction.x * ray_direction.y * ray_direction.z;\n    color += shc[11] * vec3<f32>(sh[33], sh[34], sh[35]) * ray_direction.y * (4.0 * rds.z - rds.x - rds.y);\n    color += shc[12] * vec3<f32>(sh[36], sh[37], sh[38]) * ray_direction.z * (2.0 * rds.z - 3.0 * rds.x - 3.0 * rds.y);\n    color += shc[13] * vec3<f32>(sh[39], sh[40], sh[41]) * ray_direction.x * (4.0 * rds.z - rds.x - rds.y);\n    color += shc[14] * vec3<f32>(sh[42], sh[43], sh[44]) * ray_direction.z * (rds.x - rds.y);\n    color += shc[15] * vec3<f32>(sh[45], sh[46], sh[47]) * ray_direction.x * (rds.x - 3.0 * rds.y);\n#endif\n\n    return color;\n}\n"
  },
  {
    "path": "src/material/spherindrical_harmonics.rs",
    "content": "#![allow(dead_code)] // ShaderType derives emit unused check helpers\nuse std::marker::Copy;\n\nuse bevy::{\n    asset::{load_internal_asset, uuid_handle},\n    prelude::*,\n    render::render_resource::ShaderType,\n};\nuse bytemuck::{Pod, Zeroable};\nuse serde::{Deserialize, Serialize, Serializer, ser::SerializeTuple};\n\n// #[cfg(feature = \"f16\")]\n// use half::f16;\n\nuse crate::{\n    material::spherical_harmonics::{SH_CHANNELS, SH_DEGREE},\n    math::{gcd, pad_4},\n};\n\npub const SH_4D_DEGREE_TIME: usize = 2;\n\npub const SH_4D_COEFF_COUNT_PER_CHANNEL: usize = (SH_DEGREE + 1).pow(2) * (SH_4D_DEGREE_TIME + 1);\npub const SH_4D_COEFF_COUNT: usize = pad_4(SH_4D_COEFF_COUNT_PER_CHANNEL * SH_CHANNELS);\n\npub const HALF_SH_4D_COEFF_COUNT: usize = pad_4(SH_4D_COEFF_COUNT / 2);\n\n// TODO: calculate POD_PLANE_COUNT for both f16 and f32\npub const MAX_POD_U32_ARRAY_SIZE: usize = 32;\npub const POD_ARRAY_SIZE: usize = gcd(SH_4D_COEFF_COUNT, MAX_POD_U32_ARRAY_SIZE);\npub const POD_PLANE_COUNT: usize = SH_4D_COEFF_COUNT / POD_ARRAY_SIZE;\n\npub const WASTE: usize = POD_PLANE_COUNT * POD_ARRAY_SIZE - SH_4D_COEFF_COUNT;\nstatic_assertions::const_assert_eq!(WASTE, 0);\n\n// #[cfg(feature = \"f16\")]\n// pub const SH_4D_VEC4_PLANES: usize = HALF_SH_4D_COEFF_COUNT / 4;\npub const SH_4D_VEC4_PLANES: usize = SH_4D_COEFF_COUNT / 4;\n\nconst SPHERINDRICAL_HARMONICS_SHADER_HANDLE: Handle<Shader> =\n    uuid_handle!(\"0b379c3c-daa3-48c5-bf4b-0262b9941a0a\");\n\npub struct SpherindricalHarmonicCoefficientsPlugin;\nimpl Plugin for SpherindricalHarmonicCoefficientsPlugin {\n    fn build(&self, app: &mut App) {\n        load_internal_asset!(\n            app,\n            SPHERINDRICAL_HARMONICS_SHADER_HANDLE,\n            \"spherindrical_harmonics.wgsl\",\n            Shader::from_wgsl\n        );\n    }\n}\n\n// #[cfg(feature = \"f16\")]\n// #[derive(\n//     Clone,\n//     Copy,\n//     Debug,\n//     PartialEq,\n//     Reflect,\n//     ShaderType,\n//     Pod,\n//     Zeroable,\n//     Serialize,\n//     Deserialize,\n// )]\n// #[repr(C)]\n// pub struct SpherindricalHarmonicCoefficients {\n//     #[reflect(ignore)]\n//     #[serde(serialize_with = \"coefficients_serializer\", deserialize_with = \"coefficients_deserializer\")]\n//     pub coefficients: [[u32; POD_ARRAY_SIZE]; POD_PLANE_COUNT],\n// }\n\n#[allow(dead_code)]\n#[derive(\n    Clone, Copy, Debug, PartialEq, Reflect, ShaderType, Pod, Zeroable, Serialize, Deserialize,\n)]\n#[repr(C)]\npub struct SpherindricalHarmonicCoefficients {\n    #[serde(\n        serialize_with = \"coefficients_serializer\",\n        deserialize_with = \"coefficients_deserializer\"\n    )]\n    pub coefficients: [[f32; POD_ARRAY_SIZE]; POD_PLANE_COUNT],\n}\n\n// #[cfg(feature = \"f16\")]\n// impl Default for SpherindricalHarmonicCoefficients {\n//     fn default() -> Self {\n//         Self {\n//             coefficients: [[0; POD_ARRAY_SIZE]; POD_PLANE_COUNT],\n//         }\n//     }\n// }\n\nimpl Default for SpherindricalHarmonicCoefficients {\n    fn default() -> Self {\n        Self {\n            coefficients: [[0.0; POD_ARRAY_SIZE]; POD_PLANE_COUNT],\n        }\n    }\n}\n\nimpl From<[f32; SH_4D_COEFF_COUNT]> for SpherindricalHarmonicCoefficients {\n    fn from(flat_coefficients: [f32; SH_4D_COEFF_COUNT]) -> Self {\n        let mut coefficients = [[0.0; POD_ARRAY_SIZE]; POD_PLANE_COUNT];\n\n        for (i, coefficient) in flat_coefficients.iter().enumerate() {\n            coefficients[i / POD_ARRAY_SIZE][i % POD_ARRAY_SIZE] = *coefficient;\n        }\n\n        Self { coefficients }\n    }\n}\n\nimpl SpherindricalHarmonicCoefficients {\n    // #[cfg(feature = \"f16\")]\n    // pub fn set(&mut self, index: usize, value: f32) {\n    //     let quantized = f16::from_f32(value).to_bits();\n    //     let pair_index = index / 2;\n    //     let pod_index = pair_index / POD_ARRAY_SIZE;\n    //     let pod_offset = pair_index % POD_ARRAY_SIZE;\n\n    //     self.coefficients[pod_index][pod_offset] = match index % 2 {\n    //         0 => (self.coefficients[pod_index][pod_offset] & 0xffff0000) | (quantized as u32),\n    //         1 => (self.coefficients[pod_index][pod_offset] & 0x0000ffff) | ((quantized as u32) << 16),\n    //         _ => unreachable!(),\n    //     };\n    // }\n\n    pub fn set(&mut self, index: usize, value: f32) {\n        let pod_index = index / POD_ARRAY_SIZE;\n        let pod_offset = index % POD_ARRAY_SIZE;\n\n        self.coefficients[pod_index][pod_offset] = value;\n    }\n}\n\n// #[cfg(feature = \"f16\")]\n// fn coefficients_serializer<S>(n: &[[u32; POD_ARRAY_SIZE]; POD_PLANE_COUNT], s: S) -> Result<S::Ok, S::Error>\n// where\n//     S: Serializer,\n// {\n//     let mut tup = s.serialize_tuple(HALF_SH_4D_COEFF_COUNT)?;\n//     for &x in n.iter() {\n//         tup.serialize_element(&x)?;\n//     }\n\n//     tup.end()\n// }\n\n// #[cfg(feature = \"f16\")]\n// fn coefficients_deserializer<'de, D>(d: D) -> Result<[[u32; POD_ARRAY_SIZE]; POD_PLANE_COUNT], D::Error>\n// where\n//     D: serde::Deserializer<'de>,\n// {\n//     struct CoefficientsVisitor;\n\n//     impl<'de> serde::de::Visitor<'de> for CoefficientsVisitor {\n//         type Value = [[u32; POD_ARRAY_SIZE]; POD_PLANE_COUNT];\n\n//         fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {\n//             formatter.write_str(\"an array of floats\")\n//         }\n\n//         fn visit_seq<A>(self, mut seq: A) -> Result<[[u32; POD_ARRAY_SIZE]; POD_PLANE_COUNT], A::Error>\n//         where\n//             A: serde::de::SeqAccess<'de>,\n//         {\n//             let mut coefficients = [[0; POD_ARRAY_SIZE]; POD_PLANE_COUNT];\n\n//             for (i, coefficient) in coefficients.iter_mut().enumerate().take(SH_4D_COEFF_COUNT) {\n//                 *coefficient = seq\n//                     .next_element()?\n//                     .ok_or_else(|| serde::de::Error::invalid_length(i, &self))?;\n//             }\n//             Ok(coefficients)\n//         }\n//     }\n\n//     d.deserialize_tuple(HALF_SH_4D_COEFF_COUNT, CoefficientsVisitor)\n// }\n\nfn coefficients_serializer<S>(\n    n: &[[f32; POD_ARRAY_SIZE]; POD_PLANE_COUNT],\n    s: S,\n) -> Result<S::Ok, S::Error>\nwhere\n    S: Serializer,\n{\n    let mut tup = s.serialize_tuple(SH_4D_COEFF_COUNT)?;\n    for &x in n.iter() {\n        tup.serialize_element(&x)?;\n    }\n\n    tup.end()\n}\n\nfn coefficients_deserializer<'de, D>(\n    d: D,\n) -> Result<[[f32; POD_ARRAY_SIZE]; POD_PLANE_COUNT], D::Error>\nwhere\n    D: serde::Deserializer<'de>,\n{\n    struct CoefficientsVisitor;\n\n    impl<'de> serde::de::Visitor<'de> for CoefficientsVisitor {\n        type Value = [[f32; POD_ARRAY_SIZE]; POD_PLANE_COUNT];\n\n        fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {\n            formatter.write_str(\"an array of floats\")\n        }\n\n        fn visit_seq<A>(\n            self,\n            mut seq: A,\n        ) -> Result<[[f32; POD_ARRAY_SIZE]; POD_PLANE_COUNT], A::Error>\n        where\n            A: serde::de::SeqAccess<'de>,\n        {\n            let mut coefficients = [[0.0; POD_ARRAY_SIZE]; POD_PLANE_COUNT];\n\n            for (i, coefficient) in coefficients.iter_mut().enumerate().take(SH_4D_COEFF_COUNT) {\n                *coefficient = seq\n                    .next_element()?\n                    .ok_or_else(|| serde::de::Error::invalid_length(i, &self))?;\n            }\n            Ok(coefficients)\n        }\n    }\n\n    d.deserialize_tuple(SH_4D_COEFF_COUNT, CoefficientsVisitor)\n}\n"
  },
  {
    "path": "src/material/spherindrical_harmonics.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::spherindrical_harmonics\n\n#import bevy_gaussian_splatting::bindings::gaussian_uniforms\n#import bevy_gaussian_splatting::spherical_harmonics::{\n    shc,\n    spherical_harmonics_lookup,\n}\n\nconst PI = radians(180.0);\n\nfn spherindrical_harmonics_lookup(\n    ray_direction: vec3<f32>,\n    dir_t: f32,\n    sh: array<f32, #{SH_4D_COEFF_COUNT}>,\n) -> vec3<f32> {\n    let rds = ray_direction * ray_direction;\n\n    var color = vec3<f32>(0.5);\n\n    // TODO: reinterpret sh as vec3<f32>\n    color += shc[ 0] * vec3<f32>(sh[0], sh[1], sh[2]);\n\n#if SH_DEGREE > 0\n    let x = ray_direction.x;\n    let y = ray_direction.y;\n    let z = ray_direction.z;\n\n    let l1m1 = shc[1] * y;\n    let l1m0 = shc[2] * z;\n    let l1p1 = shc[3] * x;\n\n    color += l1m1 * vec3<f32>(sh[ 3], sh[ 4], sh[ 5]);\n    color += l1m0 * vec3<f32>(sh[ 6], sh[ 7], sh[ 8]);\n    color += l1p1 * vec3<f32>(sh[ 9], sh[10], sh[11]);\n#endif\n\n#if SH_DEGREE > 1\n    let xx = x * x;\n    let yy = y * y;\n    let zz = z * z;\n    let xy = x * y;\n    let xz = x * z;\n    let yz = y * z;\n\n    let l2m2 = shc[4] * xy;\n    let l2m1 = shc[5] * yz;\n    let l2m0 = shc[6] * (2.0 * zz - xx - yy);\n    let l2p1 = shc[7] * xz;\n    let l2p2 = shc[8] * (xx - yy);\n\n    color += l2m2 * vec3<f32>(sh[12], sh[13], sh[14]);\n    color += l2m1 * vec3<f32>(sh[15], sh[16], sh[17]);\n    color += l2m0 * vec3<f32>(sh[18], sh[19], sh[20]);\n    color += l2p1 * vec3<f32>(sh[21], sh[22], sh[23]);\n    color += l2p2 * vec3<f32>(sh[24], sh[25], sh[26]);\n#endif\n\n#if SH_DEGREE > 2\n    let l3m3 = shc[9] * y * (3.0 * xx - yy);\n    let l3m2 = shc[10] * z * xy;\n    let l3m1 = shc[11] * y * (4.0 * zz - xx - yy);\n    let l3m0 = shc[12] * z * (2.0 * zz - 3.0 * xx - 3.0 * yy);\n    let l3p1 = shc[13] * x * (4.0 * zz - xx - yy);\n    let l3p2 = shc[14] * z * (xx - yy);\n    let l3p3 = shc[15] * x * (xx - 3.0 * yy);\n\n    color += l3m3 * vec3<f32>(sh[27], sh[28], sh[29]);\n    color += l3m2 * vec3<f32>(sh[30], sh[31], sh[32]);\n    color += l3m1 * vec3<f32>(sh[33], sh[34], sh[35]);\n    color += l3m0 * vec3<f32>(sh[36], sh[37], sh[38]);\n    color += l3p1 * vec3<f32>(sh[39], sh[40], sh[41]);\n    color += l3p2 * vec3<f32>(sh[42], sh[43], sh[44]);\n    color += l3p3 * vec3<f32>(sh[45], sh[46], sh[47]);\n#endif\n\n#if SH_DEGREE_TIME > 0\n    let duration = gaussian_uniforms.time_stop - gaussian_uniforms.time_start;\n    let theta = dir_t / duration;\n\n    let t1 = cos(2.0 * PI * theta);\n\n    let l0m0 = shc[0];\n\n    color += t1 * (\n        l0m0 * vec3<f32>(sh[48], sh[49], sh[50]) +\n        l1m1 * vec3<f32>(sh[51], sh[52], sh[53]) +\n        l1m0 * vec3<f32>(sh[54], sh[55], sh[56]) +\n        l1p1 * vec3<f32>(sh[57], sh[58], sh[59]) +\n        l2m2 * vec3<f32>(sh[60], sh[61], sh[62]) +\n        l2m1 * vec3<f32>(sh[63], sh[64], sh[65]) +\n        l2m0 * vec3<f32>(sh[66], sh[67], sh[68]) +\n        l2p1 * vec3<f32>(sh[69], sh[70], sh[71]) +\n        l2p2 * vec3<f32>(sh[72], sh[73], sh[74]) +\n        l3m3 * vec3<f32>(sh[75], sh[76], sh[77]) +\n        l3m2 * vec3<f32>(sh[78], sh[79], sh[80]) +\n        l3m1 * vec3<f32>(sh[81], sh[82], sh[83]) +\n        l3m0 * vec3<f32>(sh[84], sh[85], sh[86]) +\n        l3p1 * vec3<f32>(sh[87], sh[88], sh[89]) +\n        l3p2 * vec3<f32>(sh[90], sh[91], sh[92]) +\n        l3p3 * vec3<f32>(sh[93], sh[94], sh[95])\n    );\n\n    #if SH_DEGREE_TIME > 1\n        let t2 = cos(4.0 * PI * theta);\n\n        color += t1 * (\n            l0m0 * vec3<f32>(sh[ 96], sh[ 97], sh[ 98]) +\n            l1m1 * vec3<f32>(sh[ 99], sh[100], sh[101]) +\n            l1m0 * vec3<f32>(sh[102], sh[103], sh[104]) +\n            l1p1 * vec3<f32>(sh[105], sh[106], sh[107]) +\n            l2m2 * vec3<f32>(sh[108], sh[109], sh[110]) +\n            l2m1 * vec3<f32>(sh[111], sh[112], sh[113]) +\n            l2m0 * vec3<f32>(sh[114], sh[115], sh[116]) +\n            l2p1 * vec3<f32>(sh[117], sh[118], sh[119]) +\n            l2p2 * vec3<f32>(sh[120], sh[121], sh[122]) +\n            l3m3 * vec3<f32>(sh[123], sh[124], sh[125]) +\n            l3m2 * vec3<f32>(sh[126], sh[127], sh[128]) +\n            l3m1 * vec3<f32>(sh[129], sh[130], sh[131]) +\n            l3m0 * vec3<f32>(sh[132], sh[133], sh[134]) +\n            l3p1 * vec3<f32>(sh[135], sh[136], sh[137]) +\n            l3p2 * vec3<f32>(sh[138], sh[139], sh[140]) +\n            l3p3 * vec3<f32>(sh[141], sh[142], sh[143])\n        );\n    #endif\n#endif\n\n    return color;\n}\n"
  },
  {
    "path": "src/math/mod.rs",
    "content": "pub const fn gcd(a: usize, b: usize) -> usize {\n    if b == 0 { a } else { gcd(b, a % b) }\n}\n\npub const fn pad_4(x: usize) -> usize {\n    (x + 3) & !3\n}\n"
  },
  {
    "path": "src/morph/interpolate.rs",
    "content": "use std::{any::TypeId, marker::PhantomData};\n\nuse bevy::{\n    asset::{Assets, LoadState, load_internal_asset, uuid_handle},\n    core_pipeline::{\n        core_3d::graph::{Core3d, Node3d},\n        prepass::PreviousViewUniformOffset,\n    },\n    prelude::*,\n    render::{\n        Extract, ExtractSchedule, Render, RenderApp, RenderSystems,\n        extract_component::DynamicUniformIndex,\n        render_asset::RenderAssets,\n        render_graph::{Node, NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel},\n        render_resource::{\n            BindGroup, BindGroupLayout, CachedComputePipelineId, CachedPipelineState,\n            ComputePassDescriptor, ComputePipelineDescriptor, PipelineCache,\n        },\n        renderer::{RenderContext, RenderDevice},\n        sync_world::{RenderEntity, SyncToRenderWorld},\n        view::ViewUniformOffset,\n    },\n};\nuse bevy_interleave::prelude::*;\n\nuse crate::{\n    camera::GaussianCamera,\n    gaussian::formats::planar_3d::{Gaussian3d, PlanarGaussian3d, PlanarGaussian3dHandle},\n    render::{\n        CloudPipeline, CloudPipelineKey, CloudUniform, GaussianComputeViewBindGroup,\n        GaussianUniformBindGroups, PlanarStorageRebindQueue, shader_defs,\n        storage_layout_descriptor,\n    },\n};\n\nconst INTERPOLATE_SHADER_HANDLE: Handle<Shader> =\n    uuid_handle!(\"b0b03f7e-9ec2-4e7d-bc96-3ddc1a8c5942\");\nconst WORKGROUP_SIZE: u32 = 256;\n\n#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]\npub struct InterpolateLabel;\n\npub struct InterpolatePlugin<R: PlanarSync> {\n    phantom: PhantomData<fn() -> R>,\n}\n\nimpl<R: PlanarSync> Default for InterpolatePlugin<R> {\n    fn default() -> Self {\n        Self {\n            phantom: PhantomData,\n        }\n    }\n}\n\nimpl<R> Plugin for InterpolatePlugin<R>\nwhere\n    R: PlanarSync + Send + Sync + 'static,\n    R::GpuPlanarType: GpuPlanarStorage,\n    <R::GpuPlanarType as GpuPlanar>::PackedType: ReflectInterleaved,\n{\n    fn build(&self, app: &mut App) {\n        if TypeId::of::<R::PlanarType>() != TypeId::of::<PlanarGaussian3d>() {\n            return;\n        }\n\n        load_internal_asset!(\n            app,\n            INTERPOLATE_SHADER_HANDLE,\n            \"interpolate.wgsl\",\n            Shader::from_wgsl\n        );\n\n        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n            render_app\n                .add_render_graph_node::<GaussianInterpolateNode<R>>(Core3d, InterpolateLabel)\n                .add_render_graph_edge(Core3d, InterpolateLabel, Node3d::LatePrepass)\n                .add_systems(ExtractSchedule, extract_gaussian_interpolate::<R>)\n                .add_systems(\n                    Render,\n                    (queue_gaussian_interpolate_bind_groups::<R>.in_set(RenderSystems::Queue),),\n                );\n        }\n\n        app.add_systems(PostUpdate, ensure_gaussian_interpolate_synced::<R>);\n        app.add_systems(PostUpdate, ensure_gaussian_interpolate_output_gaussian3d);\n    }\n\n    fn finish(&self, app: &mut App) {\n        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n            render_app.init_resource::<GaussianInterpolatePipeline<R>>();\n        }\n    }\n}\n\nfn ensure_gaussian_interpolate_synced<R: PlanarSync>(\n    mut commands: Commands,\n    query: Query<(Entity, Option<&SyncToRenderWorld>), With<GaussianInterpolate<R>>>,\n) {\n    for (entity, sync_tag) in &query {\n        if sync_tag.is_none() {\n            debug!(\n                ?entity,\n                \"adding SyncToRenderWorld to GaussianInterpolate entity\"\n            );\n            commands.entity(entity).insert(SyncToRenderWorld);\n        }\n    }\n}\n\nfn ensure_gaussian_interpolate_output_gaussian3d(\n    mut commands: Commands,\n    mut planar_assets: ResMut<Assets<PlanarGaussian3d>>,\n    mut rebind_queue: ResMut<PlanarStorageRebindQueue<Gaussian3d>>,\n    query: Query<(\n        Entity,\n        &GaussianInterpolate<Gaussian3d>,\n        Option<&PlanarGaussian3dHandle>,\n    )>,\n) {\n    for (entity, interpolate, existing_output) in &query {\n        if existing_output.is_some() {\n            continue;\n        }\n\n        let lhs_handle = interpolate.lhs.handle();\n        let Some(cloned_asset) = planar_assets\n            .get(lhs_handle)\n            .map(|asset| asset.iter().collect::<PlanarGaussian3d>())\n        else {\n            debug!(\n                ?entity,\n                \"lhs planar asset not available for GaussianInterpolate output\"\n            );\n            continue;\n        };\n\n        let output_handle_raw = planar_assets.add(cloned_asset);\n        let output_handle = PlanarGaussian3dHandle(output_handle_raw.clone());\n\n        debug!(?entity, asset_id = ?output_handle_raw.id(), \"initialized GaussianInterpolate output asset from lhs\");\n\n        rebind_queue.push_unique(output_handle_raw.id());\n        commands.entity(entity).insert(output_handle);\n    }\n}\n\n#[derive(Component, Debug, Default, Reflect)]\n#[reflect(Component)]\npub struct GaussianInterpolate<R: PlanarSync> {\n    pub lhs: R::PlanarTypeHandle,\n    pub rhs: R::PlanarTypeHandle,\n}\n\nimpl<R: PlanarSync> Clone for GaussianInterpolate<R>\nwhere\n    R::PlanarTypeHandle: Clone,\n{\n    fn clone(&self) -> Self {\n        Self {\n            lhs: self.lhs.clone(),\n            rhs: self.rhs.clone(),\n        }\n    }\n}\n#[derive(Component)]\npub struct GaussianInterpolateBindGroups<R: PlanarSync> {\n    pub lhs: BindGroup,\n    pub rhs: BindGroup,\n    pub output: BindGroup,\n    phantom: PhantomData<fn() -> R>,\n}\n\n#[derive(Resource)]\npub struct GaussianInterpolatePipeline<R: PlanarSync> {\n    pub output_layout: BindGroupLayout,\n    pub interpolate_pipeline: CachedComputePipelineId,\n    phantom: PhantomData<fn() -> R>,\n}\n\nimpl<R: PlanarSync> FromWorld for GaussianInterpolatePipeline<R>\nwhere\n    R::GpuPlanarType: GpuPlanarStorage,\n    <R::GpuPlanarType as GpuPlanar>::PackedType: ReflectInterleaved,\n{\n    fn from_world(render_world: &mut World) -> Self {\n        let render_device = render_world.resource::<RenderDevice>();\n        let gaussian_cloud_pipeline = render_world.resource::<CloudPipeline<R>>();\n        let pipeline_cache = render_world.resource::<PipelineCache>();\n\n        let output_layout = R::GpuPlanarType::bind_group_layout(render_device, false);\n        let output_layout_desc = storage_layout_descriptor::<\n            <R::GpuPlanarType as GpuPlanar>::PackedType,\n        >(\"gaussian_interpolate_output_layout\", false);\n\n        let key = CloudPipelineKey {\n            binary_gaussian_op: true,\n            ..Default::default()\n        };\n        let shader_defs = shader_defs(key);\n\n        let interpolate_pipeline =\n            pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {\n                label: Some(\"gaussian_interpolate_pipeline\".into()),\n                layout: vec![\n                    gaussian_cloud_pipeline.compute_view_layout_desc.clone(),\n                    gaussian_cloud_pipeline.gaussian_uniform_layout_desc.clone(),\n                    gaussian_cloud_pipeline.gaussian_cloud_layout_desc.clone(),\n                    gaussian_cloud_pipeline.gaussian_cloud_layout_desc.clone(),\n                    output_layout_desc,\n                ],\n                push_constant_ranges: vec![],\n                shader: INTERPOLATE_SHADER_HANDLE,\n                shader_defs,\n                entry_point: Some(\"interpolate_gaussians\".into()),\n                zero_initialize_workgroup_memory: true,\n            });\n\n        Self {\n            output_layout,\n            interpolate_pipeline,\n            phantom: PhantomData,\n        }\n    }\n}\n\npub fn extract_gaussian_interpolate<R>(\n    mut commands: Commands,\n    query: Extract<Query<(RenderEntity, &GaussianInterpolate<R>)>>,\n) where\n    R: PlanarSync,\n    R::PlanarTypeHandle: Clone,\n{\n    let mut extracted: Vec<(Entity, (GaussianInterpolate<R>,))> = Vec::new();\n\n    for (render_entity, component) in query.iter() {\n        debug!(?render_entity, \"queueing GaussianInterpolate extraction\");\n        extracted.push((render_entity, (component.clone(),)));\n    }\n\n    if extracted.is_empty() {\n        debug!(\"no GaussianInterpolate components extracted this frame\");\n    } else {\n        let count = extracted.len();\n        debug!(\n            count,\n            \"inserting GaussianInterpolate components into render world\"\n        );\n        for (entity, bundle) in extracted {\n            match commands.get_entity(entity) {\n                Ok(mut entity_cmd) => {\n                    entity_cmd.insert(bundle);\n                }\n                Err(_) => {\n                    debug!(\n                        ?entity,\n                        \"skipping GaussianInterpolate insertion; render entity missing\"\n                    );\n                }\n            }\n        }\n    }\n}\n\n#[allow(clippy::too_many_arguments, clippy::type_complexity)]\npub fn queue_gaussian_interpolate_bind_groups<R: PlanarSync>(\n    mut commands: Commands,\n    interpolate_pipeline: Res<GaussianInterpolatePipeline<R>>,\n    gaussian_cloud_pipeline: Res<CloudPipeline<R>>,\n    render_device: Res<RenderDevice>,\n    asset_server: Res<AssetServer>,\n    gpu_planars: Res<RenderAssets<R::GpuPlanarType>>,\n    mut rebind_queue: ResMut<PlanarStorageRebindQueue<R>>,\n    mut query: Query<(\n        Entity,\n        Ref<GaussianInterpolate<R>>,\n        &R::PlanarTypeHandle,\n        Option<&GaussianInterpolateBindGroups<R>>,\n    )>,\n) where\n    R::GpuPlanarType: GpuPlanarStorage,\n{\n    let inputs_changed = gaussian_cloud_pipeline.is_changed() || gpu_planars.is_changed();\n    let mut pending_inserts: Vec<(Entity, GaussianInterpolateBindGroups<R>)> = Vec::new();\n\n    for (entity, interpolate, output_handle, existing) in query.iter_mut() {\n        let mut rebuild = inputs_changed || interpolate.is_changed();\n        if existing.is_none() {\n            rebuild = true;\n        }\n\n        if !rebuild {\n            debug!(\n                ?entity,\n                \"GaussianInterpolate bind groups unchanged; skipping\"\n            );\n            continue;\n        }\n\n        let lhs_handle = interpolate.lhs.handle().clone();\n        let rhs_handle = interpolate.rhs.handle().clone();\n        let output_asset_handle = output_handle.handle().clone();\n\n        let mut ready = true;\n        for (label, handle) in [\n            (\"lhs\", &lhs_handle),\n            (\"rhs\", &rhs_handle),\n            (\"output\", &output_asset_handle),\n        ] {\n            // Assets created at runtime (like the interpolation output) are not tracked by the AssetServer, so\n            // `get_load_state` returns `None` even though the data is ready. Treat `None` as ready and only block\n            // while the server explicitly reports a non-loaded state.\n            if let Some(load_state) = asset_server.get_load_state(handle.id())\n                && !matches!(load_state, LoadState::Loaded)\n            {\n                debug!(\n                    ?entity,\n                    handle_label = label,\n                    ?load_state,\n                    \"waiting for GaussianInterpolate asset load\"\n                );\n                ready = false;\n                break;\n            }\n\n            if gpu_planars.get(handle.id()).is_none() {\n                debug!(\n                    ?entity,\n                    handle_label = label,\n                    \"GaussianInterpolate GPU asset not ready\"\n                );\n                ready = false;\n                break;\n            }\n        }\n\n        if !ready {\n            debug!(?entity, \"deferring GaussianInterpolate bind group rebuild\");\n            continue;\n        }\n\n        rebind_queue.push_unique(output_asset_handle.id());\n\n        let lhs_gpu = gpu_planars.get(lhs_handle.id()).unwrap();\n        let rhs_gpu = gpu_planars.get(rhs_handle.id()).unwrap();\n        let output_gpu = gpu_planars.get(output_asset_handle.id()).unwrap();\n\n        let lhs_bind_group = lhs_gpu.bind_group(\n            render_device.as_ref(),\n            &gaussian_cloud_pipeline.gaussian_cloud_layout,\n        );\n        let rhs_bind_group = rhs_gpu.bind_group(\n            render_device.as_ref(),\n            &gaussian_cloud_pipeline.gaussian_cloud_layout,\n        );\n        let output_bind_group =\n            output_gpu.bind_group(render_device.as_ref(), &interpolate_pipeline.output_layout);\n\n        let gaussian_count = output_gpu.len();\n        debug!(\n            ?entity,\n            gaussian_count, \"queued GaussianInterpolate bind groups\"\n        );\n\n        pending_inserts.push((\n            entity,\n            GaussianInterpolateBindGroups::<R> {\n                lhs: lhs_bind_group,\n                rhs: rhs_bind_group,\n                output: output_bind_group,\n                phantom: PhantomData,\n            },\n        ));\n    }\n\n    if pending_inserts.is_empty() {\n        debug!(\"no GaussianInterpolate bind groups queued this frame\");\n    } else {\n        let count = pending_inserts.len();\n        debug!(\n            count,\n            \"inserted GaussianInterpolate bind groups into render world\"\n        );\n        commands.try_insert_batch(pending_inserts);\n    }\n}\n\n#[allow(clippy::type_complexity)]\npub struct GaussianInterpolateNode<R: PlanarSync> {\n    gaussian_clouds: QueryState<(\n        &'static GaussianInterpolate<R>,\n        &'static GaussianInterpolateBindGroups<R>,\n        &'static DynamicUniformIndex<CloudUniform>,\n        &'static R::PlanarTypeHandle,\n    )>,\n    view_bind_group: QueryState<(\n        &'static GaussianCamera,\n        &'static GaussianComputeViewBindGroup,\n        &'static ViewUniformOffset,\n        &'static PreviousViewUniformOffset,\n    )>,\n    initialized: bool,\n    phantom: PhantomData<fn() -> R>,\n}\n\nimpl<R: PlanarSync> FromWorld for GaussianInterpolateNode<R> {\n    fn from_world(world: &mut World) -> Self {\n        Self {\n            gaussian_clouds: world.query(),\n            view_bind_group: world.query(),\n            initialized: false,\n            phantom: PhantomData,\n        }\n    }\n}\n\nimpl<R: PlanarSync> Node for GaussianInterpolateNode<R>\nwhere\n    R::GpuPlanarType: GpuPlanarStorage,\n{\n    fn update(&mut self, world: &mut World) {\n        let pipeline = world.resource::<GaussianInterpolatePipeline<R>>();\n        let pipeline_cache = world.resource::<PipelineCache>();\n\n        if !self.initialized {\n            match pipeline_cache.get_compute_pipeline_state(pipeline.interpolate_pipeline) {\n                CachedPipelineState::Ok(_) => {\n                    self.initialized = true;\n                    debug!(\"GaussianInterpolate pipeline ready\");\n                }\n                state => {\n                    debug!(\n                        ?state,\n                        \"GaussianInterpolate pipeline not ready; skipping update\"\n                    );\n                    return;\n                }\n            }\n        }\n\n        debug!(\"updating GaussianInterpolate query archetypes\");\n        self.gaussian_clouds.update_archetypes(world);\n        self.view_bind_group.update_archetypes(world);\n    }\n\n    fn run(\n        &self,\n        _graph: &mut RenderGraphContext,\n        render_context: &mut RenderContext,\n        world: &World,\n    ) -> Result<(), NodeRunError> {\n        if !self.initialized {\n            debug!(\"GaussianInterpolateNode run skipped: pipeline not initialized\");\n            return Ok(());\n        }\n\n        let pipeline_cache = world.resource::<PipelineCache>();\n        let pipeline = world.resource::<GaussianInterpolatePipeline<R>>();\n        let gaussian_uniforms = world.resource::<GaussianUniformBindGroups>();\n        let Some(uniform_bind_group) = gaussian_uniforms.base_bind_group.as_ref() else {\n            debug!(\"GaussianInterpolateNode run skipped: GaussianUniform base bind group missing\");\n            return Ok(());\n        };\n\n        let gpu_planars = world.resource::<RenderAssets<R::GpuPlanarType>>();\n\n        let command_encoder = render_context.command_encoder();\n\n        debug!(\"GaussianInterpolateNode run starting\");\n\n        for (_camera, view_bind_group, view_uniform_offset, previous_view_uniform_offset) in\n            self.view_bind_group.iter_manual(world)\n        {\n            for (_interpolate, bind_groups, cloud_uniform_index, output_handle) in\n                self.gaussian_clouds.iter_manual(world)\n            {\n                let output_asset_id = output_handle.handle().id();\n                let Some(output_gpu) = gpu_planars.get(output_handle.handle()) else {\n                    debug!(output_asset_id = ?output_asset_id, \"GaussianInterpolate output GPU asset missing\");\n                    continue;\n                };\n\n                let gaussian_count = output_gpu.len() as u32;\n                if gaussian_count == 0 {\n                    debug!(output_asset_id = ?output_asset_id, \"GaussianInterpolate output has no gaussians; skipping dispatch\");\n                    continue;\n                }\n\n                let workgroups = gaussian_count.div_ceil(WORKGROUP_SIZE);\n                let pipeline_id = pipeline_cache\n                    .get_compute_pipeline(pipeline.interpolate_pipeline)\n                    .unwrap();\n\n                let mut pass =\n                    command_encoder.begin_compute_pass(&ComputePassDescriptor::default());\n\n                pass.set_pipeline(pipeline_id);\n                pass.set_bind_group(\n                    0,\n                    &view_bind_group.value,\n                    &[\n                        view_uniform_offset.offset,\n                        previous_view_uniform_offset.offset,\n                    ],\n                );\n                pass.set_bind_group(1, uniform_bind_group, &[cloud_uniform_index.index()]);\n                pass.set_bind_group(2, &bind_groups.lhs, &[]);\n                pass.set_bind_group(3, &bind_groups.rhs, &[]);\n                pass.set_bind_group(4, &bind_groups.output, &[]);\n\n                debug!(\n                    output_asset_id = ?output_asset_id,\n                    gaussian_count,\n                    workgroups,\n                    uniform_index = cloud_uniform_index.index(),\n                    \"dispatched GaussianInterpolate compute pass\"\n                );\n\n                pass.dispatch_workgroups(workgroups, 1, 1);\n            }\n        }\n\n        debug!(\"GaussianInterpolateNode run completed\");\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src/morph/interpolate.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::morph::interpolate\n\n#import bevy_gaussian_splatting::bindings::gaussian_uniforms\n\n#ifdef PACKED_F32\n    #import bevy_gaussian_splatting::packed::{\n        get_opacity,\n        get_position,\n        get_rotation,\n        get_scale,\n        get_spherical_harmonics,\n        get_visibility,\n        get_rhs_opacity,\n        get_rhs_position,\n        get_rhs_rotation,\n        get_rhs_scale,\n        get_rhs_spherical_harmonics,\n        get_rhs_visibility,\n        set_output_position_visibility,\n        set_output_spherical_harmonics,\n        set_output_transform,\n    };\n#else\n    #import bevy_gaussian_splatting::planar::{\n        get_opacity,\n        get_position,\n        get_rotation,\n        get_scale,\n        get_spherical_harmonics,\n        get_visibility,\n        get_rhs_opacity,\n        get_rhs_position,\n        get_rhs_rotation,\n        get_rhs_scale,\n        get_rhs_spherical_harmonics,\n        get_rhs_visibility,\n        set_output_position_visibility,\n        set_output_spherical_harmonics,\n        set_output_transform,\n    };\n\n    #ifdef PRECOMPUTE_COVARIANCE_3D\n        #import bevy_gaussian_splatting::planar::{\n            get_cov3d,\n            get_rhs_cov3d,\n            set_output_covariance,\n        };\n    #endif\n#endif\n\nfn interpolation_factor() -> f32 {\n    let duration = gaussian_uniforms.time_stop - gaussian_uniforms.time_start;\n    if abs(duration) < 1e-6 {\n        return select(0.0, 1.0, gaussian_uniforms.time >= gaussian_uniforms.time_stop);\n    }\n    return clamp((gaussian_uniforms.time - gaussian_uniforms.time_start) / duration, 0.0, 1.0);\n}\n\nfn normalize_quaternion(q: vec4<f32>) -> vec4<f32> {\n    let length_squared = dot(q, q);\n    if length_squared <= 0.0 {\n        return vec4<f32>(0.0, 0.0, 0.0, 1.0);\n    }\n    return q / sqrt(length_squared);\n}\n\nconst WORKGROUP_SIZE: u32 = 256u;\n\n@compute @workgroup_size(WORKGROUP_SIZE, 1, 1)\nfn interpolate_gaussians(\n    @builtin(global_invocation_id) global_id: vec3<u32>,\n) {\n    let index = global_id.x;\n    if index >= gaussian_uniforms.count {\n        return;\n    }\n\n    let t = interpolation_factor();\n    let position_t = vec3<f32>(t);\n    let rotation_t = vec4<f32>(t);\n\n    let lhs_position = get_position(index);\n    let rhs_position = get_rhs_position(index);\n    let lhs_visibility = get_visibility(index);\n    let rhs_visibility = get_rhs_visibility(index);\n\n    let position = mix(lhs_position, rhs_position, position_t);\n    let visibility = mix(lhs_visibility, rhs_visibility, t);\n    set_output_position_visibility(index, position, visibility);\n\n    var sh = get_spherical_harmonics(index);\n    let rhs_sh = get_rhs_spherical_harmonics(index);\n    for (var i = 0u; i < #{SH_COEFF_COUNT}; i = i + 1u) {\n        sh[i] = mix(sh[i], rhs_sh[i], t);\n    }\n    set_output_spherical_harmonics(index, sh);\n\n#ifdef PRECOMPUTE_COVARIANCE_3D\n    var cov = get_cov3d(index);\n    let rhs_cov = get_rhs_cov3d(index);\n    for (var i = 0u; i < 6u; i = i + 1u) {\n        cov[i] = mix(cov[i], rhs_cov[i], t);\n    }\n    let opacity = mix(get_opacity(index), get_rhs_opacity(index), t);\n    set_output_covariance(index, cov, opacity);\n#else\n    let rotation = normalize_quaternion(\n        mix(\n            get_rotation(index),\n            get_rhs_rotation(index),\n            rotation_t,\n        ),\n    );\n\n    let scale = mix(get_scale(index), get_rhs_scale(index), position_t);\n    let opacity = mix(get_opacity(index), get_rhs_opacity(index), t);\n    set_output_transform(index, rotation, scale, opacity);\n#endif\n}\n"
  },
  {
    "path": "src/morph/mod.rs",
    "content": "use bevy::prelude::*;\nuse bevy_interleave::prelude::*;\n\n#[cfg(feature = \"morph_interpolate\")]\npub mod interpolate;\n\n#[cfg(feature = \"morph_particles\")]\npub mod particle;\n\npub struct MorphPlugin<R: PlanarSync> {\n    _phantom: std::marker::PhantomData<R>,\n}\nimpl<R: PlanarSync> Default for MorphPlugin<R> {\n    fn default() -> Self {\n        Self {\n            _phantom: std::marker::PhantomData,\n        }\n    }\n}\n\nimpl<R> Plugin for MorphPlugin<R>\nwhere\n    R: PlanarSync,\n    R::GpuPlanarType: GpuPlanarStorage,\n    <R::GpuPlanarType as GpuPlanar>::PackedType: ReflectInterleaved,\n{\n    fn build(&self, app: &mut App) {\n        #[cfg(feature = \"morph_interpolate\")]\n        {\n            app.add_plugins(interpolate::InterpolatePlugin::<R>::default());\n        }\n\n        #[cfg(feature = \"morph_particles\")]\n        {\n            app.add_plugins(particle::ParticleBehaviorPlugin::<R>::default());\n        }\n\n        #[cfg(not(any(feature = \"morph_interpolate\", feature = \"morph_particles\")))]\n        let _ = app;\n    }\n}\n"
  },
  {
    "path": "src/morph/particle.rs",
    "content": "use rand::{\n    Rng,\n    distr::{Distribution, StandardUniform},\n    rng,\n};\nuse std::marker::Copy;\n\n#[allow(unused_imports)]\nuse bevy::{\n    asset::{LoadState, RenderAssetUsages, load_internal_asset, uuid_handle},\n    core_pipeline::core_3d::graph::{Core3d, Node3d},\n    ecs::system::{SystemParamItem, lifetimeless::SRes},\n    prelude::*,\n    render::{\n        Extract, Render, RenderApp, RenderSystems,\n        render_asset::{PrepareAssetError, RenderAsset, RenderAssetPlugin, RenderAssets},\n        render_graph::{Node, NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel},\n        render_resource::{\n            BindGroup, BindGroupEntry, BindGroupLayout, BindGroupLayoutDescriptor,\n            BindGroupLayoutEntry, BindingResource, BindingType, Buffer, BufferBinding,\n            BufferBindingType, BufferInitDescriptor, BufferSize, BufferUsages,\n            CachedComputePipelineId, CachedPipelineState, ComputePassDescriptor,\n            ComputePipelineDescriptor, Extent3d, PipelineCache, ShaderStages, ShaderType,\n            TextureDimension, TextureFormat,\n        },\n        renderer::{RenderContext, RenderDevice},\n        view::ViewUniformOffset,\n    },\n};\nuse bevy_interleave::prelude::*;\nuse bytemuck::{Pod, Zeroable};\nuse serde::{Deserialize, Serialize};\n\nuse crate::{\n    camera::GaussianCamera,\n    render::{\n        CloudPipeline, CloudPipelineKey, GaussianUniformBindGroups, GaussianViewBindGroup,\n        shader_defs,\n    },\n};\n\nconst PARTICLE_SHADER_HANDLE: Handle<Shader> = uuid_handle!(\"00000000-0000-0000-0000-00369c79ab8f\");\n\n#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]\npub struct MorphLabel;\n\npub struct ParticleBehaviorPlugin<R: PlanarSync> {\n    phantom: std::marker::PhantomData<R>,\n}\nimpl<R: PlanarSync> Default for ParticleBehaviorPlugin<R> {\n    fn default() -> Self {\n        Self {\n            phantom: std::marker::PhantomData,\n        }\n    }\n}\n\nimpl<R: PlanarSync> Plugin for ParticleBehaviorPlugin<R> {\n    fn build(&self, app: &mut App) {\n        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n            render_app.add_render_graph_node::<ParticleBehaviorNode<R>>(Core3d, MorphLabel);\n\n            // TODO: avoid duplicating the extract system\n            render_app.add_systems(\n                Render,\n                (queue_particle_behavior_bind_group::<R>.in_set(RenderSystems::Queue),),\n            );\n        }\n\n        if app.is_plugin_added::<RenderAssetPlugin<GpuParticleBehaviorBuffers>>() {\n            return;\n        }\n\n        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n            render_app.add_render_graph_edge(Core3d, MorphLabel, Node3d::LatePrepass);\n        }\n\n        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n            render_app.add_systems(ExtractSchedule, extract_particle_behaviors);\n        }\n\n        load_internal_asset!(\n            app,\n            PARTICLE_SHADER_HANDLE,\n            \"particle.wgsl\",\n            Shader::from_wgsl\n        );\n\n        app.register_type::<ParticleBehaviors>();\n        app.register_type::<ParticleBehaviorsHandle>();\n        app.init_asset::<ParticleBehaviors>();\n        app.init_asset::<ParticleBehaviors>();\n        app.register_asset_reflect::<ParticleBehaviors>();\n        app.add_plugins(RenderAssetPlugin::<GpuParticleBehaviorBuffers>::default());\n    }\n\n    fn finish(&self, app: &mut App) {\n        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n            render_app.init_resource::<ParticleBehaviorPipeline<R>>();\n        }\n    }\n}\n\npub fn extract_particle_behaviors(\n    mut commands: Commands,\n    mut prev_commands_len: Local<usize>,\n    gaussians_query: Extract<Query<(Entity, &ParticleBehaviorsHandle)>>,\n) {\n    let mut commands_list = Vec::with_capacity(*prev_commands_len);\n\n    for (entity, behaviors) in gaussians_query.iter() {\n        commands_list.push((entity, behaviors.clone()));\n    }\n    *prev_commands_len = commands_list.len();\n    commands.insert_batch(commands_list);\n}\n\n#[derive(Debug, Clone)]\npub struct GpuParticleBehaviorBuffers {\n    pub particle_behavior_count: u32,\n    pub particle_behavior_buffer: Buffer,\n}\n\nimpl RenderAsset for GpuParticleBehaviorBuffers {\n    type SourceAsset = ParticleBehaviors;\n    type Param = SRes<RenderDevice>;\n\n    fn prepare_asset(\n        source: Self::SourceAsset,\n        _: AssetId<Self::SourceAsset>,\n        render_device: &mut SystemParamItem<Self::Param>,\n        _: Option<&Self>,\n    ) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {\n        let particle_behavior_count = source.0.len() as u32;\n\n        let particle_behavior_buffer =\n            render_device.create_buffer_with_data(&BufferInitDescriptor {\n                label: Some(\"particle behavior buffer\"),\n                contents: bytemuck::cast_slice(source.0.as_slice()),\n                usage: BufferUsages::VERTEX | BufferUsages::COPY_DST | BufferUsages::STORAGE,\n            });\n\n        Ok(GpuParticleBehaviorBuffers {\n            particle_behavior_count,\n            particle_behavior_buffer,\n        })\n    }\n\n    fn asset_usage(_: &Self::SourceAsset) -> RenderAssetUsages {\n        RenderAssetUsages::default()\n    }\n}\n\n#[derive(Resource)]\npub struct ParticleBehaviorPipeline<R: PlanarSync> {\n    pub particle_behavior_layout: BindGroupLayout,\n    pub particle_behavior_pipeline: CachedComputePipelineId,\n    phantom: std::marker::PhantomData<R>,\n}\n\nimpl<R: PlanarSync> FromWorld for ParticleBehaviorPipeline<R> {\n    fn from_world(render_world: &mut World) -> Self {\n        let render_device = render_world.resource::<RenderDevice>();\n        let gaussian_cloud_pipeline = render_world.resource::<CloudPipeline<R>>();\n\n        let particle_behavior_layout_entries = [BindGroupLayoutEntry {\n            binding: 7,\n            visibility: ShaderStages::COMPUTE,\n            ty: BindingType::Buffer {\n                ty: BufferBindingType::Storage { read_only: false },\n                has_dynamic_offset: false,\n                min_binding_size: BufferSize::new(std::mem::size_of::<ParticleBehavior>() as u64),\n            },\n            count: None,\n        }];\n        let particle_behavior_layout_desc = BindGroupLayoutDescriptor::new(\n            \"gaussian_cloud_particle_behavior_layout\",\n            &particle_behavior_layout_entries,\n        );\n        let particle_behavior_layout = render_device.create_bind_group_layout(\n            Some(\"gaussian_cloud_particle_behavior_layout\"),\n            &particle_behavior_layout_entries,\n        );\n\n        let shader_defs = shader_defs(CloudPipelineKey::default());\n        let pipeline_cache = render_world.resource::<PipelineCache>();\n\n        let particle_behavior_pipeline =\n            pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {\n                label: Some(\"particle_behavior_pipeline\".into()),\n                layout: vec![\n                    gaussian_cloud_pipeline.compute_view_layout_desc.clone(),\n                    gaussian_cloud_pipeline.gaussian_uniform_layout_desc.clone(),\n                    gaussian_cloud_pipeline.gaussian_cloud_layout_desc.clone(),\n                    particle_behavior_layout_desc,\n                ],\n                push_constant_ranges: vec![],\n                shader: PARTICLE_SHADER_HANDLE,\n                shader_defs: shader_defs.clone(),\n                entry_point: Some(\"apply_particle_behaviors\".into()),\n                zero_initialize_workgroup_memory: true,\n            });\n\n        Self {\n            particle_behavior_layout,\n            particle_behavior_pipeline,\n            phantom: std::marker::PhantomData,\n        }\n    }\n}\n\n#[derive(Component)]\npub struct ParticleBehaviorBindGroup {\n    pub particle_behavior_bindgroup: BindGroup,\n}\n\npub fn queue_particle_behavior_bind_group<R: PlanarSync>(\n    mut commands: Commands,\n    particle_behavior_pipeline: Res<ParticleBehaviorPipeline<R>>,\n    render_device: Res<RenderDevice>,\n    asset_server: Res<AssetServer>,\n    particle_behaviors_res: Res<RenderAssets<GpuParticleBehaviorBuffers>>,\n    particle_behaviors: Query<(Entity, &ParticleBehaviorsHandle)>,\n) {\n    for (entity, behaviors_handle) in particle_behaviors.iter() {\n        if let Some(load_state) = asset_server.get_load_state(&behaviors_handle.0)\n            && load_state.is_loading()\n        {\n            continue;\n        }\n\n        if particle_behaviors_res.get(&behaviors_handle.0).is_none() {\n            continue;\n        }\n\n        let behaviors = particle_behaviors_res.get(&behaviors_handle.0).unwrap();\n\n        let particle_behavior_bindgroup = render_device.create_bind_group(\n            \"particle_behavior_bind_group\",\n            &particle_behavior_pipeline.particle_behavior_layout,\n            &[BindGroupEntry {\n                binding: 7,\n                resource: BindingResource::Buffer(BufferBinding {\n                    buffer: &behaviors.particle_behavior_buffer,\n                    offset: 0,\n                    size: BufferSize::new(behaviors.particle_behavior_buffer.size()),\n                }),\n            }],\n        );\n\n        commands.entity(entity).insert(ParticleBehaviorBindGroup {\n            particle_behavior_bindgroup,\n        });\n    }\n}\n\npub struct ParticleBehaviorNode<R: PlanarSync> {\n    gaussian_clouds: QueryState<(\n        &'static PlanarStorageBindGroup<R>,\n        &'static ParticleBehaviorsHandle,\n        &'static ParticleBehaviorBindGroup,\n    )>,\n    initialized: bool,\n    view_bind_group: QueryState<(\n        &'static GaussianCamera,\n        &'static GaussianViewBindGroup,\n        &'static ViewUniformOffset,\n    )>,\n    phantom: std::marker::PhantomData<R>,\n}\n\nimpl<R: PlanarSync> FromWorld for ParticleBehaviorNode<R> {\n    fn from_world(world: &mut World) -> Self {\n        Self {\n            gaussian_clouds: world.query(),\n            initialized: false,\n            view_bind_group: world.query(),\n            phantom: std::marker::PhantomData,\n        }\n    }\n}\n\nimpl<R: PlanarSync> Node for ParticleBehaviorNode<R> {\n    fn update(&mut self, world: &mut World) {\n        let pipeline = world.resource::<ParticleBehaviorPipeline<R>>();\n        let pipeline_cache = world.resource::<PipelineCache>();\n\n        if !self.initialized {\n            if let CachedPipelineState::Ok(_) =\n                pipeline_cache.get_compute_pipeline_state(pipeline.particle_behavior_pipeline)\n            {\n                self.initialized = true;\n            }\n\n            if !self.initialized {\n                return;\n            }\n        }\n\n        self.gaussian_clouds.update_archetypes(world);\n        self.view_bind_group.update_archetypes(world);\n    }\n\n    fn run(\n        &self,\n        _graph: &mut RenderGraphContext,\n        render_context: &mut RenderContext,\n        world: &World,\n    ) -> Result<(), NodeRunError> {\n        if !self.initialized {\n            return Ok(());\n        }\n\n        let pipeline_cache = world.resource::<PipelineCache>();\n        let pipeline = world.resource::<ParticleBehaviorPipeline<R>>();\n\n        let command_encoder = render_context.command_encoder();\n\n        for (_gaussian_camera, view_bind_group, view_uniform_offset) in\n            self.view_bind_group.iter_manual(world)\n        {\n            for (planar_storage_bind_group, behaviors_handle, particle_behavior_bind_group) in\n                self.gaussian_clouds.iter_manual(world)\n            {\n                let behaviors = world\n                    .get_resource::<RenderAssets<GpuParticleBehaviorBuffers>>()\n                    .unwrap()\n                    .get(behaviors_handle.0.id())\n                    .unwrap();\n                let gaussian_uniforms = world.resource::<GaussianUniformBindGroups>();\n\n                {\n                    let mut pass =\n                        command_encoder.begin_compute_pass(&ComputePassDescriptor::default());\n\n                    pass.set_bind_group(0, &view_bind_group.value, &[view_uniform_offset.offset]);\n                    pass.set_bind_group(\n                        1,\n                        gaussian_uniforms.base_bind_group.as_ref().unwrap(),\n                        &[0],\n                    );\n                    pass.set_bind_group(2, &planar_storage_bind_group.bind_group, &[]);\n                    pass.set_bind_group(\n                        3,\n                        &particle_behavior_bind_group.particle_behavior_bindgroup,\n                        &[],\n                    );\n\n                    let particle_behavior = pipeline_cache\n                        .get_compute_pipeline(pipeline.particle_behavior_pipeline)\n                        .unwrap();\n                    pass.set_pipeline(particle_behavior);\n                    pass.dispatch_workgroups(behaviors.particle_behavior_count / 32, 32, 1);\n                }\n            }\n        }\n\n        Ok(())\n    }\n}\n\n#[derive(Component, Clone, Debug, Default, PartialEq, Reflect)]\n#[reflect(Component, Default)]\npub struct ParticleBehaviorsHandle(pub Handle<ParticleBehaviors>);\n\nimpl From<Handle<ParticleBehaviors>> for ParticleBehaviorsHandle {\n    fn from(handle: Handle<ParticleBehaviors>) -> Self {\n        Self(handle)\n    }\n}\n\nimpl From<ParticleBehaviorsHandle> for AssetId<ParticleBehaviors> {\n    fn from(handle: ParticleBehaviorsHandle) -> Self {\n        handle.0.id()\n    }\n}\n\nimpl From<&ParticleBehaviorsHandle> for AssetId<ParticleBehaviors> {\n    fn from(handle: &ParticleBehaviorsHandle) -> Self {\n        handle.0.id()\n    }\n}\n\n// TODO: add more particle system functionality (e.g. lifetime, color)\n#[derive(\n    Clone, Debug, Copy, PartialEq, Reflect, ShaderType, Pod, Zeroable, Serialize, Deserialize,\n)]\n#[repr(C)]\npub struct ParticleBehavior {\n    pub indicies: [u32; 4],\n    pub velocity: [f32; 4],\n    pub acceleration: [f32; 4],\n    pub jerk: [f32; 4],\n}\n\nimpl Default for ParticleBehavior {\n    fn default() -> Self {\n        Self {\n            indicies: [0, 0, 0, 0],\n            velocity: [0.0, 0.0, 0.0, 0.0],\n            acceleration: [0.0, 0.0, 0.0, 0.0],\n            jerk: [0.0, 0.0, 0.0, 0.0],\n        }\n    }\n}\n\n#[derive(Asset, Clone, Debug, Default, PartialEq, Reflect, Serialize, Deserialize)]\npub struct ParticleBehaviors(pub Vec<ParticleBehavior>);\n\nimpl Distribution<ParticleBehavior> for StandardUniform {\n    fn sample<R: Rng + ?Sized>(&self, rng: &mut R) -> ParticleBehavior {\n        ParticleBehavior {\n            acceleration: [\n                rng.random_range(-0.01..0.01),\n                rng.random_range(-0.01..0.01),\n                rng.random_range(-0.01..0.01),\n                rng.random_range(-0.01..0.01),\n            ],\n            jerk: [\n                rng.random_range(-0.0001..0.0001),\n                rng.random_range(-0.0001..0.0001),\n                rng.random_range(-0.0001..0.0001),\n                rng.random_range(-0.0001..0.0001),\n            ],\n            velocity: [\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n                rng.random_range(-1.0..1.0),\n            ],\n            ..Default::default()\n        }\n    }\n}\n\npub fn random_particle_behaviors(n: usize) -> ParticleBehaviors {\n    let mut rng = rng();\n    let mut behaviors = Vec::with_capacity(n);\n    for i in 0..n {\n        let mut behavior: ParticleBehavior = rng.random();\n        behavior.indicies[0] = i as u32;\n        behaviors.push(behavior);\n    }\n\n    ParticleBehaviors(behaviors)\n}\n"
  },
  {
    "path": "src/morph/particle.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::morph::particle\n\n#import bevy_gaussian_splatting::bindings::{\n    gaussian_uniforms,\n    globals,\n    position_visibility,\n}\n#import bevy_gaussian_splatting::spherical_harmonics::spherical_harmonics_lookup\n#import bevy_gaussian_splatting::transform::{\n    world_to_clip,\n    in_frustum,\n}\n\nstruct ParticleBehavior {\n    @location(0) indicies: vec4<i32>,\n    @location(1) velocity: vec4<f32>,\n    @location(2) acceleration: vec4<f32>,\n    @location(3) jerk: vec4<f32>,\n}\n\n@group(3) @binding(7) var<storage, read_write> particle_behaviors: array<ParticleBehavior>;\n\n@compute @workgroup_size(32, 32)\nfn apply_particle_behaviors(\n    @builtin(local_invocation_id) gl_LocalInvocationID: vec3<u32>,\n    @builtin(global_invocation_id) gl_GlobalInvocationID: vec3<u32>,\n) {\n    let behavior_index = gl_GlobalInvocationID.x * 32u + gl_GlobalInvocationID.y;\n    let behavior = particle_behaviors[behavior_index];\n\n    let point_index = behavior.indicies.x;\n    let point = position_visibility[point_index];\n\n    // TODO: add gaussian attribute setters for 4d capability\n\n    let delta_position = behavior.velocity * globals.delta_time + 0.5 * behavior.acceleration * globals.delta_time * globals.delta_time + 1.0 / 6.0 * behavior.jerk * globals.delta_time * globals.delta_time * globals.delta_time;\n    let delta_velocity = behavior.acceleration * globals.delta_time + 0.5 * behavior.jerk * globals.delta_time * globals.delta_time;\n    let delta_acceleration = behavior.jerk * globals.delta_time;\n\n    let new_position = point + delta_position;\n    let new_velocity = behavior.velocity + delta_velocity;\n    let new_acceleration = behavior.acceleration + delta_acceleration;\n\n    workgroupBarrier();\n\n    if (behavior.indicies.x < 0) {\n        return;\n    }\n\n    position_visibility[point_index] = new_position;\n    particle_behaviors[behavior_index].velocity = new_velocity;\n    particle_behaviors[behavior_index].acceleration = new_acceleration;\n}\n"
  },
  {
    "path": "src/noise/mod.rs",
    "content": "use bevy::{\n    asset::{load_internal_asset, uuid_handle},\n    prelude::*,\n};\n\nconst NOISE_SHADER_HANDLE: Handle<Shader> = uuid_handle!(\"4f73e89b-30f9-48de-b2b3-3d0f09f09f6f\");\n\n#[derive(Default)]\npub struct NoisePlugin;\n\nimpl Plugin for NoisePlugin {\n    fn build(&self, app: &mut App) {\n        load_internal_asset!(app, NOISE_SHADER_HANDLE, \"noise.wgsl\", Shader::from_wgsl);\n    }\n}\n"
  },
  {
    "path": "src/noise/noise.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::noise\n\n//  MIT License. © Ian McEwan, Stefan Gustavson, Munrocket, Johan Helsing\n//\nfn mod289_2d(x: vec2<f32>) -> vec2<f32> {\n    return x - floor(x * (1.0 / 289.0)) * 289.0;\n}\n\nfn mod289_3d(x: vec3<f32>) -> vec3<f32> {\n    return x - floor(x * (1.0 / 289.0)) * 289.0;\n}\n\nfn permute_3d(x: vec3<f32>) -> vec3<f32> {\n    return mod289_3d(((x * 34.0) + 1.0) * x);\n}\n\n//  MIT License. © Ian McEwan, Stefan Gustavson, Munrocket\nfn simplex_2d(v: vec2<f32>) -> f32 {\n    let C = vec4(\n        0.211324865405187, // (3.0-sqrt(3.0))/6.0\n        0.366025403784439, // 0.5*(sqrt(3.0)-1.0)\n        -0.577350269189626, // -1.0 + 2.0 * C.x\n        0.024390243902439 // 1.0 / 41.0\n    );\n\n    // First corner\n    var i = floor(v + dot(v, C.yy));\n    let x0 = v - i + dot(i, C.xx);\n\n    // Other corners\n    var i1 = select(vec2(0., 1.), vec2(1., 0.), x0.x > x0.y);\n\n    // x0 = x0 - 0.0 + 0.0 * C.xx ;\n    // x1 = x0 - i1 + 1.0 * C.xx ;\n    // x2 = x0 - 1.0 + 2.0 * C.xx ;\n    var x12 = x0.xyxy + C.xxzz;\n    x12.x = x12.x - i1.x;\n    x12.y = x12.y - i1.y;\n\n    // Permutations\n    i = mod289_2d(i); // Avoid truncation effects in permutation\n\n    var p = permute_3d(permute_3d(i.y + vec3(0., i1.y, 1.)) + i.x + vec3(0., i1.x, 1.));\n    var m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), vec3(0.));\n    m *= m;\n    m *= m;\n\n    // Gradients: 41 points uniformly over a line, mapped onto a diamond.\n    // The ring size 17*17 = 289 is close to a multiple of 41 (41*7 = 287)\n    let x = 2. * fract(p * C.www) - 1.;\n    let h = abs(x) - 0.5;\n    let ox = floor(x + 0.5);\n    let a0 = x - ox;\n\n    // Normalize gradients implicitly by scaling m\n    // Approximation of: m *= inversesqrt( a0*a0 + h*h );\n    m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);\n\n    // Compute final noise value at P\n    let g = vec3(a0.x * x0.x + h.x * x0.y, a0.yz * x12.xz + h.yz * x12.yw);\n    return 130. * dot(m, g);\n}\n\n// MIT License. © Stefan Gustavson, Munrocket\nfn permute4_(x: vec4<f32>) -> vec4<f32> { return ((x * 34.0 + 1.0) * x) % vec4<f32>(289.0); }\nfn taylorInvSqrt4_(r: vec4<f32>) -> vec4<f32> { return 1.79284291400159 - 0.85373472095314 * r; }\nfn fade3_(t: vec3<f32>) -> vec3<f32> { return t * t * t * (t * (t * 6.0 - 15.0) + 10.0); }\n\nfn perlin_3d(P: vec3<f32>) -> f32 {\n    var Pi0 : vec3<f32> = floor(P); // Integer part for indexing\n    var Pi1 : vec3<f32> = Pi0 + vec3<f32>(1.0); // Integer part + 1\n    Pi0 = Pi0 % vec3<f32>(289.0);\n    Pi1 = Pi1 % vec3<f32>(289.0);\n    let Pf0 = fract(P); // Fractional part for interpolation\n    let Pf1 = Pf0 - vec3<f32>(1.0); // Fractional part - 1.\n    let ix = vec4<f32>(Pi0.x, Pi1.x, Pi0.x, Pi1.x);\n    let iy = vec4<f32>(Pi0.yy, Pi1.yy);\n    let iz0 = Pi0.zzzz;\n    let iz1 = Pi1.zzzz;\n\n    let ixy = permute4_(permute4_(ix) + iy);\n    let ixy0 = permute4_(ixy + iz0);\n    let ixy1 = permute4_(ixy + iz1);\n\n    var gx0: vec4<f32> = ixy0 / 7.0;\n    var gy0: vec4<f32> = fract(floor(gx0) / 7.0) - 0.5;\n    gx0 = fract(gx0);\n    var gz0: vec4<f32> = vec4<f32>(0.5) - abs(gx0) - abs(gy0);\n    var sz0: vec4<f32> = step(gz0, vec4<f32>(0.0));\n    gx0 = gx0 + sz0 * (step(vec4<f32>(0.0), gx0) - 0.5);\n    gy0 = gy0 + sz0 * (step(vec4<f32>(0.0), gy0) - 0.5);\n\n    var gx1: vec4<f32> = ixy1 / 7.0;\n    var gy1: vec4<f32> = fract(floor(gx1) / 7.0) - 0.5;\n    gx1 = fract(gx1);\n    var gz1: vec4<f32> = vec4<f32>(0.5) - abs(gx1) - abs(gy1);\n    var sz1: vec4<f32> = step(gz1, vec4<f32>(0.0));\n    gx1 = gx1 - sz1 * (step(vec4<f32>(0.0), gx1) - 0.5);\n    gy1 = gy1 - sz1 * (step(vec4<f32>(0.0), gy1) - 0.5);\n\n    var g000: vec3<f32> = vec3<f32>(gx0.x, gy0.x, gz0.x);\n    var g100: vec3<f32> = vec3<f32>(gx0.y, gy0.y, gz0.y);\n    var g010: vec3<f32> = vec3<f32>(gx0.z, gy0.z, gz0.z);\n    var g110: vec3<f32> = vec3<f32>(gx0.w, gy0.w, gz0.w);\n    var g001: vec3<f32> = vec3<f32>(gx1.x, gy1.x, gz1.x);\n    var g101: vec3<f32> = vec3<f32>(gx1.y, gy1.y, gz1.y);\n    var g011: vec3<f32> = vec3<f32>(gx1.z, gy1.z, gz1.z);\n    var g111: vec3<f32> = vec3<f32>(gx1.w, gy1.w, gz1.w);\n\n    let norm0 = taylorInvSqrt4_(\n        vec4<f32>(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));\n    g000 = g000 * norm0.x;\n    g010 = g010 * norm0.y;\n    g100 = g100 * norm0.z;\n    g110 = g110 * norm0.w;\n    let norm1 = taylorInvSqrt4_(\n        vec4<f32>(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));\n    g001 = g001 * norm1.x;\n    g011 = g011 * norm1.y;\n    g101 = g101 * norm1.z;\n    g111 = g111 * norm1.w;\n\n    let n000 = dot(g000, Pf0);\n    let n100 = dot(g100, vec3<f32>(Pf1.x, Pf0.yz));\n    let n010 = dot(g010, vec3<f32>(Pf0.x, Pf1.y, Pf0.z));\n    let n110 = dot(g110, vec3<f32>(Pf1.xy, Pf0.z));\n    let n001 = dot(g001, vec3<f32>(Pf0.xy, Pf1.z));\n    let n101 = dot(g101, vec3<f32>(Pf1.x, Pf0.y, Pf1.z));\n    let n011 = dot(g011, vec3<f32>(Pf0.x, Pf1.yz));\n    let n111 = dot(g111, Pf1);\n\n    var fade_xyz: vec3<f32> = fade3_(Pf0);\n    let temp = vec4<f32>(f32(fade_xyz.z)); // simplify after chrome bug fix\n    let n_z = mix(vec4<f32>(n000, n100, n010, n110), vec4<f32>(n001, n101, n011, n111), temp);\n    let n_yz = mix(n_z.xy, n_z.zw, vec2<f32>(f32(fade_xyz.y))); // simplify after chrome bug fix\n    let n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);\n    return 2.2 * n_xyz;\n}\n\n//  <https://www.shadertoy.com/view/Xd23Dh>\n//  by Inigo Quilez\nfn hash_23_(p: vec2<f32>) -> vec3<f32> {\n    let q = vec3<f32>(dot(p, vec2<f32>(127.1, 311.7)),\n        dot(p, vec2<f32>(269.5, 183.3)),\n        dot(p, vec2<f32>(419.2, 371.9)));\n    return fract(sin(q) * 43758.5453);\n}\n\nfn voro_2d(x: vec2<f32>, u: f32, v: f32) -> f32 {\n    let p = floor(x);\n    let f = fract(x);\n    let k = 1.0 + 63.0 * pow(1. - v, 4.0);\n    var va: f32 = 0.0;\n    var wt: f32 = 0.0;\n    for(var j: i32 = -2; j <= 2; j = j + 1) {\n      for(var i: i32 = -2; i <= 2; i = i + 1) {\n        let g = vec2<f32>(f32(i), f32(j));\n        let o = hash_23_(p + g) * vec3<f32>(u, u, 1.0);\n        let r = g - f + o.xy;\n        let d = dot(r, r);\n        let ww = pow(1. - smoothstep(0.0, 1.414, sqrt(d)), k);\n        va = va + o.z * ww;\n        wt = wt + ww;\n      }\n    }\n    return va / wt;\n}\n\nfn nrand(n: vec2<f32>) -> f32 {\n    return fract(sin(dot(n, vec2<f32>(12.9898, 4.1414))) * 43758.5453);\n}\n\nfn noise_2d(n: vec2<f32>) -> f32 {\n    let d = vec2<f32>(0.0, 1.0);\n    let b = floor(n);\n    let f = smoothstep(vec2<f32>(0.0), vec2<f32>(1.0), fract(n));\n    return mix(mix(nrand(b), nrand(b + d.yx), f.x), mix(nrand(b + d.xy), nrand(b + d.yy), f.x), f.y);\n}\n\n// https://www.shadertoy.com/view/MlVSzw\nconst ALPHA: f32 = 0.14;\nconst INV_ALPHA: f32 = 7.14285714286;\nconst K: f32 = 0.08912676813;\n\nfn inv_error_function(x: f32) -> f32 {\n    let y: f32 = log(1.0 - x*x);\n    let z: f32 = K + 0.5 * y;\n    return sqrt(sqrt(z*z - y * INV_ALPHA) - z) * sign(x);\n}\n\n// expects n to be in ~[0, 1]\nfn gaussian_rand(n: vec2<f32>) -> f32 {\n    let x: f32 = nrand(n * 13.7);\n\n    return inv_error_function(x * 2.0 - 1.0) * 0.3;\n}\n"
  },
  {
    "path": "src/query/mod.rs",
    "content": "use bevy::prelude::*;\n\n#[cfg(feature = \"query_raycast\")]\npub mod raycast;\n\n#[cfg(feature = \"query_select\")]\npub mod select;\n\n#[cfg(feature = \"query_sparse\")]\npub mod sparse;\n\n#[derive(Default)]\npub struct QueryPlugin;\n\nimpl Plugin for QueryPlugin {\n    #[allow(unused)]\n    fn build(&self, app: &mut App) {\n        #[cfg(feature = \"query_raycast\")]\n        app.add_plugins(raycast::RaycastSelectionPlugin);\n\n        #[cfg(feature = \"query_select\")]\n        app.add_plugins(select::SelectPlugin);\n\n        #[cfg(feature = \"query_sparse\")]\n        app.add_plugins(sparse::SparsePlugin);\n    }\n}\n"
  },
  {
    "path": "src/query/raycast.rs",
    "content": "use bevy::{\n    mesh::{Indices, Mesh3d, PrimitiveTopology},\n    prelude::*,\n};\n\n#[derive(Component, Clone, Copy, Debug, Default, Reflect)]\n#[reflect(Component)]\npub struct Point {\n    pub position: Vec3,\n}\n\n#[derive(Component, Clone, Copy, Debug, Default, Reflect)]\n#[reflect(Component)]\npub struct InsideMesh;\n\n#[derive(Default)]\npub struct RaycastSelectionPlugin;\n\nimpl Plugin for RaycastSelectionPlugin {\n    fn build(&self, app: &mut App) {\n        app.register_type::<Point>();\n        app.register_type::<InsideMesh>();\n        app.add_systems(Update, point_in_mesh_system);\n    }\n}\n\nstruct Triangle {\n    vertices: [Vec3; 3],\n}\n\nfn point_in_mesh_system(\n    mesh_query: Query<(&Mesh3d, &GlobalTransform)>,\n    point_query: Query<(Entity, &Point), Without<InsideMesh>>,\n    meshes: Res<Assets<Mesh>>,\n    mut commands: Commands,\n) {\n    for (mesh_handle, transform) in mesh_query.iter() {\n        let Some(mesh) = meshes.get(&mesh_handle.0) else {\n            continue;\n        };\n\n        for (entity, point) in point_query.iter() {\n            let local_point = transform\n                .to_matrix()\n                .inverse()\n                .transform_point3(point.position);\n            if is_point_in_mesh(local_point, mesh) {\n                commands.entity(entity).insert(InsideMesh);\n            }\n        }\n    }\n}\n\nfn is_point_in_mesh(point: Vec3, mesh: &Mesh) -> bool {\n    if mesh.primitive_topology() != PrimitiveTopology::TriangleList {\n        return false;\n    }\n\n    let Some(vertex_attribute) = mesh.attribute(Mesh::ATTRIBUTE_POSITION) else {\n        return false;\n    };\n    let Some(vertices) = vertex_attribute.as_float3() else {\n        return false;\n    };\n\n    let Some(indices) = mesh.indices() else {\n        return false;\n    };\n    let Indices::U32(indices) = indices else {\n        return false;\n    };\n\n    let mut intersections = 0usize;\n    for chunk in indices.chunks_exact(3) {\n        let triangle = Triangle {\n            vertices: [\n                Vec3::from(vertices[chunk[0] as usize]),\n                Vec3::from(vertices[chunk[1] as usize]),\n                Vec3::from(vertices[chunk[2] as usize]),\n            ],\n        };\n\n        let ray_direction = Vec3::new(1.0, 0.0, 0.0);\n        if ray_intersects_triangle(point, ray_direction, &triangle) {\n            intersections += 1;\n        }\n    }\n\n    intersections % 2 == 1\n}\n\nfn ray_intersects_triangle(ray_origin: Vec3, ray_direction: Vec3, triangle: &Triangle) -> bool {\n    let epsilon = 0.000_001;\n    let vertex0 = triangle.vertices[0];\n    let vertex1 = triangle.vertices[1];\n    let vertex2 = triangle.vertices[2];\n\n    let edge1 = vertex1 - vertex0;\n    let edge2 = vertex2 - vertex0;\n    let h = ray_direction.cross(edge2);\n    let a = edge1.dot(h);\n\n    if a > -epsilon && a < epsilon {\n        return false;\n    }\n\n    let f = 1.0 / a;\n    let s = ray_origin - vertex0;\n    let u = f * s.dot(h);\n\n    if !(0.0..=1.0).contains(&u) {\n        return false;\n    }\n\n    let q = s.cross(edge1);\n    let v = f * ray_direction.dot(q);\n\n    if v < 0.0 || (u + v) > 1.0 {\n        return false;\n    }\n\n    let t = f * edge2.dot(q);\n    t > epsilon\n}\n"
  },
  {
    "path": "src/query/select.rs",
    "content": "use bevy::prelude::*;\nuse bevy_interleave::prelude::*;\n\nuse crate::{\n    gaussian::{\n        formats::{planar_3d::Gaussian3d, planar_4d::Gaussian4d},\n        interface::CommonCloud,\n    },\n    io::codec::CloudCodec,\n};\n\n#[derive(Component, Debug, Default, Reflect)]\npub struct Select {\n    pub indicies: Vec<usize>,\n    pub completed: bool,\n}\n\nimpl FromIterator<usize> for Select {\n    fn from_iter<I: IntoIterator<Item = usize>>(iter: I) -> Self {\n        let indicies = iter.into_iter().collect::<Vec<usize>>();\n        Select {\n            indicies,\n            ..Default::default()\n        }\n    }\n}\n\nimpl Select {\n    pub fn invert(&mut self, cloud_size: usize) -> Select {\n        let inverted = (0..cloud_size)\n            .filter(|index| !self.indicies.contains(index))\n            .collect::<Vec<usize>>();\n\n        Select {\n            indicies: inverted,\n            completed: self.completed,\n        }\n    }\n}\n\n#[derive(Default)]\npub struct SelectPlugin;\n\nimpl Plugin for SelectPlugin {\n    fn build(&self, app: &mut App) {\n        app.register_type::<Select>();\n\n        app.add_message::<InvertSelectionEvent>();\n        app.add_message::<SaveSelectionEvent>();\n\n        app.add_plugins(CommonCloudSelectPlugin::<Gaussian3d>::default());\n        app.add_plugins(CommonCloudSelectPlugin::<Gaussian4d>::default());\n    }\n}\n\n#[derive(Default)]\npub struct CommonCloudSelectPlugin<R: PlanarSync>\nwhere\n    R::PlanarType: CommonCloud,\n{\n    _phantom: std::marker::PhantomData<R>,\n}\n\nimpl<R: PlanarSync> Plugin for CommonCloudSelectPlugin<R>\nwhere\n    R::PlanarType: CloudCodec,\n    R::PlanarType: CommonCloud,\n{\n    fn build(&self, app: &mut App) {\n        app.add_systems(\n            Update,\n            (\n                apply_selection::<R>,\n                invert_selection::<R>,\n                save_selection::<R>,\n            ),\n        );\n    }\n}\n\nfn apply_selection<R: PlanarSync>(\n    asset_server: Res<AssetServer>,\n    mut gaussian_clouds_res: ResMut<Assets<R::PlanarType>>,\n    mut selections: Query<(Entity, &R::PlanarTypeHandle, &mut Select)>,\n) where\n    R::PlanarType: CommonCloud,\n{\n    for (_entity, cloud_handle, mut select) in selections.iter_mut() {\n        if select.indicies.is_empty() || select.completed {\n            continue;\n        }\n\n        if let Some(load_state) = asset_server.get_load_state(cloud_handle.handle())\n            && load_state.is_loading()\n        {\n            continue;\n        }\n\n        let cloud = gaussian_clouds_res.get_mut(cloud_handle.handle()).unwrap();\n\n        (0..cloud.len()).for_each(|index| {\n            *cloud.visibility_mut(index) = 0.0;\n        });\n\n        select.indicies.iter().for_each(|index| {\n            *cloud.visibility_mut(*index) = 1.0;\n        });\n\n        select.completed = true;\n    }\n}\n\n#[derive(Message, Debug, Reflect)]\npub struct InvertSelectionEvent;\n\nfn invert_selection<R: PlanarSync>(\n    mut events: MessageReader<InvertSelectionEvent>,\n    mut gaussian_clouds_res: ResMut<Assets<R::PlanarType>>,\n    mut selections: Query<(Entity, &R::PlanarTypeHandle, &mut Select)>,\n) where\n    R::PlanarType: CommonCloud,\n{\n    if events.is_empty() {\n        return;\n    }\n    events.clear();\n\n    for (_entity, cloud_handle, mut select) in selections.iter_mut() {\n        if select.indicies.is_empty() {\n            continue;\n        }\n\n        let cloud = gaussian_clouds_res.get_mut(cloud_handle.handle()).unwrap();\n\n        let mut new_indicies = Vec::with_capacity(cloud.len() - select.indicies.len());\n\n        (0..cloud.len()).for_each(|index| {\n            if cloud.visibility(index) == 0.0 {\n                new_indicies.push(index);\n            }\n\n            *cloud.visibility_mut(index) = 1.0;\n        });\n\n        select.indicies.iter().for_each(|index| {\n            *cloud.visibility_mut(*index) = 0.0;\n        });\n\n        select.indicies = new_indicies;\n    }\n}\n\n#[derive(Message, Debug, Reflect)]\npub struct SaveSelectionEvent;\n\npub fn save_selection<R: PlanarSync>(\n    mut events: MessageReader<SaveSelectionEvent>,\n    mut gaussian_clouds_res: ResMut<Assets<R::PlanarType>>,\n    mut selections: Query<(Entity, &R::PlanarTypeHandle, &Select)>,\n) where\n    R::PlanarType: CloudCodec,\n    R::PlanarType: CommonCloud,\n{\n    if events.is_empty() {\n        return;\n    }\n    events.clear();\n\n    for (_entity, cloud_handle, select) in selections.iter_mut() {\n        let cloud = gaussian_clouds_res.get_mut(cloud_handle.handle()).unwrap();\n\n        let selected = cloud.subset(select.indicies.as_slice());\n\n        selected.write_to_file(\"live_output.gcloud\");\n    }\n}\n"
  },
  {
    "path": "src/query/sparse.rs",
    "content": "use bevy::prelude::*;\nuse bevy_interleave::prelude::PlanarHandle;\nuse kd_tree::{KdPoint, KdTree};\nuse typenum::consts::U3;\n\nuse crate::{\n    PlanarGaussian3d, PlanarGaussian3dHandle, gaussian::interface::CommonCloud,\n    query::select::Select,\n};\n\n#[derive(Clone, Copy)]\nstruct PositionPoint([f32; 3]);\n\nimpl KdPoint for PositionPoint {\n    type Scalar = f32;\n    type Dim = U3;\n\n    fn at(&self, i: usize) -> Self::Scalar {\n        self.0[i]\n    }\n}\n\n#[derive(Component, Debug, Reflect)]\npub struct SparseSelect {\n    pub radius: f32,\n    pub neighbor_threshold: usize,\n    pub completed: bool,\n}\n\nimpl Default for SparseSelect {\n    fn default() -> Self {\n        Self {\n            radius: 0.05,\n            neighbor_threshold: 3,\n            completed: false,\n        }\n    }\n}\n\nimpl SparseSelect {\n    pub fn select(&self, cloud: &PlanarGaussian3d) -> Select {\n        let points = collect_points(cloud);\n        let tree = KdTree::build_by_ordered_float(points.clone());\n\n        points\n            .iter()\n            .enumerate()\n            .filter(|(_idx, point)| {\n                tree.within_radius(*point, self.radius).len() < self.neighbor_threshold\n            })\n            .map(|(idx, _point)| idx)\n            .collect::<Select>()\n    }\n}\n\n#[derive(Default)]\npub struct SparsePlugin;\n\nimpl Plugin for SparsePlugin {\n    fn build(&self, app: &mut App) {\n        app.register_type::<SparseSelect>();\n        app.add_systems(Update, select_sparse_handler);\n    }\n}\n\nfn collect_points(cloud: &PlanarGaussian3d) -> Vec<PositionPoint> {\n    cloud\n        .position_iter()\n        .map(|position| PositionPoint([position[0], position[1], position[2]]))\n        .collect()\n}\n\nfn select_sparse_handler(\n    mut commands: Commands,\n    asset_server: Res<AssetServer>,\n    gaussian_clouds_res: Res<Assets<PlanarGaussian3d>>,\n    mut selections: Query<(Entity, &PlanarGaussian3dHandle, &mut SparseSelect)>,\n) {\n    for (entity, cloud_handle, mut select) in selections.iter_mut() {\n        if let Some(load_state) = asset_server.get_load_state(cloud_handle.handle())\n            && load_state.is_loading()\n        {\n            continue;\n        }\n\n        if select.completed {\n            continue;\n        }\n        select.completed = true;\n\n        let Some(cloud) = gaussian_clouds_res.get(cloud_handle.handle()) else {\n            continue;\n        };\n\n        let points = collect_points(cloud);\n        let tree = KdTree::build_by_ordered_float(points.clone());\n\n        let new_selection = points\n            .iter()\n            .enumerate()\n            .filter(|(_idx, point)| {\n                tree.within_radius(*point, select.radius).len() < select.neighbor_threshold\n            })\n            .map(|(idx, _point)| idx)\n            .collect::<Select>();\n\n        commands\n            .entity(entity)\n            .remove::<Select>()\n            .insert(new_selection);\n    }\n}\n"
  },
  {
    "path": "src/render/bindings.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::bindings\n\n#import bevy_pbr::prepass_bindings::PreviousViewUniforms\n#import bevy_render::globals::Globals\n#import bevy_render::view::View\n\n@group(0) @binding(0) var<uniform> view: View;\n@group(0) @binding(1) var<uniform> globals: Globals;\n@group(0) @binding(2) var<uniform> previous_view_uniforms: PreviousViewUniforms;\n\n@group(0) @binding(14) var<storage> visibility_ranges: array<vec4<f32>>;\n\nstruct GaussianUniforms {\n    transform: mat4x4<f32>,\n    global_opacity: f32,\n    global_scale: f32,\n    count: u32,\n    count_root_ceil: u32,\n    time: f32,\n    time_start: f32,\n    time_stop: f32,\n    num_classes: u32,\n    color_space: u32,\n    min: vec4<f32>,\n    max: vec4<f32>,\n};\n@group(1) @binding(0) var<uniform> gaussian_uniforms: GaussianUniforms;\n\n#ifdef GAUSSIAN_3D_STRUCTURE\n    #ifdef PACKED_F32\n        struct Gaussian {\n            @location(0) rotation: vec4<f32>,\n            @location(1) position_visibility: vec4<f32>,\n            @location(2) scale_opacity: vec4<f32>,\n            sh: array<f32, #{SH_COEFF_COUNT}>,\n        };\n\n        #ifdef READ_WRITE_POINTS\n            @group(2) @binding(0) var<storage, read_write> points: array<Gaussian>;\n        #else\n            @group(2) @binding(0) var<storage, read> points: array<Gaussian>;\n        #endif\n\n        #ifdef BINARY_GAUSSIAN_OP\n            #ifdef READ_WRITE_POINTS\n                @group(3) @binding(0) var<storage, read_write> rhs_points: array<Gaussian>;\n            #else\n                @group(3) @binding(0) var<storage, read> rhs_points: array<Gaussian>;\n            #endif\n\n            @group(4) @binding(0) var<storage, read_write> out_points: array<Gaussian>;\n        #endif\n    #endif\n\n    #ifdef PLANAR_F32\n        #ifdef READ_WRITE_POINTS\n            @group(2) @binding(0) var<storage, read_write> position_visibility: array<vec4<f32>>;\n        #else\n            @group(2) @binding(0) var<storage, read> position_visibility: array<vec4<f32>>;\n        #endif\n\n        @group(2) @binding(1) var<storage, read> spherical_harmonics: array<array<f32, #{SH_COEFF_COUNT}>>;\n\n        #ifdef BINARY_GAUSSIAN_OP\n            #ifdef READ_WRITE_POINTS\n                @group(3) @binding(0) var<storage, read_write> rhs_position_visibility: array<vec4<f32>>;\n            #else\n                @group(3) @binding(0) var<storage, read> rhs_position_visibility: array<vec4<f32>>;\n            #endif\n\n            @group(3) @binding(1) var<storage, read> rhs_spherical_harmonics: array<array<f32, #{SH_COEFF_COUNT}>>;\n\n            #ifdef PRECOMPUTE_COVARIANCE_3D\n                @group(3) @binding(2) var<storage, read> rhs_covariance_3d_opacity: array<array<f32, 8>>;\n            #else\n                @group(3) @binding(2) var<storage, read> rhs_rotation: array<vec4<f32>>;\n                @group(3) @binding(3) var<storage, read> rhs_scale_opacity: array<vec4<f32>>;\n            #endif\n        #endif\n\n\n        #ifdef BINARY_GAUSSIAN_OP\n            @group(4) @binding(0) var<storage, read_write> out_position_visibility: array<vec4<f32>>;\n            @group(4) @binding(1) var<storage, read_write> out_spherical_harmonics: array<array<f32, #{SH_COEFF_COUNT}>>;\n\n            #ifdef PRECOMPUTE_COVARIANCE_3D\n                @group(4) @binding(2) var<storage, read_write> out_covariance_3d_opacity: array<array<f32, 8>>;\n            #else\n                @group(4) @binding(2) var<storage, read_write> out_rotation: array<vec4<f32>>;\n                @group(4) @binding(3) var<storage, read_write> out_scale_opacity: array<vec4<f32>>;\n            #endif\n        #endif\n\n        #ifdef PRECOMPUTE_COVARIANCE_3D\n            @group(2) @binding(2) var<storage, read> covariance_3d_opacity: array<array<f32, 8>>;\n        #else\n            @group(2) @binding(2) var<storage, read> rotation: array<vec4<f32>>;\n            @group(2) @binding(3) var<storage, read> scale_opacity: array<vec4<f32>>;\n        #endif\n    #endif\n\n    #ifdef PLANAR_F16\n        #ifdef READ_WRITE_POINTS\n            @group(2) @binding(0) var<storage, read_write> position_visibility: array<vec4<f32>>;\n        #else\n            @group(2) @binding(0) var<storage, read> position_visibility: array<vec4<f32>>;\n        #endif\n\n        @group(2) @binding(1) var<storage, read> spherical_harmonics: array<array<u32, #{HALF_SH_COEFF_COUNT}>>;\n\n        #ifdef BINARY_GAUSSIAN_OP\n            #ifdef READ_WRITE_POINTS\n                @group(3) @binding(0) var<storage, read_write> rhs_position_visibility: array<vec4<f32>>;\n            #else\n                @group(3) @binding(0) var<storage, read> rhs_position_visibility: array<vec4<f32>>;\n            #endif\n\n            @group(3) @binding(1) var<storage, read> rhs_spherical_harmonics: array<array<u32, #{HALF_SH_COEFF_COUNT}>>;\n\n            #ifdef PRECOMPUTE_COVARIANCE_3D\n                @group(3) @binding(2) var<storage, read> rhs_covariance_3d_opacity: array<vec4<u32>>;\n            #else\n                @group(3) @binding(2) var<storage, read> rhs_rotation_scale_opacity: array<vec4<u32>>;\n            #endif\n\n            @group(4) @binding(0) var<storage, read_write> out_position_visibility: array<vec4<f32>>;\n            @group(4) @binding(1) var<storage, read_write> out_spherical_harmonics: array<array<u32, #{HALF_SH_COEFF_COUNT}>>;\n\n            #ifdef PRECOMPUTE_COVARIANCE_3D\n                @group(4) @binding(2) var<storage, read_write> out_covariance_3d_opacity: array<vec4<u32>>;\n            #else\n                @group(4) @binding(2) var<storage, read_write> out_rotation_scale_opacity: array<vec4<u32>>;\n            #endif\n        #endif\n\n        #ifdef PRECOMPUTE_COVARIANCE_3D\n            @group(2) @binding(2) var<storage, read> covariance_3d_opacity: array<vec4<u32>>;\n        #else\n            @group(2) @binding(2) var<storage, read> rotation_scale_opacity: array<vec4<u32>>;\n        #endif\n    #endif\n\n    #ifdef PLANAR_TEXTURE_F16\n        @group(2) @binding(0) var position_visibility: texture_2d<f32>;\n\n        #if SH_VEC4_PLANES == 1\n            @group(2) @binding(1) var spherical_harmonics: texture_2d<u32>;\n        #else\n            @group(2) @binding(1) var spherical_harmonics: texture_2d_array<u32>;\n        #endif\n\n        #ifdef PRECOMPUTE_COVARIANCE_3D\n            @group(2) @binding(2) var covariance_3d_opacity: texture_2d<u32>;\n        #else\n            @group(2) @binding(2) var rotation_scale_opacity: texture_2d<u32>;\n        #endif\n\n        #ifdef BINARY_GAUSSIAN_OP\n            @group(3) @binding(0) var rhs_position_visibility: texture_2d<f32>;\n\n            #if SH_VEC4_PLANES == 1\n                @group(3) @binding(1) var rhs_spherical_harmonics: texture_2d<u32>;\n            #else\n                @group(3) @binding(1) var rhs_spherical_harmonics: texture_2d_array<u32>;\n            #endif\n\n            #ifdef PRECOMPUTE_COVARIANCE_3D\n                @group(3) @binding(2) var rhs_covariance_3d_opacity: texture_2d<u32>;\n            #else\n                @group(3) @binding(2) var rhs_rotation_scale_opacity: texture_2d<u32>;\n            #endif\n        #endif\n    #endif\n\n    #ifdef PLANAR_TEXTURE_F32\n        @group(2) @binding(0) var position_visibility: texture_2d<f32>;\n\n        #if SH_VEC4_PLANES == 1\n            @group(2) @binding(1) var spherical_harmonics: texture_2d<f32>;\n        #else\n            @group(2) @binding(1) var spherical_harmonics: texture_2d_array<f32>;\n        #endif\n\n        // TODO: support f32_cov3d_opacity texture\n\n        @group(2) @binding(2) var rotation_scale_opacity: texture_2d<f32>;\n\n        #ifdef BINARY_GAUSSIAN_OP\n            @group(3) @binding(0) var rhs_position_visibility: texture_2d<f32>;\n\n            #if SH_VEC4_PLANES == 1\n                @group(3) @binding(1) var rhs_spherical_harmonics: texture_2d<f32>;\n            #else\n                @group(3) @binding(1) var rhs_spherical_harmonics: texture_2d_array<f32>;\n            #endif\n\n            @group(3) @binding(2) var rhs_rotation_scale_opacity: texture_2d<f32>;\n        #endif\n    #endif\n#else ifdef GAUSSIAN_4D\n    #ifdef PLANAR_F32\n        #ifdef READ_WRITE_POINTS\n            @group(2) @binding(0) var<storage, read_write> position_visibility: array<vec4<f32>>;\n        #else\n            @group(2) @binding(0) var<storage, read> position_visibility: array<vec4<f32>>;\n        #endif\n\n        @group(2) @binding(1) var<storage, read> spherindrical_harmonics: array<array<f32, #{SH_4D_COEFF_COUNT}>>;\n\n        @group(2) @binding(2) var<storage, read> isotropic_rotations: array<array<f32, 8>>;\n        @group(2) @binding(3) var<storage, read> scale_opacity: array<vec4<f32>>;\n        @group(2) @binding(4) var<storage, read> timestamp_timescale: array<vec4<f32>>;\n\n        #ifdef BINARY_GAUSSIAN_OP\n            @group(3) @binding(0) var<storage, read> rhs_position_visibility: array<vec4<f32>>;\n            @group(3) @binding(1) var<storage, read> rhs_spherindrical_harmonics: array<array<f32, #{SH_4D_COEFF_COUNT}>>;\n            @group(3) @binding(2) var<storage, read> rhs_isotropic_rotations: array<array<f32, 8>>;\n            @group(3) @binding(3) var<storage, read> rhs_scale_opacity: array<vec4<f32>>;\n            @group(3) @binding(4) var<storage, read> rhs_timestamp_timescale: array<vec4<f32>>;\n        #endif\n    #endif\n#endif\n\nstruct DrawIndirect {\n    vertex_count: u32,\n    instance_count: atomic<u32>,\n    base_vertex: u32,\n    base_instance: u32,\n}\n\nstruct Entry {\n    key: u32,\n    value: u32,\n}\n"
  },
  {
    "path": "src/render/gaussian.wgsl",
    "content": "#import bevy_gaussian_splatting::bindings::{\n    view,\n    gaussian_uniforms,\n    Entry,\n}\n#import bevy_gaussian_splatting::classification::class_to_rgb\n#import bevy_gaussian_splatting::depth::depth_to_rgb\n#import bevy_gaussian_splatting::optical_flow::{\n    calculate_motion_vector,\n    optical_flow_to_rgb,\n}\n#import bevy_gaussian_splatting::helpers::{\n    get_rotation_matrix,\n    get_scale_matrix,\n}\n#import bevy_gaussian_splatting::transform::{\n    world_to_clip,\n    in_frustum,\n}\n\n#ifdef GAUSSIAN_2D\n    #import bevy_gaussian_splatting::gaussian_2d::{\n        compute_cov2d_surfel,\n        get_bounding_box_cov2d,\n        surfel_fragment_power,\n    }\n#else ifdef GAUSSIAN_3D\n    #import bevy_gaussian_splatting::gaussian_3d::{\n        compute_cov2d_3dgs,\n    }\n    #import bevy_gaussian_splatting::helpers::{\n        get_bounding_box_clip,\n    }\n#else ifdef GAUSSIAN_4D\n    #import bevy_gaussian_splatting::gaussian_4d::{\n        conditional_cov3d,\n    }\n    #import bevy_gaussian_splatting::helpers::{\n        cov2d,\n        get_bounding_box_clip,\n    }\n#endif\n\n#ifdef PACKED\n    #ifdef PRECOMPUTE_COVARIANCE_3D\n        #import bevy_gaussian_splatting::packed::{\n            get_position,\n            get_color,\n            get_visibility,\n            get_opacity,\n            get_cov3d,\n        }\n    #else\n        #import bevy_gaussian_splatting::packed::{\n            get_position,\n            get_color,\n            get_visibility,\n            get_opacity,\n            get_rotation,\n            get_scale,\n        }\n    #endif\n#else ifdef BUFFER_STORAGE\n    #ifdef PRECOMPUTE_COVARIANCE_3D\n        #import bevy_gaussian_splatting::planar::{\n            get_position,\n            get_color,\n            get_visibility,\n            get_opacity,\n            get_cov3d,\n        }\n    #else\n        #import bevy_gaussian_splatting::planar::{\n            get_position,\n            get_color,\n            get_visibility,\n            get_opacity,\n            get_rotation,\n            get_scale,\n        }\n    #endif\n#else ifdef BUFFER_TEXTURE\n    #ifdef PRECOMPUTE_COVARIANCE_3D\n        #import bevy_gaussian_splatting::texture::{\n            get_position,\n            get_color,\n            get_visibility,\n            get_opacity,\n            get_cov3d,\n            location,\n        }\n    #else\n        #import bevy_gaussian_splatting::texture::{\n            get_position,\n            get_color,\n            get_visibility,\n            get_opacity,\n            get_rotation,\n            get_scale,\n            location,\n        }\n    #endif\n#endif\n\n#ifdef BUFFER_STORAGE\n    @group(3) @binding(0) var<storage, read> sorted_entries: array<Entry>;\n    fn get_entry(index: u32) -> Entry {\n        return sorted_entries[index];\n    }\n#else ifdef BUFFER_TEXTURE\n    @group(3) @binding(0) var sorted_entries: texture_2d<u32>;\n    fn get_entry(index: u32) -> Entry {\n        let sample = textureLoad(\n            sorted_entries,\n            location(index),\n            0,\n        );\n\n        return Entry(\n            sample.r,\n            sample.g,\n        );\n    }\n#endif\n\n#ifdef WEBGL2\n    struct GaussianVertexOutput {\n        @builtin(position) position: vec4<f32>,\n        @location(0) color: vec4<f32>,\n        @location(1) uv: vec2<f32>,\n    #ifdef GAUSSIAN_2D\n        @location(2) local_to_pixel_u: vec3<f32>,\n        @location(3) local_to_pixel_v: vec3<f32>,\n        @location(4) local_to_pixel_w: vec3<f32>,\n        @location(5) mean_2d: vec2<f32>,\n        @location(6) radius: vec2<f32>,\n    #else #ifdef GAUSSIAN_3D\n        @location(2) conic: vec3<f32>,\n        @location(3) major_minor: vec2<f32>,\n    #else #ifdef GAUSSIAN_4D\n        @location(2) conic: vec3<f32>,\n        @location(3) major_minor: vec2<f32>,\n    #endif\n    };\n#else\n    struct GaussianVertexOutput {\n        @builtin(position) position: vec4<f32>,\n        @location(0) @interpolate(flat) color: vec4<f32>,\n        @location(1) @interpolate(linear) uv: vec2<f32>,\n    #ifdef GAUSSIAN_2D\n        @location(2) @interpolate(flat) local_to_pixel_u: vec3<f32>,\n        @location(3) @interpolate(flat) local_to_pixel_v: vec3<f32>,\n        @location(4) @interpolate(flat) local_to_pixel_w: vec3<f32>,\n        @location(5) @interpolate(flat) mean_2d: vec2<f32>,\n        @location(6) @interpolate(flat) radius: vec2<f32>,\n    #else ifdef GAUSSIAN_3D\n        @location(2) @interpolate(flat) conic: vec3<f32>,\n        @location(3) @interpolate(linear) major_minor: vec2<f32>,\n    #else ifdef GAUSSIAN_4D\n        @location(2) @interpolate(flat) conic: vec3<f32>,\n        @location(3) @interpolate(linear) major_minor: vec2<f32>,\n    #endif\n    };\n#endif\n\nfn world_to_local_direction(ray_direction_world: vec3<f32>, transform: mat4x4<f32>) -> vec3<f32> {\n    let basis = mat3x3<f32>(\n        transform[0].xyz,\n        transform[1].xyz,\n        transform[2].xyz,\n    );\n    let basis_x = normalize(basis[0]);\n    let basis_y = normalize(basis[1]);\n    let basis_z = normalize(basis[2]);\n\n    let local = vec3<f32>(\n        dot(basis_x, ray_direction_world),\n        dot(basis_y, ray_direction_world),\n        dot(basis_z, ray_direction_world),\n    );\n\n    return normalize(local);\n}\n@vertex\nfn vs_points(\n    @builtin(instance_index) instance_index: u32,\n    @builtin(vertex_index) vertex_index: u32,\n) -> GaussianVertexOutput {\n    var output: GaussianVertexOutput;\n\n    let entry = get_entry(instance_index);\n    let splat_index = entry.value;\n\n    var discard_quad = false;\n\n    discard_quad |= entry.key == 0xFFFFFFFFu; // || splat_index == 0u;\n\n    let position = vec4<f32>(get_position(splat_index), 1.0);\n\n    var transformed_position = (gaussian_uniforms.transform * position).xyz;\n    var previous_transformed_position = transformed_position;\n\n#ifdef DRAW_SELECTED\n    discard_quad |= get_visibility(splat_index) < 0.5;\n#endif\n\n#ifdef GAUSSIAN_4D\n#else\n    let projected_position = world_to_clip(transformed_position);\n    discard_quad |= !in_frustum(projected_position.xyz);\n#endif\n\n    if (discard_quad) {\n        output.color = vec4<f32>(0.0, 0.0, 0.0, 0.0);\n        output.position = vec4<f32>(0.0, 0.0, 0.0, 0.0);\n        return output;\n    }\n\n    var quad_vertices = array<vec2<f32>, 4>(\n        vec2<f32>(-1.0, -1.0),\n        vec2<f32>(-1.0,  1.0),\n        vec2<f32>( 1.0, -1.0),\n        vec2<f32>( 1.0,  1.0),\n    );\n\n    let quad_index = vertex_index % 4u;\n    let quad_offset = quad_vertices[quad_index];\n\n    var opacity = get_opacity(splat_index);\n\n#ifdef OPACITY_ADAPTIVE_RADIUS\n    let cutoff = sqrt(max(9.0 + 2.0 * log(opacity), 0.000001));\n#else\n    let cutoff = 3.0;\n#endif\n\n#ifdef GAUSSIAN_2D\n    let surfel = compute_cov2d_surfel(\n        transformed_position,\n        splat_index,\n        cutoff,\n    );\n\n    output.local_to_pixel_u = surfel.local_to_pixel[0];\n    output.local_to_pixel_v = surfel.local_to_pixel[1];\n    output.local_to_pixel_w = surfel.local_to_pixel[2];\n    output.mean_2d = surfel.mean_2d;\n\n    let bb = get_bounding_box_cov2d(\n        surfel.extent,\n        quad_offset,\n        cutoff,\n    );\n    output.radius = bb.zw;\n#else\n    #ifdef GAUSSIAN_3D\n        let gaussian_cov2d = compute_cov2d_3dgs(\n            transformed_position,\n            splat_index,\n        );\n    #else ifdef GAUSSIAN_4D\n        let gaussian_4d = conditional_cov3d(\n            transformed_position,\n            splat_index,\n            gaussian_uniforms.time,\n        );\n\n        if !gaussian_4d.mask {\n            output.color = vec4<f32>(0.0, 0.0, 0.0, 0.0);\n            output.position = vec4<f32>(0.0, 0.0, 0.0, 0.0);\n            return output;\n        }\n\n        let position_t = vec4<f32>(position.xyz + gaussian_4d.delta_mean, 1.0);\n        transformed_position = (gaussian_uniforms.transform * position_t).xyz;\n        // TODO: set previous_transformed_position based on temporal position delta\n        let projected_position = world_to_clip(transformed_position);\n\n        if !in_frustum(projected_position.xyz) {\n            output.color = vec4<f32>(0.0, 0.0, 0.0, 0.0);\n            output.position = vec4<f32>(0.0, 0.0, 0.0, 0.0);\n            return output;\n        }\n\n        opacity = opacity * gaussian_4d.opacity_modifier;\n\n        let gaussian_cov2d = cov2d(\n            transformed_position,\n            gaussian_4d.cov3d,\n        );\n    #endif\n\n    let bb = get_bounding_box_clip(\n        gaussian_cov2d,\n        quad_offset,\n        cutoff,\n    );\n\n    #ifdef USE_AABB\n        let det = gaussian_cov2d.x * gaussian_cov2d.z - gaussian_cov2d.y * gaussian_cov2d.y;\n        let det_inv = 1.0 / det;\n        let conic = vec3<f32>(\n            gaussian_cov2d.z * det_inv,\n            -gaussian_cov2d.y * det_inv,\n            gaussian_cov2d.x * det_inv\n        );\n        output.conic = conic;\n        output.major_minor = bb.zw;\n    #endif\n#endif\n\n    var rgb = vec3<f32>(0.0);\n\n// TODO: RASTERIZE_ACCELERATION\n#ifdef RASTERIZE_CLASSIFICATION\n    let ray_direction_world = normalize(transformed_position - view.world_position);\n    let ray_direction_local = world_to_local_direction(ray_direction_world, gaussian_uniforms.transform);\n\n    #ifdef GAUSSIAN_3D_STRUCTURE\n        rgb = get_color(splat_index, ray_direction_local);\n    #else ifdef GAUSSIAN_4D\n        rgb = get_color(splat_index, gaussian_4d.dir_t, ray_direction_local);\n    #endif\n\n    rgb = class_to_rgb(\n        get_visibility(splat_index),\n        rgb,\n    );\n#else ifdef RASTERIZE_DEPTH\n    // TODO: unbiased depth rendering, see: https://zju3dv.github.io/pgsr/\n    let first_position = vec4<f32>(get_position(get_entry(1u).value), 1.0);\n    let last_position = vec4<f32>(get_position(get_entry(gaussian_uniforms.count - 1u).value), 1.0);\n\n    let min_position = (gaussian_uniforms.transform * last_position).xyz;\n    let max_position = (gaussian_uniforms.transform * first_position).xyz;\n\n    let camera_position = view.world_position;\n\n    let min_distance = length(min_position - camera_position);\n    let max_distance = length(max_position - camera_position);\n\n    let depth = length(transformed_position - camera_position);\n    rgb = depth_to_rgb(\n        depth,\n        min_distance,\n        max_distance,\n    );\n#else ifdef RASTERIZE_NORMAL\n    // TODO: support rotation decomposition for 4d gaussians\n    let R = get_rotation_matrix(get_rotation(splat_index));\n    let S = get_scale_matrix(get_scale(splat_index));\n    let T = mat3x3<f32>(\n        gaussian_uniforms.transform[0].xyz,\n        gaussian_uniforms.transform[1].xyz,\n        gaussian_uniforms.transform[2].xyz,\n    );\n    let L = T * S * R;\n\n    let local_normal = vec4<f32>(L[2], 0.0);\n    let world_normal = view.view_from_world * local_normal;\n\n    let t = normalize(world_normal);\n\n    rgb = vec3<f32>(\n        0.5 * (t.x + 1.0),\n        0.5 * (t.y + 1.0),\n        0.5 * (t.z + 1.0)\n    );\n#else ifdef RASTERIZE_OPTICAL_FLOW\n    let motion_vector = calculate_motion_vector(\n        transformed_position,\n        previous_transformed_position,\n    );\n\n    rgb = optical_flow_to_rgb(motion_vector);\n#else ifdef RASTERIZE_POSITION\n    rgb = (transformed_position - gaussian_uniforms.min.xyz) / (gaussian_uniforms.max.xyz - gaussian_uniforms.min.xyz);\n#else ifdef RASTERIZE_VELOCITY\n    let time_delta = 1e-3;\n    let future_gaussian_4d = conditional_cov3d(\n        transformed_position,\n        splat_index,\n        gaussian_uniforms.time + time_delta,\n    );\n    let position_delta = future_gaussian_4d.delta_mean - gaussian_4d.delta_mean;\n    let velocity = position_delta / time_delta;\n    let velocity_magnitude = length(velocity);\n    let velocity_normalized = normalize(velocity);\n\n    // TODO: magnitude normalization\n    let min_magnitude = 1.0;\n    let max_magnitude = 2.0;\n\n    let scaled_mag = clamp(\n        (velocity_magnitude - min_magnitude) / (max_magnitude - min_magnitude),\n        0.0,\n        1.0\n    );\n\n    if scaled_mag < 1e-2 {\n        opacity = 0.0;\n    }\n\n    let base_color = 0.5 * (velocity_normalized + vec3<f32>(1.0, 1.0, 1.0));\n    rgb = base_color * scaled_mag;\n#else ifdef RASTERIZE_COLOR\n    // TODO: verify color benefit for ray_direction computed at quad verticies instead of gaussian center (same as current complexity)\n    let ray_direction_world = normalize(transformed_position - view.world_position);\n    let ray_direction_local = world_to_local_direction(ray_direction_world, gaussian_uniforms.transform);\n\n    #ifdef GAUSSIAN_3D_STRUCTURE\n        rgb = get_color(splat_index, ray_direction_local);\n    #else ifdef GAUSSIAN_4D\n        rgb = get_color(splat_index, gaussian_4d.dir_t, ray_direction_local);\n    #endif\n#endif\n\n    output.color = vec4<f32>(\n        rgb,\n        opacity * gaussian_uniforms.global_opacity,\n    );\n\n#ifdef HIGHLIGHT_SELECTED\n    if (get_visibility(splat_index) > 0.5) {\n        output.color = vec4<f32>(0.3, 1.0, 0.1, 1.0);\n    }\n#endif\n\n    output.uv = quad_offset;\n    output.position = vec4<f32>(\n        projected_position.xy + bb.xy,\n        projected_position.zw,\n    );\n\n    return output;\n}\n\n@fragment\nfn fs_main(input: GaussianVertexOutput) -> @location(0) vec4<f32> {\n#ifdef USE_AABB\n#ifdef GAUSSIAN_2D\n    let radius = input.radius;\n    let mean_2d = input.mean_2d;\n    let aspect = vec2<f32>(\n        1.0,\n        view.viewport.z / view.viewport.w,\n    );\n    let pixel_coord = input.uv * radius * aspect + mean_2d;\n\n    let power = surfel_fragment_power(\n        mat3x3<f32>(\n            input.local_to_pixel_u,\n            input.local_to_pixel_v,\n            input.local_to_pixel_w,\n        ),\n        pixel_coord,\n        mean_2d,\n    );\n#else ifdef GAUSSIAN_3D\n    let d = -input.major_minor;\n    let conic = input.conic;\n    let power = -0.5 * (conic.x * d.x * d.x + conic.z * d.y * d.y) + conic.y * d.x * d.y;\n#else ifdef GAUSSIAN_4D\n    let d = -input.major_minor;\n    let conic = input.conic;\n    let power = -0.5 * (conic.x * d.x * d.x + conic.z * d.y * d.y) + conic.y * d.x * d.y;\n#endif\n\n    if (power > 0.0) {\n        discard;\n    }\n#endif\n\n#ifdef USE_OBB\n    let sigma = 1.0 / 3.0;\n    let sigma_squared = 2.0 * sigma * sigma;\n    let distance_squared = dot(input.uv, input.uv);\n\n    let power = -distance_squared / sigma_squared;\n\n    if (distance_squared > 3.0 * 3.0) {\n        discard;\n    }\n#endif\n\n#ifdef VISUALIZE_BOUNDING_BOX\n    let uv = input.uv * 0.5 + 0.5;\n    let edge_width = 0.08;\n    if (\n        (uv.x < edge_width || uv.x > 1.0 - edge_width) ||\n        (uv.y < edge_width || uv.y > 1.0 - edge_width)\n    ) {\n        return vec4<f32>(0.3, 1.0, 0.1, 1.0);\n    }\n#endif\n\n    let alpha = min(exp(power) * input.color.a, 0.999);\n\n    // TODO: round alpha to terminate depth test?\n\n    return vec4<f32>(\n        input.color.rgb * alpha,\n        alpha,\n    );\n}\n"
  },
  {
    "path": "src/render/gaussian_2d.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::gaussian_2d\n\n#ifdef GAUSSIAN_2D\n#import bevy_gaussian_splatting::bindings::{\n    view,\n    gaussian_uniforms,\n}\n#import bevy_gaussian_splatting::helpers::{\n    get_rotation_matrix,\n    get_scale_matrix,\n    intrinsic_matrix,\n}\n\n#ifdef PACKED\n    #import bevy_gaussian_splatting::packed::{\n        get_position,\n        get_color,\n        get_visibility,\n        get_opacity,\n        get_rotation,\n        get_scale,\n    }\n#else ifdef BUFFER_STORAGE\n    #import bevy_gaussian_splatting::planar::{\n        get_position,\n        get_color,\n        get_visibility,\n        get_opacity,\n        get_rotation,\n        get_scale,\n    }\n#else BUFFER_TEXTURE\n    #import bevy_gaussian_splatting::texture::{\n        get_position,\n        get_color,\n        get_visibility,\n        get_opacity,\n        get_rotation,\n        get_scale,\n    }\n#endif\n\nstruct Surfel {\n    local_to_pixel: mat3x3<f32>,\n    mean_2d: vec2<f32>,\n    extent: vec2<f32>,\n};\n\nfn get_bounding_box_cov2d(\n    extent: vec2<f32>,\n    direction: vec2<f32>,\n    cutoff: f32,\n) -> vec4<f32> {\n    let fitler_size = 0.707106;\n\n    if extent.x < 1.e-4 || extent.y < 1.e-4 {\n        return vec4<f32>(0.0);\n    }\n\n    let radius = sqrt(extent);\n    let max_radius = vec2<f32>(max(\n        max(radius.x, radius.y),\n        cutoff * fitler_size,\n    ));\n\n    // TODO: verify OBB capability\n    let radius_ndc = vec2<f32>(\n        max_radius / view.viewport.zw,\n    );\n\n    return vec4<f32>(\n        radius_ndc * direction,\n        max_radius,\n    );\n}\n\nfn compute_cov2d_surfel(\n    gaussian_position: vec3<f32>,\n    index: u32,\n    cutoff: f32,\n) -> Surfel {\n    var output: Surfel;\n\n    let rotation = get_rotation(index);\n    let scale = get_scale(index);\n\n    let T_r = mat3x3<f32>(\n        gaussian_uniforms.transform[0].xyz,\n        gaussian_uniforms.transform[1].xyz,\n        gaussian_uniforms.transform[2].xyz,\n    );\n\n    let S = get_scale_matrix(scale);\n    let R = get_rotation_matrix(rotation);\n\n    let L = T_r * transpose(R) * S;\n\n    let world_from_local = mat3x4<f32>(\n        vec4<f32>(L[0], 0.0),\n        vec4<f32>(L[1], 0.0),\n        vec4<f32>(gaussian_position, 1.0),\n    );\n\n    let ndc_from_world = transpose(view.clip_from_world);\n    let pixels_from_ndc = intrinsic_matrix();\n\n    let T = transpose(world_from_local) * ndc_from_world * pixels_from_ndc;\n\n    let test = vec3<f32>(cutoff * cutoff, cutoff * cutoff, -1.0);\n    let d = dot(test * T[2], T[2]);\n    if abs(d) < 1.0e-4 {\n        output.extent = vec2<f32>(0.0);\n        return output;\n    }\n\n    let f = (1.0 / d) * test;\n    let mean_2d = vec2<f32>(\n        dot(f, T[0] * T[2]),\n        dot(f, T[1] * T[2]),\n    );\n\n    let t = vec2<f32>(\n        dot(f * T[0], T[0]),\n        dot(f * T[1], T[1]),\n    );\n    let extent = mean_2d * mean_2d - t;\n\n    output.local_to_pixel = T;\n    output.mean_2d = mean_2d;\n    output.extent = extent;\n    return output;\n}\n\nfn surfel_fragment_power(\n    local_to_pixel: mat3x3<f32>,\n    pixel_coord: vec2<f32>,\n    mean_2d: vec2<f32>,\n) -> f32 {\n    let deltas = mean_2d - pixel_coord;\n\n    let hu = pixel_coord.x * local_to_pixel[2] - local_to_pixel[0];\n    let hv = pixel_coord.y * local_to_pixel[2] - local_to_pixel[1];\n\n    let p = cross(hu, hv);\n\n    let us = p.x / p.z;\n    let vs = p.y / p.z;\n\n    let sigmas_3d = us * us + vs * vs;\n    let sigmas_2d = 2.0 * (deltas.x * deltas.x + deltas.y * deltas.y);\n\n    let sigmas = 0.5 * min(sigmas_3d, sigmas_2d);\n    let power = -sigmas;\n\n    return power;\n}\n\n#endif  // GAUSSIAN_2D\n"
  },
  {
    "path": "src/render/gaussian_3d.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::gaussian_3d\n\n#ifdef GAUSSIAN_3D\n#import bevy_gaussian_splatting::bindings::{\n    view,\n    gaussian_uniforms,\n}\n#import bevy_gaussian_splatting::helpers::{\n    cov2d,\n    get_rotation_matrix,\n    get_scale_matrix,\n}\n\n#ifdef PACKED\n    #ifdef PRECOMPUTE_COVARIANCE_3D\n        #import bevy_gaussian_splatting::packed::{\n            get_cov3d,\n        }\n    #else\n        #import bevy_gaussian_splatting::packed::{\n            get_rotation,\n            get_scale,\n        }\n    #endif\n#else ifdef BUFFER_STORAGE\n    #ifdef PRECOMPUTE_COVARIANCE_3D\n        #import bevy_gaussian_splatting::planar::{\n            get_cov3d,\n        }\n    #else\n        #import bevy_gaussian_splatting::planar::{\n            get_rotation,\n            get_scale,\n        }\n    #endif\n#else ifdef BUFFER_TEXTURE\n    #ifdef PRECOMPUTE_COVARIANCE_3D\n        #import bevy_gaussian_splatting::texture::{\n            get_cov3d,\n        }\n    #else\n        #import bevy_gaussian_splatting::texture::{\n            get_rotation,\n            get_scale,\n        }\n    #endif\n#endif\n\nfn compute_cov3d(scale: vec3<f32>, rotation: vec4<f32>) -> array<f32, 6> {\n    let S = get_scale_matrix(scale);\n\n    let T = mat3x3<f32>(\n        gaussian_uniforms.transform[0].xyz,\n        gaussian_uniforms.transform[1].xyz,\n        gaussian_uniforms.transform[2].xyz,\n    );\n\n    let R = get_rotation_matrix(rotation);\n\n    let M = S * R;\n    let Sigma = transpose(M) * M;\n    let TS = T * Sigma * transpose(T);\n\n    return array<f32, 6>(\n        TS[0][0],\n        TS[0][1],\n        TS[0][2],\n        TS[1][1],\n        TS[1][2],\n        TS[2][2],\n    );\n}\n\nfn compute_cov2d_3dgs(\n    position: vec3<f32>,\n    index: u32,\n) -> vec3<f32> {\n#ifdef PRECOMPUTE_COVARIANCE_3D\n    let cov3d = get_cov3d(index);\n#else\n    let rotation = get_rotation(index);\n    let scale = get_scale(index);\n\n    let cov3d = compute_cov3d(scale, rotation);\n#endif\n\n    return cov2d(position, cov3d);\n}\n\n#endif  // GAUSSIAN_3D\n"
  },
  {
    "path": "src/render/gaussian_4d.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::gaussian_4d\n\n#import bevy_gaussian_splatting::bindings::{\n    view,\n    globals,\n    gaussian_uniforms,\n}\n\n#ifdef BUFFER_STORAGE\n    #import bevy_gaussian_splatting::planar::{\n        get_isotropic_rotations,\n        get_scale,\n        get_timestamp,\n        get_time_scale,\n    }\n#endif\n\nstruct DecomposedGaussian4d {\n    cov3d: array<f32, 6>,\n    delta_mean: vec3<f32>,\n    opacity_modifier: f32,\n    dir_t: f32,\n    mask: bool,\n}\n\nfn outer_product(\n    a: vec3<f32>,\n    b: vec3<f32>,\n) -> mat3x3<f32> {\n    return mat3x3<f32>(\n        a.x * b.x, a.x * b.y, a.x * b.z,\n        a.y * b.x, a.y * b.y, a.y * b.z,\n        a.z * b.x, a.z * b.y, a.z * b.z,\n    );\n}\n\nfn conditional_cov3d(\n    position: vec3<f32>,\n    index: u32,\n    time: f32,\n) -> DecomposedGaussian4d {\n    let isotropic_rotations = get_isotropic_rotations(index);\n    let rotation = isotropic_rotations[0];\n    let rotation_r = isotropic_rotations[1];\n    let scale = get_scale(index);\n\n    let dt = time - get_timestamp(index);\n\n    let S = mat4x4<f32>(\n        gaussian_uniforms.global_scale * scale.x, 0.0, 0.0, 0.0,\n        0.0, gaussian_uniforms.global_scale * scale.y, 0.0, 0.0,\n        0.0, 0.0, gaussian_uniforms.global_scale * scale.z, 0.0,\n        0.0, 0.0, 0.0, get_time_scale(index),\n    );\n\n    let w = rotation.x;\n    let x = rotation.y;\n    let y = rotation.z;\n    let z = rotation.w;\n\n    let wr = rotation_r.x;\n    let xr = rotation_r.y;\n    let yr = rotation_r.z;\n    let zr = rotation_r.w;\n\n    let M_l = mat4x4<f32>(\n        w, -x, -y, -z,\n        x,  w, -z,  y,\n        y,  z,  w, -x,\n        z, -y,  x,  w,\n    );\n\n    let M_r = mat4x4<f32>(\n        wr, -xr, -yr, -zr,\n        xr,  wr,  zr, -yr,\n        yr, -zr,  wr,  xr,\n        zr,  yr, -xr,  wr,\n    );\n\n    let R = M_r * M_l;\n    let M = R * S;\n    let Sigma = transpose(M) * M;\n\n    let cov_t = Sigma[3][3];\n    let marginal_t = exp(-0.5 * dt * dt / cov_t);\n\n    let mask = marginal_t > 0.05;\n    if !mask {\n        return DecomposedGaussian4d(\n            array<f32, 6>(0.0, 0.0, 0.0, 0.0, 0.0, 0.0),\n            vec3<f32>(0.0, 0.0, 0.0),\n            0.0,\n            dt,\n            mask,\n        );\n    }\n\n    let opacity_modifier = marginal_t;\n\n    let cov11 = mat3x3<f32>(\n        Sigma[0][0], Sigma[0][1], Sigma[0][2],\n        Sigma[1][0], Sigma[1][1], Sigma[1][2],\n        Sigma[2][0], Sigma[2][1], Sigma[2][2],\n    );\n    let cov12 = vec3<f32>(Sigma[0][3], Sigma[1][3], Sigma[2][3]);\n    let cov_op = outer_product(cov12, cov12);\n    let cov_op_t = mat3x3<f32>(\n        cov_op[0].x / cov_t, cov_op[0].y / cov_t, cov_op[0].z / cov_t,\n        cov_op[1].x / cov_t, cov_op[1].y / cov_t, cov_op[1].z / cov_t,\n        cov_op[2].x / cov_t, cov_op[2].y / cov_t, cov_op[2].z / cov_t,\n    );\n    let cov3d_condition = cov11 - cov_op_t;\n\n    let delta_mean = (cov12 / cov_t) * dt;\n\n    return DecomposedGaussian4d(\n        array<f32, 6>(\n            cov3d_condition[0][0],\n            cov3d_condition[0][1],\n            cov3d_condition[0][2],\n            cov3d_condition[1][1],\n            cov3d_condition[1][2],\n            cov3d_condition[2][2]\n        ),\n        delta_mean,\n        opacity_modifier,\n        dt,\n        mask,\n    );\n}\n"
  },
  {
    "path": "src/render/helpers.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::helpers\n\n#import bevy_gaussian_splatting::bindings::{\n    view,\n    gaussian_uniforms,\n}\n\nfn cov2d(\n    position: vec3<f32>,\n    cov3d: array<f32, 6>,\n) -> vec3<f32> {\n    let Vrk = mat3x3(\n        cov3d[0], cov3d[1], cov3d[2],\n        cov3d[1], cov3d[3], cov3d[4],\n        cov3d[2], cov3d[4], cov3d[5],\n    );\n\n    var t = view.view_from_world * vec4<f32>(position, 1.0);\n\n    let focal = vec2<f32>(\n        view.clip_from_view[0].x * view.viewport.z,\n        view.clip_from_view[1].y * view.viewport.w,\n    );\n\n    let s = 1.0 / (t.z * t.z);\n    let J = mat3x3(\n        focal.x / t.z, 0.0, -(focal.x * t.x) * s,\n        0.0, -focal.y / t.z, (focal.y * t.y) * s,\n        0.0, 0.0, 0.0,\n    );\n\n    let W = transpose(\n        mat3x3<f32>(\n            view.view_from_world[0].xyz,\n            view.view_from_world[1].xyz,\n            view.view_from_world[2].xyz,\n        )\n    );\n\n    let T = W * J;\n\n    var cov = transpose(T) * transpose(Vrk) * T;\n    cov[0][0] += 0.3f;\n    cov[1][1] += 0.3f;\n\n    return vec3<f32>(cov[0][0], cov[0][1], cov[1][1]);\n}\n\nfn get_bounding_box_clip(\n    cov2d: vec3<f32>,\n    direction: vec2<f32>,\n    cutoff: f32,\n) -> vec4<f32> {\n    // return vec4<f32>(offset, uv);\n\n    let det = cov2d.x * cov2d.z - cov2d.y * cov2d.y;\n    let trace = cov2d.x + cov2d.z;\n    let mid = 0.5 * trace;\n    let discriminant = max(0.0, mid * mid - det);\n\n    let term = sqrt(discriminant);\n\n    let lambda1 = mid + term;\n    let lambda2 = max(mid - term, 0.0);\n\n    let x_axis_length = sqrt(lambda1);\n    let y_axis_length = sqrt(lambda2);\n\n#ifdef USE_AABB\n    let radius_px = cutoff * max(x_axis_length, y_axis_length);\n    let radius_ndc = vec2<f32>(\n        radius_px / view.viewport.zw,\n    );\n\n    return vec4<f32>(\n        radius_ndc * direction,\n        radius_px * direction,\n    );\n#endif\n\n#ifdef USE_OBB\n\n    let a = (cov2d.x - cov2d.z) * (cov2d.x - cov2d.z);\n    let b = sqrt(a + 4.0 * cov2d.y * cov2d.y);\n    let major_radius = sqrt((cov2d.x + cov2d.z + b) * 0.5);\n    let minor_radius = sqrt((cov2d.x + cov2d.z - b) * 0.5);\n\n    let bounds = cutoff * vec2<f32>(\n        major_radius,\n        minor_radius,\n    );\n\n    let eigvec1 = normalize(vec2<f32>(\n        -cov2d.y,\n        lambda1 - cov2d.x,\n    ));\n    let eigvec2 = vec2<f32>(\n        eigvec1.y,\n        -eigvec1.x\n    );\n\n    let rotation_matrix = transpose(\n        mat2x2(\n            eigvec1,\n            eigvec2,\n        )\n    );\n\n    let scaled_vertex = direction * bounds;\n    let rotated_vertex = scaled_vertex * rotation_matrix;\n\n    let scaling_factor = 1.0 / view.viewport.zw;\n    let ndc_vertex = rotated_vertex * scaling_factor;\n\n    return vec4<f32>(\n        ndc_vertex,\n        rotated_vertex,\n    );\n#endif\n}\n\nfn intrinsic_matrix() -> mat3x4<f32> {\n    let focal = vec2<f32>(\n        view.clip_from_view[0].x * view.viewport.z / 2.0,\n        view.clip_from_view[1].y * view.viewport.w / 2.0,\n    );\n\n    let Ks = mat3x4<f32>(\n        vec4<f32>(focal.x, 0.0, 0.0, (view.viewport.z - 1.0) / 2.0),\n        vec4<f32>(0.0, focal.y, 0.0, (view.viewport.w - 1.0) / 2.0),\n        vec4<f32>(0.0, 0.0, 0.0, 1.0)\n    );\n\n    return Ks;\n}\n\nfn get_rotation_matrix(\n    rotation: vec4<f32>,\n) -> mat3x3<f32> {\n    let r = rotation.x;\n    let x = rotation.y;\n    let y = rotation.z;\n    let z = rotation.w;\n\n    return mat3x3<f32>(\n        1.0 - 2.0 * (y * y + z * z),\n        2.0 * (x * y - r * z),\n        2.0 * (x * z + r * y),\n\n        2.0 * (x * y + r * z),\n        1.0 - 2.0 * (x * x + z * z),\n        2.0 * (y * z - r * x),\n\n        2.0 * (x * z - r * y),\n        2.0 * (y * z + r * x),\n        1.0 - 2.0 * (x * x + y * y),\n    );\n}\n\nfn get_scale_matrix(\n    scale: vec3<f32>,\n) -> mat3x3<f32> {\n    return mat3x3<f32>(\n        scale.x * gaussian_uniforms.global_scale, 0.0, 0.0,\n        0.0, scale.y * gaussian_uniforms.global_scale, 0.0,\n        0.0, 0.0, scale.z * gaussian_uniforms.global_scale,\n    );\n}\n"
  },
  {
    "path": "src/render/mod.rs",
    "content": "#![allow(dead_code)] // ShaderType derives emit unused check helpers\nuse std::{borrow::Cow, hash::Hash, num::NonZero};\n\nuse bevy::render::render_resource::TextureFormat;\nuse bevy::shader::ShaderDefVal;\nuse bevy::{\n    asset::{AssetEvent, AssetId, load_internal_asset, uuid_handle},\n    camera::primitives::Aabb,\n    core_pipeline::{\n        core_3d::Transparent3d,\n        prepass::{\n            MotionVectorPrepass, PreviousViewData, PreviousViewUniformOffset, PreviousViewUniforms,\n        },\n    },\n    ecs::{\n        query::ROQueryItem,\n        system::{SystemParamItem, lifetimeless::*},\n    },\n    pbr::PrepassViewBindGroup,\n    prelude::*,\n    render::{\n        Extract, Render, RenderApp, RenderSystems,\n        extract_component::{ComponentUniforms, DynamicUniformIndex, UniformComponentPlugin},\n        globals::{GlobalsBuffer, GlobalsUniform},\n        render_asset::RenderAssets,\n        render_phase::{\n            AddRenderCommand, DrawFunctions, PhaseItem, PhaseItemExtraIndex, RenderCommand,\n            RenderCommandResult, SetItemPipeline, TrackedRenderPass, ViewSortedRenderPhases,\n        },\n        render_resource::*,\n        renderer::RenderDevice,\n        sync_world::RenderEntity,\n        view::{\n            ExtractedView, RenderVisibilityRanges, RenderVisibleEntities,\n            VISIBILITY_RANGES_STORAGE_BUFFER_COUNT, ViewUniform, ViewUniformOffset, ViewUniforms,\n        },\n    },\n};\nuse bevy_interleave::prelude::*;\n\n#[cfg(feature = \"buffer_storage\")]\nuse crate::sort::SortEntry;\nuse crate::{\n    camera::GaussianCamera,\n    gaussian::{\n        cloud::CloudVisibilityClass,\n        interface::CommonCloud,\n        settings::{CloudSettings, DrawMode, GaussianColorSpace, GaussianMode, RasterizeMode},\n    },\n    material::{\n        spherical_harmonics::{HALF_SH_COEFF_COUNT, SH_COEFF_COUNT, SH_DEGREE, SH_VEC4_PLANES},\n        spherindrical_harmonics::{SH_4D_COEFF_COUNT, SH_4D_DEGREE_TIME},\n    },\n    morph::MorphPlugin,\n    sort::{GpuSortedEntry, SortPlugin, SortTrigger, SortedEntriesHandle},\n};\n\n#[cfg(feature = \"packed\")]\nmod packed;\n\n#[cfg(feature = \"buffer_storage\")]\nmod planar;\n\n#[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))]\nmod texture;\n\nconst BINDINGS_SHADER_HANDLE: Handle<Shader> = uuid_handle!(\"cfd9a3d9-a0cb-40c8-ab0b-073110a02474\");\nconst GAUSSIAN_SHADER_HANDLE: Handle<Shader> = uuid_handle!(\"9a18d83b-137d-4f44-9628-e2defc4b62b0\");\nconst GAUSSIAN_2D_SHADER_HANDLE: Handle<Shader> =\n    uuid_handle!(\"713fb941-b4f5-408e-bbde-32fb7dc447ce\");\nconst GAUSSIAN_3D_SHADER_HANDLE: Handle<Shader> =\n    uuid_handle!(\"b7eb322b-983b-4ce0-a5a2-3c0d6cb06d65\");\nconst GAUSSIAN_4D_SHADER_HANDLE: Handle<Shader> =\n    uuid_handle!(\"26234995-0932-4dfa-ab8d-53df1e779dd4\");\nconst HELPERS_SHADER_HANDLE: Handle<Shader> = uuid_handle!(\"9ca57ab0-07de-4a43-94f8-547c38e292cb\");\nconst PACKED_SHADER_HANDLE: Handle<Shader> = uuid_handle!(\"5bb62086-7004-4575-9972-274dc8acccf1\");\nconst PLANAR_SHADER_HANDLE: Handle<Shader> = uuid_handle!(\"d6a3f978-f795-4786-8475-26366f28d852\");\nconst TEXTURE_SHADER_HANDLE: Handle<Shader> = uuid_handle!(\"500e2ebf-51a8-402e-9c88-e0d5152c3486\");\nconst TRANSFORM_SHADER_HANDLE: Handle<Shader> =\n    uuid_handle!(\"648516b2-87cc-4937-ae1c-d986952e9fa7\");\n\n// TODO: consider refactor to bind via bevy's mesh (dynamic vertex planes) + shared batching/instancing/preprocessing\n//       utilize RawBufferVec<T> for gaussian data?\npub struct RenderPipelinePlugin<R: PlanarSync> {\n    _phantom: std::marker::PhantomData<R>,\n}\n\nimpl<R: PlanarSync> Default for RenderPipelinePlugin<R> {\n    fn default() -> Self {\n        Self {\n            _phantom: std::marker::PhantomData,\n        }\n    }\n}\n\nimpl<R: PlanarSync> Plugin for RenderPipelinePlugin<R>\nwhere\n    R::PlanarType: CommonCloud,\n    R::GpuPlanarType: GpuPlanarStorage,\n    <R::GpuPlanarType as GpuPlanar>::PackedType: ReflectInterleaved,\n{\n    fn build(&self, app: &mut App) {\n        debug!(\"building render pipeline plugin\");\n\n        app.add_plugins(MorphPlugin::<R>::default());\n        app.add_plugins(SortPlugin::<R>::default());\n        app.init_resource::<PlanarStorageRebindQueue<R>>();\n        app.add_systems(PostUpdate, queue_planar_storage_rebinds::<R>);\n\n        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n            render_app\n                .add_render_command::<Transparent3d, DrawGaussians<R>>()\n                .init_resource::<GaussianUniformBindGroups>()\n                .init_resource::<PlanarStorageRebindQueue<R>>()\n                .add_systems(\n                    ExtractSchedule,\n                    (\n                        extract_gaussians::<R>,\n                        extract_planar_storage_rebind_queue::<R>,\n                    ),\n                )\n                .add_systems(\n                    Render,\n                    (\n                        refresh_planar_storage_bind_groups::<R>\n                            .in_set(RenderSystems::PrepareBindGroups),\n                        queue_gaussian_bind_group::<R>.in_set(RenderSystems::PrepareBindGroups),\n                        queue_gaussian_view_bind_groups::<R>\n                            .in_set(RenderSystems::PrepareBindGroups),\n                        queue_gaussian_compute_view_bind_groups::<R>\n                            .in_set(RenderSystems::PrepareBindGroups),\n                        queue_gaussians::<R>.in_set(RenderSystems::Queue),\n                    ),\n                );\n        }\n\n        // TODO: refactor common resources into a common plugin\n        if app.is_plugin_added::<UniformComponentPlugin<CloudUniform>>() {\n            debug!(\"render plugin already added\");\n            return;\n        }\n\n        load_internal_asset!(\n            app,\n            BINDINGS_SHADER_HANDLE,\n            \"bindings.wgsl\",\n            Shader::from_wgsl\n        );\n\n        load_internal_asset!(\n            app,\n            GAUSSIAN_SHADER_HANDLE,\n            \"gaussian.wgsl\",\n            Shader::from_wgsl\n        );\n\n        load_internal_asset!(\n            app,\n            GAUSSIAN_2D_SHADER_HANDLE,\n            \"gaussian_2d.wgsl\",\n            Shader::from_wgsl\n        );\n\n        load_internal_asset!(\n            app,\n            GAUSSIAN_3D_SHADER_HANDLE,\n            \"gaussian_3d.wgsl\",\n            Shader::from_wgsl\n        );\n\n        load_internal_asset!(\n            app,\n            GAUSSIAN_4D_SHADER_HANDLE,\n            \"gaussian_4d.wgsl\",\n            Shader::from_wgsl\n        );\n\n        load_internal_asset!(\n            app,\n            HELPERS_SHADER_HANDLE,\n            \"helpers.wgsl\",\n            Shader::from_wgsl\n        );\n\n        load_internal_asset!(app, PACKED_SHADER_HANDLE, \"packed.wgsl\", Shader::from_wgsl);\n\n        load_internal_asset!(app, PLANAR_SHADER_HANDLE, \"planar.wgsl\", Shader::from_wgsl);\n\n        load_internal_asset!(\n            app,\n            TEXTURE_SHADER_HANDLE,\n            \"texture.wgsl\",\n            Shader::from_wgsl\n        );\n\n        load_internal_asset!(\n            app,\n            TRANSFORM_SHADER_HANDLE,\n            \"transform.wgsl\",\n            Shader::from_wgsl\n        );\n\n        app.add_plugins(UniformComponentPlugin::<CloudUniform>::default());\n\n        #[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))]\n        app.add_plugins(texture::BufferTexturePlugin);\n    }\n\n    fn finish(&self, app: &mut App) {\n        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n            render_app\n                .init_resource::<CloudPipeline<R>>()\n                .init_resource::<SpecializedRenderPipelines<CloudPipeline<R>>>();\n        }\n    }\n}\n\n#[derive(Resource)]\npub struct PlanarStorageRebindQueue<R: PlanarSync> {\n    handles: Vec<AssetId<R::PlanarType>>,\n    marker: std::marker::PhantomData<R>,\n}\n\nimpl<R: PlanarSync> Default for PlanarStorageRebindQueue<R> {\n    fn default() -> Self {\n        Self {\n            handles: Vec::new(),\n            marker: std::marker::PhantomData,\n        }\n    }\n}\n\nimpl<R: PlanarSync> Clone for PlanarStorageRebindQueue<R> {\n    fn clone(&self) -> Self {\n        Self {\n            handles: self.handles.clone(),\n            marker: std::marker::PhantomData,\n        }\n    }\n}\n\nimpl<R: PlanarSync> PlanarStorageRebindQueue<R> {\n    pub fn push_unique(&mut self, id: AssetId<R::PlanarType>) {\n        if !self.handles.contains(&id) {\n            self.handles.push(id);\n        }\n    }\n}\n\nfn queue_planar_storage_rebinds<R: PlanarSync>(\n    mut events: MessageReader<AssetEvent<R::PlanarType>>,\n    mut queue: ResMut<PlanarStorageRebindQueue<R>>,\n) {\n    for event in events.read() {\n        match event {\n            AssetEvent::Modified { id } | AssetEvent::LoadedWithDependencies { id } => {\n                queue.push_unique(*id);\n            }\n            AssetEvent::Removed { id } => {\n                queue.handles.retain(|handle_id| handle_id != id);\n            }\n            AssetEvent::Added { .. } | AssetEvent::Unused { .. } => {}\n        }\n    }\n}\n\nfn extract_planar_storage_rebind_queue<R: PlanarSync>(\n    mut commands: Commands,\n    mut main_world: ResMut<bevy::render::MainWorld>,\n) {\n    let mut queue = main_world.resource_mut::<PlanarStorageRebindQueue<R>>();\n    commands.insert_resource(queue.clone());\n    queue.handles.clear();\n}\n\nfn refresh_planar_storage_bind_groups<R: PlanarSync>(\n    mut commands: Commands,\n    render_device: Res<RenderDevice>,\n    gpu_planars: Res<RenderAssets<R::GpuPlanarType>>,\n    bind_group_layouts: Res<bevy_interleave::interface::storage::PlanarStorageLayouts<R>>,\n    mut queue: ResMut<PlanarStorageRebindQueue<R>>,\n    query: Query<(Entity, &R::PlanarTypeHandle)>,\n) where\n    R::GpuPlanarType: GpuPlanarStorage,\n{\n    if queue.handles.is_empty() {\n        return;\n    }\n\n    let layout = &bind_group_layouts.bind_group_layout;\n    let handles = queue.handles.clone();\n    queue.handles.clear();\n\n    for id in handles {\n        for (entity, planar_handle) in query.iter() {\n            if planar_handle.handle().id() != id {\n                continue;\n            }\n\n            if let Some(gpu_planar) = gpu_planars.get(planar_handle.handle()) {\n                let bind_group = gpu_planar.bind_group(render_device.as_ref(), layout);\n\n                commands.entity(entity).insert(PlanarStorageBindGroup::<R> {\n                    bind_group,\n                    phantom: std::marker::PhantomData,\n                });\n            }\n        }\n    }\n}\n\n#[derive(Bundle)]\npub struct GpuCloudBundle<R: PlanarSync> {\n    pub aabb: Aabb,\n    pub settings: CloudSettings,\n    pub settings_uniform: CloudUniform,\n    pub sorted_entries: SortedEntriesHandle,\n    pub cloud_handle: R::PlanarTypeHandle,\n    pub transform: GlobalTransform,\n}\n\n#[allow(type_alias_bounds)]\ntype GpuCloudBundleQuery<R: bevy_interleave::prelude::PlanarSync> = (\n    Entity,\n    &'static <R as bevy_interleave::prelude::PlanarSync>::PlanarTypeHandle,\n    &'static Aabb,\n    &'static SortedEntriesHandle,\n    &'static CloudSettings,\n    &'static GlobalTransform,\n);\n\n#[allow(type_alias_bounds)]\ntype GpuCloudBindGroupQuery<R: bevy_interleave::prelude::PlanarSync> = (\n    Entity,\n    &'static <R as bevy_interleave::prelude::PlanarSync>::PlanarTypeHandle,\n    &'static SortedEntriesHandle,\n    Option<&'static SortBindGroup>,\n);\n\n#[allow(clippy::too_many_arguments)]\nfn queue_gaussians<R: PlanarSync>(\n    gaussian_cloud_uniform: Res<ComponentUniforms<CloudUniform>>,\n    transparent_3d_draw_functions: Res<DrawFunctions<Transparent3d>>,\n    custom_pipeline: Res<CloudPipeline<R>>,\n    mut pipelines: ResMut<SpecializedRenderPipelines<CloudPipeline<R>>>,\n    pipeline_cache: Res<PipelineCache>,\n    gaussian_clouds: Res<RenderAssets<R::GpuPlanarType>>,\n    sorted_entries: Res<RenderAssets<GpuSortedEntry>>,\n    mut transparent_render_phases: ResMut<ViewSortedRenderPhases<Transparent3d>>,\n    mut views: Query<(\n        &ExtractedView,\n        &GaussianCamera,\n        &RenderVisibleEntities,\n        Option<&Msaa>,\n    )>,\n    gaussian_splatting_bundles: Query<GpuCloudBundleQuery<R>>,\n) {\n    debug!(\"queue_gaussians\");\n\n    let warmup = views.iter().any(|(_, camera, _, _)| camera.warmup);\n    if warmup {\n        debug!(\"skipping gaussian cloud render during warmup\");\n        return;\n    }\n\n    // TODO: condition this system based on CloudBindGroup attachment\n    if gaussian_cloud_uniform.buffer().is_none() {\n        debug!(\"uniform buffer not initialized\");\n        return;\n    };\n\n    let draw_custom = transparent_3d_draw_functions\n        .read()\n        .id::<DrawGaussians<R>>();\n\n    for (view, _, visible_entities, msaa) in &mut views {\n        debug!(\"queue gaussians view\");\n        let Some(transparent_phase) = transparent_render_phases.get_mut(&view.retained_view_entity)\n        else {\n            debug!(\"transparent phase not found\");\n            continue;\n        };\n\n        debug!(\"visible entities...\");\n        for (render_entity, visible_entity) in visible_entities.iter::<CloudVisibilityClass>() {\n            if gaussian_splatting_bundles.get(*render_entity).is_err() {\n                debug!(\"gaussian splatting bundle not found\");\n                continue;\n            }\n\n            let (_entity, cloud_handle, aabb, sorted_entries_handle, settings, transform) =\n                gaussian_splatting_bundles.get(*render_entity).unwrap();\n\n            debug!(\"queue gaussians clouds\");\n            if gaussian_clouds.get(cloud_handle.handle()).is_none() {\n                debug!(\"gaussian cloud asset not found\");\n                return;\n            }\n\n            if sorted_entries.get(sorted_entries_handle).is_none() {\n                debug!(\"sorted entries asset not found\");\n                return;\n            }\n\n            let msaa = msaa.cloned().unwrap_or_default();\n\n            let key = CloudPipelineKey {\n                aabb: settings.aabb,\n                binary_gaussian_op: false,\n                opacity_adaptive_radius: settings.opacity_adaptive_radius,\n                visualize_bounding_box: settings.visualize_bounding_box,\n                draw_mode: settings.draw_mode,\n                gaussian_mode: settings.gaussian_mode,\n                rasterize_mode: settings.rasterize_mode,\n                sample_count: msaa.samples(),\n                hdr: view.hdr,\n            };\n\n            let pipeline = pipelines.specialize(&pipeline_cache, &custom_pipeline, key);\n\n            let rangefinder = view.rangefinder3d();\n            let aabb_center = (aabb.min() + aabb.max()) / 2.0;\n            let aabb_size = aabb.max() - aabb.min();\n            let center = *transform\n                * GlobalTransform::from(\n                    Transform::from_translation(aabb_center.into()).with_scale(aabb_size.into()),\n                );\n            let distance = rangefinder.distance(&center.translation());\n\n            transparent_phase.add(Transparent3d {\n                entity: (*render_entity, *visible_entity),\n                draw_function: draw_custom,\n                distance,\n                pipeline,\n                batch_range: 0..1,\n                extra_index: PhaseItemExtraIndex::None,\n                indexed: false,\n            });\n        }\n    }\n}\n\n// TODO: pipeline trait\n// TODO: support extentions /w ComputePipelineDescriptor builder\n#[derive(Resource)]\npub struct CloudPipeline<R: PlanarSync> {\n    shader: Handle<Shader>,\n    pub gaussian_cloud_layout: BindGroupLayout,\n    pub gaussian_cloud_layout_desc: BindGroupLayoutDescriptor,\n    pub gaussian_uniform_layout: BindGroupLayout,\n    pub gaussian_uniform_layout_desc: BindGroupLayoutDescriptor,\n    pub view_layout: BindGroupLayout,\n    pub view_layout_desc: BindGroupLayoutDescriptor,\n    pub compute_view_layout: BindGroupLayout,\n    pub compute_view_layout_desc: BindGroupLayoutDescriptor,\n    pub sorted_layout: BindGroupLayout,\n    pub sorted_layout_desc: BindGroupLayoutDescriptor,\n    phantom: std::marker::PhantomData<R>,\n}\n\nfn buffer_layout(\n    buffer_binding_type: BufferBindingType,\n    has_dynamic_offset: bool,\n    min_binding_size: Option<NonZero<u64>>,\n) -> BindGroupLayoutEntryBuilder {\n    match buffer_binding_type {\n        BufferBindingType::Uniform => {\n            binding_types::uniform_buffer_sized(has_dynamic_offset, min_binding_size)\n        }\n        BufferBindingType::Storage { read_only } => {\n            if read_only {\n                binding_types::storage_buffer_read_only_sized(has_dynamic_offset, min_binding_size)\n            } else {\n                binding_types::storage_buffer_sized(has_dynamic_offset, min_binding_size)\n            }\n        }\n    }\n}\n\npub(crate) fn storage_layout_descriptor<P: ReflectInterleaved>(\n    label: impl Into<Cow<'static, str>>,\n    read_only: bool,\n) -> BindGroupLayoutDescriptor {\n    let entries = P::min_binding_sizes()\n        .iter()\n        .enumerate()\n        .map(|(idx, size)| BindGroupLayoutEntry {\n            binding: idx as u32,\n            visibility: ShaderStages::VERTEX_FRAGMENT | ShaderStages::COMPUTE,\n            ty: BindingType::Buffer {\n                ty: BufferBindingType::Storage { read_only },\n                has_dynamic_offset: false,\n                min_binding_size: BufferSize::new(*size as u64),\n            },\n            count: None,\n        })\n        .collect::<Vec<_>>();\n\n    BindGroupLayoutDescriptor::new(label, &entries)\n}\n\nimpl<R: PlanarSync> FromWorld for CloudPipeline<R>\nwhere\n    R::GpuPlanarType: GpuPlanarStorage,\n    <R::GpuPlanarType as GpuPlanar>::PackedType: ReflectInterleaved,\n{\n    fn from_world(render_world: &mut World) -> Self {\n        let render_device = render_world.resource::<RenderDevice>();\n\n        let visibility_ranges_buffer_binding_type = render_device\n            .get_supported_read_only_binding_type(VISIBILITY_RANGES_STORAGE_BUFFER_COUNT);\n\n        let visibility_ranges_entry = buffer_layout(\n            visibility_ranges_buffer_binding_type,\n            false,\n            Some(Vec4::min_size()),\n        )\n        .build(14, ShaderStages::VERTEX);\n\n        let view_layout_entries = vec![\n            BindGroupLayoutEntry {\n                binding: 0,\n                visibility: ShaderStages::VERTEX_FRAGMENT,\n                ty: BindingType::Buffer {\n                    ty: BufferBindingType::Uniform,\n                    has_dynamic_offset: true,\n                    min_binding_size: Some(ViewUniform::min_size()),\n                },\n                count: None,\n            },\n            BindGroupLayoutEntry {\n                binding: 1,\n                visibility: ShaderStages::VERTEX_FRAGMENT,\n                ty: BindingType::Buffer {\n                    ty: BufferBindingType::Uniform,\n                    has_dynamic_offset: false,\n                    min_binding_size: Some(GlobalsUniform::min_size()),\n                },\n                count: None,\n            },\n            BindGroupLayoutEntry {\n                binding: 2,\n                visibility: ShaderStages::VERTEX_FRAGMENT,\n                ty: BindingType::Buffer {\n                    ty: BufferBindingType::Uniform,\n                    has_dynamic_offset: true,\n                    min_binding_size: Some(PreviousViewData::min_size()),\n                },\n                count: None,\n            },\n            visibility_ranges_entry,\n        ];\n\n        let compute_view_layout_entries = vec![\n            BindGroupLayoutEntry {\n                binding: 0,\n                visibility: ShaderStages::VERTEX_FRAGMENT | ShaderStages::COMPUTE,\n                ty: BindingType::Buffer {\n                    ty: BufferBindingType::Uniform,\n                    has_dynamic_offset: true,\n                    min_binding_size: Some(ViewUniform::min_size()),\n                },\n                count: None,\n            },\n            BindGroupLayoutEntry {\n                binding: 1,\n                visibility: ShaderStages::VERTEX_FRAGMENT | ShaderStages::COMPUTE,\n                ty: BindingType::Buffer {\n                    ty: BufferBindingType::Uniform,\n                    has_dynamic_offset: false,\n                    min_binding_size: Some(GlobalsUniform::min_size()),\n                },\n                count: None,\n            },\n            BindGroupLayoutEntry {\n                binding: 2,\n                visibility: ShaderStages::VERTEX_FRAGMENT | ShaderStages::COMPUTE,\n                ty: BindingType::Buffer {\n                    ty: BufferBindingType::Uniform,\n                    has_dynamic_offset: true,\n                    min_binding_size: Some(PreviousViewData::min_size()),\n                },\n                count: None,\n            },\n            visibility_ranges_entry,\n        ];\n\n        let view_layout_desc =\n            BindGroupLayoutDescriptor::new(\"gaussian_view_layout\", &view_layout_entries);\n        let view_layout = render_device\n            .create_bind_group_layout(Some(\"gaussian_view_layout\"), &view_layout_entries);\n\n        let compute_view_layout_desc = BindGroupLayoutDescriptor::new(\n            \"gaussian_compute_view_layout\",\n            &compute_view_layout_entries,\n        );\n        let compute_view_layout = render_device.create_bind_group_layout(\n            Some(\"gaussian_compute_view_layout\"),\n            &compute_view_layout_entries,\n        );\n\n        let gaussian_uniform_layout_entries = [BindGroupLayoutEntry {\n            binding: 0,\n            visibility: ShaderStages::VERTEX_FRAGMENT | ShaderStages::COMPUTE,\n            ty: BindingType::Buffer {\n                ty: BufferBindingType::Uniform,\n                has_dynamic_offset: true,\n                min_binding_size: Some(CloudUniform::min_size()),\n            },\n            count: None,\n        }];\n        let gaussian_uniform_layout_desc = BindGroupLayoutDescriptor::new(\n            \"gaussian_uniform_layout\",\n            &gaussian_uniform_layout_entries,\n        );\n        let gaussian_uniform_layout = render_device.create_bind_group_layout(\n            Some(\"gaussian_uniform_layout\"),\n            &gaussian_uniform_layout_entries,\n        );\n\n        #[cfg(not(feature = \"morph_particles\"))]\n        let read_only = true;\n        #[cfg(feature = \"morph_particles\")]\n        let read_only = false;\n\n        let gaussian_cloud_layout = R::GpuPlanarType::bind_group_layout(render_device, read_only);\n        let gaussian_cloud_layout_desc = storage_layout_descriptor::<\n            <R::GpuPlanarType as GpuPlanar>::PackedType,\n        >(\"gaussian_cloud_layout\", read_only);\n\n        #[cfg(feature = \"buffer_storage\")]\n        let sorted_layout_entries = [BindGroupLayoutEntry {\n            binding: 0,\n            visibility: ShaderStages::VERTEX_FRAGMENT,\n            ty: BindingType::Buffer {\n                ty: BufferBindingType::Storage { read_only: true },\n                has_dynamic_offset: true,\n                min_binding_size: BufferSize::new(std::mem::size_of::<SortEntry>() as u64),\n            },\n            count: None,\n        }];\n        #[cfg(feature = \"buffer_storage\")]\n        let sorted_layout_desc =\n            BindGroupLayoutDescriptor::new(\"sorted_layout\", &sorted_layout_entries);\n        #[cfg(feature = \"buffer_storage\")]\n        let sorted_layout =\n            render_device.create_bind_group_layout(Some(\"sorted_layout\"), &sorted_layout_entries);\n        #[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))]\n        let sorted_layout = texture::get_sorted_bind_group_layout(render_device);\n        #[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))]\n        let sorted_layout_desc = BindGroupLayoutDescriptor::new(\n            \"texture_sorted_layout\",\n            &[BindGroupLayoutEntry {\n                binding: 0,\n                visibility: ShaderStages::VERTEX_FRAGMENT | ShaderStages::COMPUTE,\n                ty: BindingType::Texture {\n                    view_dimension: TextureViewDimension::D2,\n                    sample_type: TextureSampleType::Uint,\n                    multisampled: false,\n                },\n                count: None,\n            }],\n        );\n\n        debug!(\"created cloud pipeline\");\n\n        Self {\n            gaussian_cloud_layout,\n            gaussian_cloud_layout_desc,\n            gaussian_uniform_layout,\n            gaussian_uniform_layout_desc,\n            view_layout,\n            view_layout_desc,\n            compute_view_layout,\n            compute_view_layout_desc,\n            shader: GAUSSIAN_SHADER_HANDLE,\n            sorted_layout,\n            sorted_layout_desc,\n            phantom: std::marker::PhantomData,\n        }\n    }\n}\n\n// TODO: allow setting shader defines via API\n// TODO: separate shader defines for each pipeline\npub struct ShaderDefines {\n    pub radix_bits_per_digit: u32,\n    pub radix_digit_places: u32,\n    pub radix_base: u32,\n    pub entries_per_invocation_a: u32,\n    pub entries_per_invocation_c: u32,\n    pub workgroup_invocations_a: u32,\n    pub workgroup_invocations_c: u32,\n    pub workgroup_entries_a: u32,\n    pub workgroup_entries_c: u32,\n    pub sorting_buffer_size: u32,\n\n    pub temporal_sort_window_size: u32,\n}\n\nimpl ShaderDefines {\n    pub fn max_tile_count(&self, count: usize) -> u32 {\n        (count as u32).div_ceil(self.workgroup_entries_c)\n    }\n\n    pub fn sorting_status_counters_buffer_size(&self, count: usize) -> usize {\n        self.radix_base as usize * self.max_tile_count(count) as usize * std::mem::size_of::<u32>()\n    }\n}\n\nimpl Default for ShaderDefines {\n    fn default() -> Self {\n        let radix_bits_per_digit = 8;\n        let radix_digit_places = 32 / radix_bits_per_digit;\n        let radix_base = 1 << radix_bits_per_digit;\n        let entries_per_invocation_a = 4;\n        let entries_per_invocation_c = 4;\n        let workgroup_invocations_a = radix_base * radix_digit_places;\n        let workgroup_invocations_c = radix_base;\n        let workgroup_entries_a = workgroup_invocations_a * entries_per_invocation_a;\n        let workgroup_entries_c = workgroup_invocations_c * entries_per_invocation_c;\n        let sorting_buffer_size =\n            radix_base * radix_digit_places * std::mem::size_of::<u32>() as u32\n                + (5 + radix_base) * std::mem::size_of::<u32>() as u32;\n\n        Self {\n            radix_bits_per_digit,\n            radix_digit_places,\n            radix_base,\n            entries_per_invocation_a,\n            entries_per_invocation_c,\n            workgroup_invocations_a,\n            workgroup_invocations_c,\n            workgroup_entries_a,\n            workgroup_entries_c,\n            sorting_buffer_size,\n\n            temporal_sort_window_size: 16,\n        }\n    }\n}\n\npub fn shader_defs(key: CloudPipelineKey) -> Vec<ShaderDefVal> {\n    let defines = ShaderDefines::default();\n    let mut shader_defs = vec![\n        ShaderDefVal::UInt(\"SH_COEFF_COUNT\".into(), SH_COEFF_COUNT as u32),\n        ShaderDefVal::UInt(\"SH_4D_COEFF_COUNT\".into(), SH_4D_COEFF_COUNT as u32),\n        ShaderDefVal::UInt(\"SH_DEGREE\".into(), SH_DEGREE as u32),\n        ShaderDefVal::UInt(\"SH_DEGREE_TIME\".into(), SH_4D_DEGREE_TIME as u32),\n        ShaderDefVal::UInt(\"HALF_SH_COEFF_COUNT\".into(), HALF_SH_COEFF_COUNT as u32),\n        ShaderDefVal::UInt(\"SH_VEC4_PLANES\".into(), SH_VEC4_PLANES as u32),\n        ShaderDefVal::UInt(\"RADIX_BASE\".into(), defines.radix_base),\n        ShaderDefVal::UInt(\"RADIX_BITS_PER_DIGIT\".into(), defines.radix_bits_per_digit),\n        ShaderDefVal::UInt(\"RADIX_DIGIT_PLACES\".into(), defines.radix_digit_places),\n        ShaderDefVal::UInt(\n            \"ENTRIES_PER_INVOCATION_A\".into(),\n            defines.entries_per_invocation_a,\n        ),\n        ShaderDefVal::UInt(\n            \"ENTRIES_PER_INVOCATION_C\".into(),\n            defines.entries_per_invocation_c,\n        ),\n        ShaderDefVal::UInt(\n            \"WORKGROUP_INVOCATIONS_A\".into(),\n            defines.workgroup_invocations_a,\n        ),\n        ShaderDefVal::UInt(\n            \"WORKGROUP_INVOCATIONS_C\".into(),\n            defines.workgroup_invocations_c,\n        ),\n        ShaderDefVal::UInt(\"WORKGROUP_ENTRIES_C\".into(), defines.workgroup_entries_c),\n        ShaderDefVal::UInt(\n            \"TEMPORAL_SORT_WINDOW_SIZE\".into(),\n            defines.temporal_sort_window_size,\n        ),\n    ];\n\n    if key.aabb {\n        shader_defs.push(\"USE_AABB\".into());\n    }\n\n    if !key.aabb {\n        shader_defs.push(\"USE_OBB\".into());\n    }\n\n    if key.binary_gaussian_op {\n        shader_defs.push(\"BINARY_GAUSSIAN_OP\".into());\n    }\n\n    if key.opacity_adaptive_radius {\n        shader_defs.push(\"OPACITY_ADAPTIVE_RADIUS\".into());\n    }\n\n    if key.visualize_bounding_box {\n        shader_defs.push(\"VISUALIZE_BOUNDING_BOX\".into());\n    }\n\n    #[cfg(feature = \"morph_particles\")]\n    shader_defs.push(\"READ_WRITE_POINTS\".into());\n\n    #[cfg(feature = \"packed\")]\n    shader_defs.push(\"PACKED\".into());\n\n    #[cfg(feature = \"buffer_storage\")]\n    shader_defs.push(\"BUFFER_STORAGE\".into());\n\n    #[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))]\n    shader_defs.push(\"BUFFER_TEXTURE\".into());\n\n    // #[cfg(feature = \"f16\")]\n    // shader_defs.push(\"F16\".into());\n\n    shader_defs.push(\"F32\".into());\n\n    #[cfg(feature = \"packed\")]\n    shader_defs.push(\"PACKED_F32\".into());\n\n    // #[cfg(all(feature = \"f16\", feature = \"buffer_storage\"))]\n    // shader_defs.push(\"PLANAR_F16\".into());\n\n    #[cfg(feature = \"buffer_storage\")]\n    shader_defs.push(\"PLANAR_F32\".into());\n\n    // #[cfg(all(feature = \"f16\", feature = \"buffer_texture\"))]\n    // shader_defs.push(\"PLANAR_TEXTURE_F16\".into());\n\n    #[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))]\n    shader_defs.push(\"PLANAR_TEXTURE_F32\".into());\n\n    #[cfg(feature = \"precompute_covariance_3d\")]\n    shader_defs.push(\"PRECOMPUTE_COVARIANCE_3D\".into());\n\n    #[cfg(feature = \"webgl2\")]\n    shader_defs.push(\"WEBGL2\".into());\n\n    match key.gaussian_mode {\n        GaussianMode::Gaussian2d => shader_defs.push(\"GAUSSIAN_2D\".into()),\n        GaussianMode::Gaussian3d => shader_defs.push(\"GAUSSIAN_3D\".into()),\n        GaussianMode::Gaussian4d => shader_defs.push(\"GAUSSIAN_4D\".into()),\n    }\n\n    match key.gaussian_mode {\n        GaussianMode::Gaussian2d | GaussianMode::Gaussian3d => {\n            shader_defs.push(\"GAUSSIAN_3D_STRUCTURE\".into());\n        }\n        _ => {}\n    }\n\n    match key.rasterize_mode {\n        RasterizeMode::Classification => shader_defs.push(\"RASTERIZE_CLASSIFICATION\".into()),\n        RasterizeMode::Color => shader_defs.push(\"RASTERIZE_COLOR\".into()),\n        RasterizeMode::Depth => shader_defs.push(\"RASTERIZE_DEPTH\".into()),\n        RasterizeMode::Normal => shader_defs.push(\"RASTERIZE_NORMAL\".into()),\n        RasterizeMode::OpticalFlow => shader_defs.push(\"RASTERIZE_OPTICAL_FLOW\".into()),\n        RasterizeMode::Position => shader_defs.push(\"RASTERIZE_POSITION\".into()),\n        RasterizeMode::Velocity => shader_defs.push(\"RASTERIZE_VELOCITY\".into()),\n    }\n\n    match key.draw_mode {\n        DrawMode::All => {}\n        DrawMode::Selected => shader_defs.push(\"DRAW_SELECTED\".into()),\n        DrawMode::HighlightSelected => shader_defs.push(\"HIGHLIGHT_SELECTED\".into()),\n    }\n\n    shader_defs\n}\n\n#[derive(PartialEq, Eq, Hash, Clone, Copy, Default)]\npub struct CloudPipelineKey {\n    pub aabb: bool,\n    pub binary_gaussian_op: bool,\n    pub visualize_bounding_box: bool,\n    pub opacity_adaptive_radius: bool,\n    pub draw_mode: DrawMode,\n    pub gaussian_mode: GaussianMode,\n    pub rasterize_mode: RasterizeMode,\n    pub sample_count: u32,\n    pub hdr: bool,\n}\n\nimpl<R: PlanarSync> SpecializedRenderPipeline for CloudPipeline<R> {\n    type Key = CloudPipelineKey;\n\n    fn specialize(&self, key: Self::Key) -> RenderPipelineDescriptor {\n        let shader_defs = shader_defs(key);\n\n        let format = if key.hdr {\n            TextureFormat::Rgba16Float\n        } else {\n            TextureFormat::Rgba8UnormSrgb\n        };\n\n        debug!(\"specializing cloud pipeline\");\n\n        RenderPipelineDescriptor {\n            label: Some(\"gaussian cloud render pipeline\".into()),\n            layout: vec![\n                self.view_layout_desc.clone(),\n                self.gaussian_uniform_layout_desc.clone(),\n                self.gaussian_cloud_layout_desc.clone(),\n                self.sorted_layout_desc.clone(),\n            ],\n            vertex: VertexState {\n                shader: self.shader.clone(),\n                shader_defs: shader_defs.clone(),\n                entry_point: Some(\"vs_points\".into()),\n                buffers: vec![],\n            },\n            fragment: Some(FragmentState {\n                shader: self.shader.clone(),\n                shader_defs,\n                entry_point: Some(\"fs_main\".into()),\n                targets: vec![Some(ColorTargetState {\n                    format,\n                    blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING),\n                    write_mask: ColorWrites::ALL,\n                })],\n            }),\n            primitive: PrimitiveState {\n                topology: PrimitiveTopology::TriangleStrip,\n                strip_index_format: None,\n                front_face: FrontFace::Ccw,\n                unclipped_depth: false,\n                cull_mode: None,\n                conservative: false,\n                polygon_mode: PolygonMode::Fill,\n            },\n            depth_stencil: Some(DepthStencilState {\n                format: TextureFormat::Depth32Float,\n                depth_write_enabled: false,\n                depth_compare: CompareFunction::GreaterEqual,\n                stencil: StencilState {\n                    front: StencilFaceState::IGNORE,\n                    back: StencilFaceState::IGNORE,\n                    read_mask: 0,\n                    write_mask: 0,\n                },\n                bias: DepthBiasState {\n                    constant: 0,\n                    slope_scale: 0.0,\n                    clamp: 0.0,\n                },\n            }),\n            multisample: MultisampleState {\n                count: key.sample_count,\n                mask: !0,\n                alpha_to_coverage_enabled: false,\n            },\n            push_constant_ranges: Vec::new(),\n            zero_initialize_workgroup_memory: true,\n        }\n    }\n}\n\n#[allow(type_alias_bounds)]\ntype DrawGaussians<R: bevy_interleave::prelude::PlanarSync> = (\n    SetItemPipeline,\n    // SetViewBindGroup<0>,\n    SetPreviousViewBindGroup<0>,\n    SetGaussianUniformBindGroup<1>,\n    DrawGaussianInstanced<R>,\n);\n\n#[allow(dead_code)]\n#[derive(Component, ShaderType, Clone, Copy)]\npub struct CloudUniform {\n    pub transform: Mat4,\n    pub global_opacity: f32,\n    pub global_scale: f32,\n    pub count: u32,\n    pub count_root_ceil: u32,\n    pub time: f32,\n    pub time_start: f32,\n    pub time_stop: f32,\n    pub num_classes: u32,\n    pub color_space: u32,\n    pub min: Vec4,\n    pub max: Vec4,\n}\n\n#[allow(clippy::type_complexity)]\npub fn extract_gaussians<R: PlanarSync>(\n    mut commands: Commands,\n    mut prev_commands_len: Local<usize>,\n    asset_server: Res<AssetServer>,\n    gaussian_cloud_res: Res<RenderAssets<R::GpuPlanarType>>,\n    gaussians_query: Extract<\n        Query<(\n            RenderEntity,\n            &ViewVisibility,\n            &R::PlanarTypeHandle,\n            &Aabb,\n            &SortedEntriesHandle,\n            &CloudSettings,\n            &GlobalTransform,\n        )>,\n    >,\n) {\n    let mut commands_list = Vec::with_capacity(*prev_commands_len);\n    // let visible_gaussians = gaussians_query.iter().filter(|(_, vis, ..)| vis.is_visible());\n\n    for (entity, visibility, cloud_handle, aabb, sorted_entries, settings, transform) in\n        gaussians_query.iter()\n    {\n        debug!(\"extracting gaussian cloud entity: {:?}\", entity);\n\n        if !visibility.get() {\n            debug!(\"gaussian cloud not visible\");\n            continue;\n        }\n\n        if let Some(load_state) = asset_server.get_load_state(cloud_handle.handle())\n            && load_state.is_loading()\n        {\n            debug!(\"gaussian cloud asset loading\");\n            continue;\n        }\n\n        if gaussian_cloud_res.get(cloud_handle.handle()).is_none() {\n            debug!(\"gaussian cloud asset not found\");\n            continue;\n        }\n\n        let cloud = gaussian_cloud_res.get(cloud_handle.handle()).unwrap();\n\n        let settings_uniform = CloudUniform {\n            transform: transform.to_matrix(),\n            global_opacity: settings.global_opacity,\n            global_scale: settings.global_scale,\n            count: cloud.len() as u32,\n            count_root_ceil: (cloud.len() as f32).sqrt().ceil() as u32,\n            time: settings.time,\n            time_start: settings.time_start,\n            time_stop: settings.time_stop,\n            num_classes: settings.num_classes as u32,\n            color_space: match settings.color_space {\n                GaussianColorSpace::SrgbRec709Display => 0,\n                GaussianColorSpace::LinRec709Display => 1,\n            },\n            min: aabb.min().extend(1.0),\n            max: aabb.max().extend(1.0),\n        };\n\n        commands_list.push((\n            entity,\n            GpuCloudBundle::<R> {\n                aabb: *aabb,\n                settings: settings.clone(),\n                settings_uniform,\n                sorted_entries: sorted_entries.clone(),\n                cloud_handle: cloud_handle.clone(),\n                transform: *transform,\n            },\n        ));\n    }\n    *prev_commands_len = commands_list.len();\n    commands.insert_batch(commands_list);\n}\n\n#[derive(Resource, Default)]\npub struct GaussianUniformBindGroups {\n    pub base_bind_group: Option<BindGroup>,\n}\n\n#[derive(Component)]\npub struct SortBindGroup {\n    pub sorted_bind_group: BindGroup,\n}\n\n#[allow(clippy::too_many_arguments)]\nfn queue_gaussian_bind_group<R: PlanarSync>(\n    mut commands: Commands,\n    mut groups: ResMut<GaussianUniformBindGroups>,\n    gaussian_cloud_pipeline: Res<CloudPipeline<R>>,\n    render_device: Res<RenderDevice>,\n    gaussian_uniforms: Res<ComponentUniforms<CloudUniform>>,\n    asset_server: Res<AssetServer>,\n    gaussian_cloud_res: Res<RenderAssets<R::GpuPlanarType>>,\n    sorted_entries_res: Res<RenderAssets<GpuSortedEntry>>,\n    gaussian_clouds: Query<GpuCloudBindGroupQuery<R>>,\n    #[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))] gpu_images: Res<\n        RenderAssets<bevy::render::texture::GpuImage>,\n    >,\n) {\n    let Some(resource) = gaussian_uniforms.binding() else {\n        return;\n    };\n\n    let pipeline_changed = gaussian_cloud_pipeline.is_changed();\n    if gaussian_uniforms.is_changed() || pipeline_changed || groups.base_bind_group.is_none() {\n        groups.base_bind_group = Some(render_device.create_bind_group(\n            \"gaussian_uniform_bind_group\",\n            &gaussian_cloud_pipeline.gaussian_uniform_layout,\n            &[BindGroupEntry {\n                binding: 0,\n                resource,\n            }],\n        ));\n    }\n\n    let gaussian_assets_changed = gaussian_cloud_res.is_changed();\n    let sorted_assets_changed = sorted_entries_res.is_changed();\n    #[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))]\n    let mut should_refresh_for_assets =\n        pipeline_changed || gaussian_assets_changed || sorted_assets_changed;\n    #[cfg(not(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\"))))]\n    let should_refresh_for_assets =\n        pipeline_changed || gaussian_assets_changed || sorted_assets_changed;\n\n    #[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))]\n    {\n        let textures_changed = gpu_images.is_changed();\n        should_refresh_for_assets |= textures_changed;\n    }\n\n    for query in gaussian_clouds.iter() {\n        let (entity, cloud_handle, sorted_entries_handle, existing_bind_group) = query;\n\n        if !should_refresh_for_assets && existing_bind_group.is_some() {\n            continue;\n        }\n\n        if let Some(load_state) = asset_server.get_load_state(cloud_handle.handle())\n            && load_state.is_loading()\n        {\n            debug!(\"queue gaussian bind group: cloud asset loading\");\n            continue;\n        }\n\n        if gaussian_cloud_res.get(cloud_handle.handle()).is_none() {\n            debug!(\"queue gaussian bind group: cloud asset not found\");\n            continue;\n        }\n\n        if let Some(load_state) = asset_server.get_load_state(&sorted_entries_handle.0)\n            && load_state.is_loading()\n        {\n            debug!(\"queue gaussian bind group: sorted entries asset loading\");\n            continue;\n        }\n\n        if sorted_entries_res.get(&sorted_entries_handle.0).is_none() {\n            debug!(\"queue gaussian bind group: sorted entries asset not found\");\n            continue;\n        }\n\n        #[cfg(feature = \"buffer_storage\")]\n        let cloud = gaussian_cloud_res.get(cloud_handle.handle()).unwrap();\n\n        let sorted_entries = sorted_entries_res.get(&sorted_entries_handle.0).unwrap();\n\n        #[cfg(feature = \"buffer_storage\")]\n        let sorted_bind_group = render_device.create_bind_group(\n            \"render_sorted_bind_group\",\n            &gaussian_cloud_pipeline.sorted_layout,\n            &[BindGroupEntry {\n                binding: 0,\n                resource: BindingResource::Buffer(BufferBinding {\n                    buffer: &sorted_entries.sorted_entry_buffer,\n                    offset: 0,\n                    size: BufferSize::new((cloud.len() * std::mem::size_of::<SortEntry>()) as u64),\n                }),\n            }],\n        );\n        #[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))]\n        let sorted_bind_group = render_device.create_bind_group(\n            Some(\"render_sorted_bind_group\"),\n            &gaussian_cloud_pipeline.sorted_layout,\n            &[BindGroupEntry {\n                binding: 0,\n                resource: BindingResource::TextureView(\n                    &gpu_images\n                        .get(&sorted_entries.texture)\n                        .unwrap()\n                        .texture_view,\n                ),\n            }],\n        );\n\n        debug!(\"inserting sorted bind group\");\n\n        commands\n            .entity(entity)\n            .insert(SortBindGroup { sorted_bind_group });\n    }\n}\n\n#[derive(Component)]\npub struct GaussianViewBindGroup {\n    pub value: BindGroup,\n}\n\n#[derive(Component)]\npub struct GaussianComputeViewBindGroup {\n    pub value: BindGroup,\n}\n\n// TODO: move to gaussian camera module\n// TODO: remove cloud pipeline dependency by separating view layout\n\n#[allow(clippy::too_many_arguments, clippy::type_complexity)]\npub fn queue_gaussian_view_bind_groups<R: PlanarSync>(\n    mut commands: Commands,\n    render_device: Res<RenderDevice>,\n    gaussian_cloud_pipeline: Res<CloudPipeline<R>>,\n    view_uniforms: Res<ViewUniforms>,\n    previous_view_uniforms: Res<PreviousViewUniforms>,\n    views: Query<\n        (\n            Entity,\n            &ExtractedView,\n            Option<&PreviousViewData>,\n            Option<&GaussianViewBindGroup>,\n        ),\n        With<GaussianCamera>,\n    >,\n    visibility_ranges: Res<RenderVisibilityRanges>,\n    globals_buffer: Res<GlobalsBuffer>,\n) {\n    let Some(view_binding) = view_uniforms.uniforms.binding() else {\n        return;\n    };\n    let Some(previous_view_binding) = previous_view_uniforms.uniforms.binding() else {\n        return;\n    };\n    let Some(globals) = globals_buffer.buffer.binding() else {\n        return;\n    };\n    let Some(visibility_ranges_buffer) = visibility_ranges.buffer().buffer() else {\n        return;\n    };\n\n    let resources_changed = gaussian_cloud_pipeline.is_changed()\n        || view_uniforms.is_changed()\n        || previous_view_uniforms.is_changed()\n        || globals_buffer.is_changed()\n        || visibility_ranges.is_changed();\n\n    for (entity, _extracted_view, _maybe_previous_view, existing_bind_group) in &views {\n        if !resources_changed && existing_bind_group.is_some() {\n            continue;\n        }\n\n        let layout = &gaussian_cloud_pipeline.view_layout;\n\n        let entries = vec![\n            BindGroupEntry {\n                binding: 0,\n                resource: view_binding.clone(),\n            },\n            BindGroupEntry {\n                binding: 1,\n                resource: globals.clone(),\n            },\n            BindGroupEntry {\n                binding: 2,\n                resource: previous_view_binding.clone(),\n            },\n            BindGroupEntry {\n                binding: 14,\n                resource: visibility_ranges_buffer.as_entire_binding(),\n            },\n        ];\n\n        let view_bind_group =\n            render_device.create_bind_group(\"gaussian_view_bind_group\", layout, &entries);\n\n        debug!(\"inserting gaussian view bind group\");\n\n        commands.entity(entity).insert(GaussianViewBindGroup {\n            value: view_bind_group,\n        });\n    }\n}\n\n// Prepare the compute view bind group using the compute_view_layout (for compute pipelines)\n#[allow(clippy::too_many_arguments, clippy::type_complexity)]\npub fn queue_gaussian_compute_view_bind_groups<R: PlanarSync>(\n    mut commands: Commands,\n    render_device: Res<RenderDevice>,\n    gaussian_cloud_pipeline: Res<CloudPipeline<R>>,\n    view_uniforms: Res<ViewUniforms>,\n    previous_view_uniforms: Res<PreviousViewUniforms>,\n    views: Query<\n        (\n            Entity,\n            &ExtractedView,\n            Option<&PreviousViewData>,\n            Option<&GaussianComputeViewBindGroup>,\n        ),\n        With<GaussianCamera>,\n    >,\n    visibility_ranges: Res<RenderVisibilityRanges>,\n    globals_buffer: Res<GlobalsBuffer>,\n) where\n    R::GpuPlanarType: GpuPlanarStorage,\n{\n    let Some(view_binding) = view_uniforms.uniforms.binding() else {\n        return;\n    };\n    let Some(previous_view_binding) = previous_view_uniforms.uniforms.binding() else {\n        return;\n    };\n    let Some(globals) = globals_buffer.buffer.binding() else {\n        return;\n    };\n    let Some(visibility_ranges_buffer) = visibility_ranges.buffer().buffer() else {\n        return;\n    };\n\n    let resources_changed = gaussian_cloud_pipeline.is_changed()\n        || view_uniforms.is_changed()\n        || previous_view_uniforms.is_changed()\n        || globals_buffer.is_changed()\n        || visibility_ranges.is_changed();\n\n    for (entity, _extracted_view, _maybe_previous_view, existing_bind_group) in &views {\n        if !resources_changed && existing_bind_group.is_some() {\n            continue;\n        }\n\n        let layout = &gaussian_cloud_pipeline.compute_view_layout;\n\n        let entries = vec![\n            BindGroupEntry {\n                binding: 0,\n                resource: view_binding.clone(),\n            },\n            BindGroupEntry {\n                binding: 1,\n                resource: globals.clone(),\n            },\n            BindGroupEntry {\n                binding: 2,\n                resource: previous_view_binding.clone(),\n            },\n            BindGroupEntry {\n                binding: 14,\n                resource: visibility_ranges_buffer.as_entire_binding(),\n            },\n        ];\n\n        let view_bind_group =\n            render_device.create_bind_group(\"gaussian_compute_view_bind_group\", layout, &entries);\n\n        commands\n            .entity(entity)\n            .insert(GaussianComputeViewBindGroup {\n                value: view_bind_group,\n            });\n    }\n}\n\npub struct SetViewBindGroup<const I: usize>;\nimpl<P: PhaseItem, const I: usize> RenderCommand<P> for SetViewBindGroup<I> {\n    type Param = ();\n    type ViewQuery = (Read<GaussianViewBindGroup>, Read<ViewUniformOffset>);\n    type ItemQuery = ();\n\n    #[inline]\n    fn render<'w>(\n        _: &P,\n        (gaussian_view_bind_group, view_uniform): ROQueryItem<'w, 'w, Self::ViewQuery>,\n        _entity: Option<()>,\n        _: SystemParamItem<'w, '_, Self::Param>,\n        pass: &mut TrackedRenderPass<'w>,\n    ) -> RenderCommandResult {\n        pass.set_bind_group(I, &gaussian_view_bind_group.value, &[view_uniform.offset]);\n\n        debug!(\"set view bind group\");\n\n        RenderCommandResult::Success\n    }\n}\n\npub struct SetPreviousViewBindGroup<const I: usize>;\nimpl<P: PhaseItem, const I: usize> RenderCommand<P> for SetPreviousViewBindGroup<I> {\n    type Param = SRes<PrepassViewBindGroup>;\n    type ViewQuery = (\n        Read<ViewUniformOffset>,\n        Option<Has<MotionVectorPrepass>>,\n        Option<Read<PreviousViewUniformOffset>>,\n    );\n    type ItemQuery = ();\n\n    #[inline]\n    fn render<'w>(\n        _: &P,\n        (view_uniform_offset, has_motion_vector_prepass, previous_view_uniform_offset): ROQueryItem<\n            'w,\n            'w,\n            Self::ViewQuery,\n        >,\n        _entity: Option<()>,\n        prepass_view_bind_group: SystemParamItem<'w, '_, Self::Param>,\n        pass: &mut TrackedRenderPass<'w>,\n    ) -> RenderCommandResult {\n        let prepass_view_bind_group = prepass_view_bind_group.into_inner();\n        match previous_view_uniform_offset {\n            Some(previous_view_uniform_offset) if has_motion_vector_prepass.unwrap_or_default() => {\n                pass.set_bind_group(\n                    I,\n                    prepass_view_bind_group.motion_vectors.as_ref().unwrap(),\n                    &[\n                        view_uniform_offset.offset,\n                        previous_view_uniform_offset.offset,\n                    ],\n                );\n            }\n            _ => pass.set_bind_group(\n                I,\n                prepass_view_bind_group.motion_vectors.as_ref().unwrap(),\n                &[view_uniform_offset.offset, 0],\n            ),\n        }\n\n        debug!(\"set previous view bind group\");\n\n        RenderCommandResult::Success\n    }\n}\n\npub struct SetGaussianUniformBindGroup<const I: usize>;\nimpl<P: PhaseItem, const I: usize> RenderCommand<P> for SetGaussianUniformBindGroup<I> {\n    type Param = SRes<GaussianUniformBindGroups>;\n    type ViewQuery = ();\n    type ItemQuery = Read<DynamicUniformIndex<CloudUniform>>;\n\n    #[inline]\n    fn render<'w>(\n        _item: &P,\n        _view: (),\n        gaussian_cloud_index: Option<ROQueryItem<'w, 'w, Self::ItemQuery>>,\n        bind_groups: SystemParamItem<'w, '_, Self::Param>,\n        pass: &mut TrackedRenderPass<'w>,\n    ) -> RenderCommandResult {\n        let bind_groups = bind_groups.into_inner();\n        let bind_group = bind_groups\n            .base_bind_group\n            .as_ref()\n            .expect(\"bind group not initialized\");\n\n        let mut set_bind_group = |indices: &[u32]| pass.set_bind_group(I, bind_group, indices);\n\n        if gaussian_cloud_index.is_none() {\n            debug!(\"skipping gaussian uniform bind group\\n\");\n            return RenderCommandResult::Skip;\n        }\n\n        let gaussian_cloud_index = gaussian_cloud_index.unwrap().index();\n        set_bind_group(&[gaussian_cloud_index]);\n\n        debug!(\"set gaussian uniform bind group\");\n\n        RenderCommandResult::Success\n    }\n}\n\npub struct DrawGaussianInstanced<R: PlanarSync> {\n    phantom: std::marker::PhantomData<R>,\n}\n\nimpl<R: PlanarSync> Default for DrawGaussianInstanced<R> {\n    fn default() -> Self {\n        Self {\n            phantom: std::marker::PhantomData,\n        }\n    }\n}\n\nimpl<P: PhaseItem, R: PlanarSync> RenderCommand<P> for DrawGaussianInstanced<R>\nwhere\n    R::GpuPlanarType: GpuPlanarStorage,\n{\n    type Param = SRes<RenderAssets<R::GpuPlanarType>>;\n    type ViewQuery = Read<SortTrigger>;\n    type ItemQuery = (\n        Read<R::PlanarTypeHandle>,\n        Read<PlanarStorageBindGroup<R>>,\n        Read<SortBindGroup>,\n    );\n\n    #[inline]\n    fn render<'w>(\n        _item: &P,\n        view: &'w SortTrigger,\n        entity: Option<(\n            &'w R::PlanarTypeHandle,\n            &'w PlanarStorageBindGroup<R>,\n            &'w SortBindGroup,\n        )>,\n        gaussian_clouds: SystemParamItem<'w, '_, Self::Param>,\n        pass: &mut TrackedRenderPass<'w>,\n    ) -> RenderCommandResult {\n        debug!(\"render call\");\n\n        #[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))]\n        let _ = view;\n\n        let (handle, planar_bind_groups, sort_bind_groups) =\n            entity.expect(\"gaussian cloud entity not found\");\n\n        let gpu_gaussian_cloud = match gaussian_clouds.into_inner().get(handle.handle()) {\n            Some(gpu_gaussian_cloud) => gpu_gaussian_cloud,\n            None => {\n                debug!(\"gpu cloud not found\");\n                return RenderCommandResult::Skip;\n            }\n        };\n\n        debug!(\"drawing indirect\");\n\n        pass.set_bind_group(2, &planar_bind_groups.bind_group, &[]);\n\n        #[cfg(feature = \"buffer_storage\")]\n        {\n            // TODO: align dynamic offset to `min_storage_buffer_offset_alignment`\n            pass.set_bind_group(\n                3,\n                &sort_bind_groups.sorted_bind_group,\n                &[view.camera_index as u32\n                    * std::mem::size_of::<SortEntry>() as u32\n                    * gpu_gaussian_cloud.len() as u32],\n            );\n        }\n\n        #[cfg(all(feature = \"buffer_texture\", not(feature = \"buffer_storage\")))]\n        {\n            pass.set_bind_group(3, &sort_bind_groups.sorted_bind_group, &[]);\n        }\n\n        #[cfg(feature = \"webgl2\")]\n        pass.draw(0..4, 0..gpu_gaussian_cloud.len() as u32);\n\n        #[cfg(not(feature = \"webgl2\"))]\n        pass.draw_indirect(gpu_gaussian_cloud.draw_indirect_buffer(), 0);\n\n        RenderCommandResult::Success\n    }\n}\n"
  },
  {
    "path": "src/render/packed.rs",
    "content": "use bevy::render::{\n    render_resource::{\n        BindGroup, BindGroupEntry, BindGroupLayout, BindGroupLayoutEntry, BindingResource,\n        BindingType, Buffer, BufferBinding, BufferBindingType, BufferInitDescriptor, BufferSize,\n        BufferUsages, ShaderStages,\n    },\n    renderer::RenderDevice,\n};\nuse bevy_interleave::prelude::PlanarSync;\n\nuse crate::{\n    gaussian::formats::planar_3d::{Gaussian3d, PlanarGaussian3d},\n    render::CloudPipeline,\n};\n\n#[derive(Debug, Clone)]\npub struct PackedBuffers {\n    pub gaussians: Buffer,\n}\n\npub fn prepare_cloud(render_device: &RenderDevice, cloud: &PlanarGaussian3d) -> PackedBuffers {\n    let packed: Vec<Gaussian3d> = cloud.iter().collect();\n    let gaussians = render_device.create_buffer_with_data(&BufferInitDescriptor {\n        label: Some(\"packed_gaussian_cloud_buffer\"),\n        contents: bytemuck::cast_slice(packed.as_slice()),\n        usage: BufferUsages::VERTEX | BufferUsages::COPY_DST | BufferUsages::STORAGE,\n    });\n\n    PackedBuffers { gaussians }\n}\n\npub fn get_bind_group_layout(render_device: &RenderDevice, read_only: bool) -> BindGroupLayout {\n    render_device.create_bind_group_layout(\n        Some(\"packed_gaussian_cloud_layout\"),\n        &[BindGroupLayoutEntry {\n            binding: 0,\n            visibility: ShaderStages::VERTEX_FRAGMENT | ShaderStages::COMPUTE,\n            ty: BindingType::Buffer {\n                ty: BufferBindingType::Storage { read_only },\n                has_dynamic_offset: false,\n                min_binding_size: BufferSize::new(std::mem::size_of::<Gaussian3d>() as u64),\n            },\n            count: None,\n        }],\n    )\n}\n\n#[cfg(feature = \"packed\")]\npub fn get_bind_group<R: PlanarSync>(\n    render_device: &RenderDevice,\n    gaussian_cloud_pipeline: &CloudPipeline<R>,\n    cloud: &PackedBuffers,\n) -> BindGroup {\n    render_device.create_bind_group(\n        \"packed_gaussian_cloud_bind_group\",\n        &gaussian_cloud_pipeline.gaussian_cloud_layout,\n        &[BindGroupEntry {\n            binding: 0,\n            resource: BindingResource::Buffer(BufferBinding {\n                buffer: &cloud.gaussians,\n                offset: 0,\n                size: BufferSize::new(cloud.gaussians.size()),\n            }),\n        }],\n    )\n}\n"
  },
  {
    "path": "src/render/packed.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::packed\n\n#import bevy_gaussian_splatting::bindings::{gaussian_uniforms, points}\n#ifdef BINARY_GAUSSIAN_OP\n    #import bevy_gaussian_splatting::bindings::{rhs_points, out_points}\n#endif\n\n#import bevy_gaussian_splatting::spherical_harmonics::{\n    spherical_harmonics_lookup,\n    srgb_to_linear,\n}\n\n#ifdef PACKED_F32\n    fn convert_sh_color_to_linear(color: vec3<f32>) -> vec3<f32> {\n        if gaussian_uniforms.color_space == 1u {\n            return color;\n        }\n\n        return srgb_to_linear(color);\n    }\n\n    fn gaussian_position(point: Gaussian) -> vec3<f32> {\n        return point.position_visibility.xyz;\n    }\n\n    fn gaussian_color(point: Gaussian, ray_direction: vec3<f32>) -> vec3<f32> {\n        let sh = gaussian_spherical_harmonics(point);\n        let color = spherical_harmonics_lookup(ray_direction, sh);\n        return convert_sh_color_to_linear(color);\n    }\n\n    fn gaussian_spherical_harmonics(point: Gaussian) -> array<f32, #{SH_COEFF_COUNT}> {\n        return point.sh;\n    }\n\n    fn gaussian_rotation(point: Gaussian) -> vec4<f32> {\n        return point.rotation;\n    }\n\n    fn gaussian_scale(point: Gaussian) -> vec3<f32> {\n        return point.scale_opacity.xyz;\n    }\n\n    fn gaussian_opacity(point: Gaussian) -> f32 {\n        return point.scale_opacity.w;\n    }\n\n    fn gaussian_visibility(point: Gaussian) -> f32 {\n        return point.position_visibility.w;\n    }\n\n    fn get_position(index: u32) -> vec3<f32> {\n        return gaussian_position(points[index]);\n    }\n\n    fn get_color(\n        index: u32,\n        ray_direction: vec3<f32>,\n    ) -> vec3<f32> {\n        return gaussian_color(points[index], ray_direction);\n    }\n\n    fn get_spherical_harmonics(index: u32) -> array<f32, #{SH_COEFF_COUNT}> {\n        return gaussian_spherical_harmonics(points[index]);\n    }\n\n    fn get_rotation(index: u32) -> vec4<f32> {\n        return gaussian_rotation(points[index]);\n    }\n\n    fn get_scale(index: u32) -> vec3<f32> {\n        return gaussian_scale(points[index]);\n    }\n\n    fn get_opacity(index: u32) -> f32 {\n        return gaussian_opacity(points[index]);\n    }\n\n    fn get_visibility(index: u32) -> f32 {\n        return gaussian_visibility(points[index]);\n    }\n\n    #ifdef BINARY_GAUSSIAN_OP\n\n        fn get_rhs_position(index: u32) -> vec3<f32> {\n            return gaussian_position(rhs_points[index]);\n        }\n\n        fn get_rhs_color(\n            index: u32,\n            ray_direction: vec3<f32>,\n        ) -> vec3<f32> {\n            return gaussian_color(rhs_points[index], ray_direction);\n        }\n\n        fn get_rhs_spherical_harmonics(index: u32) -> array<f32, #{SH_COEFF_COUNT}> {\n            return gaussian_spherical_harmonics(rhs_points[index]);\n        }\n\n        fn get_rhs_rotation(index: u32) -> vec4<f32> {\n            return gaussian_rotation(rhs_points[index]);\n        }\n\n        fn get_rhs_scale(index: u32) -> vec3<f32> {\n            return gaussian_scale(rhs_points[index]);\n        }\n\n        fn get_rhs_opacity(index: u32) -> f32 {\n            return gaussian_opacity(rhs_points[index]);\n        }\n\n        fn get_rhs_visibility(index: u32) -> f32 {\n            return gaussian_visibility(rhs_points[index]);\n        }\n\n        fn set_output_position_visibility(\n            index: u32,\n            position: vec3<f32>,\n            visibility: f32,\n        ) {\n            out_points[index].position_visibility = vec4<f32>(position, visibility);\n        }\n\n        fn set_output_spherical_harmonics(\n            index: u32,\n            sh: array<f32, #{SH_COEFF_COUNT}>,\n        ) {\n            out_points[index].sh = sh;\n        }\n\n        fn set_output_transform(\n            index: u32,\n            rotation: vec4<f32>,\n            scale: vec3<f32>,\n            opacity: f32,\n        ) {\n            out_points[index].rotation = rotation;\n            out_points[index].scale_opacity = vec4<f32>(scale, opacity);\n        }\n\n    #endif\n\n\n#endif\n"
  },
  {
    "path": "src/render/planar.rs",
    "content": "\n"
  },
  {
    "path": "src/render/planar.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::planar\n\n#import bevy_gaussian_splatting::bindings::gaussian_uniforms\n\n#ifdef GAUSSIAN_3D_STRUCTURE\n    #ifdef PRECOMPUTE_COVARIANCE_3D\n        #import bevy_gaussian_splatting::bindings::{\n            position_visibility,\n            spherical_harmonics,\n            covariance_3d_opacity,\n        }\n\n        #ifdef BINARY_GAUSSIAN_OP\n            #import bevy_gaussian_splatting::bindings::{\n                rhs_position_visibility,\n                rhs_spherical_harmonics,\n                rhs_covariance_3d_opacity,\n                out_position_visibility,\n                out_spherical_harmonics,\n                out_covariance_3d_opacity,\n            }\n        #endif\n    #else\n        #import bevy_gaussian_splatting::bindings::{\n            position_visibility,\n            spherical_harmonics,\n            rotation,\n            rotation_scale_opacity,\n            scale_opacity,\n        }\n\n        #ifdef BINARY_GAUSSIAN_OP\n            #import bevy_gaussian_splatting::bindings::{\n                rhs_position_visibility,\n                rhs_spherical_harmonics,\n                out_position_visibility,\n                out_spherical_harmonics,\n            }\n\n            #ifdef PLANAR_F16\n                #import bevy_gaussian_splatting::bindings::rhs_rotation_scale_opacity\n                #import bevy_gaussian_splatting::bindings::out_rotation_scale_opacity\n            #endif\n\n            #ifdef PLANAR_F32\n                #import bevy_gaussian_splatting::bindings::{\n                    rhs_rotation,\n                    rhs_scale_opacity,\n                    out_rotation,\n                    out_scale_opacity,\n                }\n            #endif\n        #endif\n    #endif\n\n    #import bevy_gaussian_splatting::spherical_harmonics::{\n        spherical_harmonics_lookup,\n        srgb_to_linear,\n    }\n#else ifdef GAUSSIAN_4D\n    #import bevy_gaussian_splatting::bindings::{\n        position_visibility,\n        spherindrical_harmonics,\n        isotropic_rotations,\n        scale_opacity,\n        timestamp_timescale,\n    }\n\n    #ifdef BINARY_GAUSSIAN_OP\n        #import bevy_gaussian_splatting::bindings::{\n            rhs_position_visibility,\n            rhs_spherindrical_harmonics,\n            rhs_isotropic_rotations,\n            rhs_scale_opacity,\n            rhs_timestamp_timescale,\n        }\n    #endif\n\n    #import bevy_gaussian_splatting::spherical_harmonics::srgb_to_linear\n    #import bevy_gaussian_splatting::spherindrical_harmonics::spherindrical_harmonics_lookup\n#endif\n\nfn planar_position(value: vec4<f32>) -> vec3<f32> {\n    return value.xyz;\n}\n\nfn planar_visibility(value: vec4<f32>) -> f32 {\n    return value.w;\n}\n\nfn convert_sh_color_to_linear(color: vec3<f32>) -> vec3<f32> {\n    if gaussian_uniforms.color_space == 1u {\n        return color;\n    }\n\n    return srgb_to_linear(color);\n}\n\n#ifdef GAUSSIAN_3D_STRUCTURE\n    fn planar_color_from_sh(\n        ray_direction: vec3<f32>,\n        sh: array<f32, #{SH_COEFF_COUNT}>,\n    ) -> vec3<f32> {\n        let color = spherical_harmonics_lookup(ray_direction, sh);\n        return convert_sh_color_to_linear(color);\n    }\n\n    fn planar_scale_from(scale_opacity: vec4<f32>) -> vec3<f32> {\n        return scale_opacity.xyz;\n    }\n\n    fn planar_opacity_from(scale_opacity: vec4<f32>) -> f32 {\n        return scale_opacity.w;\n    }\n\n    #ifdef PLANAR_F16\n        fn planar_f16_decode_sh(\n            raw: array<u32, #{HALF_SH_COEFF_COUNT}>,\n        ) -> array<f32, #{SH_COEFF_COUNT}> {\n            var coefficients: array<f32, #{SH_COEFF_COUNT}>;\n\n            for (var i = 0u; i < #{HALF_SH_COEFF_COUNT}u; i = i + 1u) {\n                let values = unpack2x16float(raw[i]);\n\n                coefficients[i * 2u] = values[0];\n                coefficients[i * 2u + 1u] = values[1];\n            }\n\n            return coefficients;\n        }\n\n        #ifdef PRECOMPUTE_COVARIANCE_3D\n            fn planar_f16_decode_covariance(raw: vec4<u32>) -> array<f32, 6> {\n                let c0 = unpack2x16float(raw.x);\n                let c1 = unpack2x16float(raw.y);\n                let c2 = unpack2x16float(raw.z);\n\n                var cov3d: array<f32, 6>;\n\n                cov3d[0] = c0.y;\n                cov3d[1] = c0.x;\n                cov3d[2] = c1.y;\n                cov3d[3] = c1.x;\n                cov3d[4] = c2.y;\n                cov3d[5] = c2.x;\n\n                return cov3d;\n            }\n\n            fn planar_f16_opacity(raw: vec4<u32>) -> f32 {\n                return unpack2x16float(raw.w).y;\n            }\n        #else\n            fn planar_f16_decode_rotation(raw: vec4<u32>) -> vec4<f32> {\n                let q0 = unpack2x16float(raw.x);\n                let q1 = unpack2x16float(raw.y);\n\n                return vec4<f32>(\n                    q0.yx,\n                    q1.yx,\n                );\n            }\n\n            fn planar_f16_decode_scale(raw: vec4<u32>) -> vec3<f32> {\n                let s0 = unpack2x16float(raw.z);\n                let s1 = unpack2x16float(raw.w);\n\n                return vec3<f32>(\n                    s0.yx,\n                    s1.y,\n                );\n            }\n\n            fn planar_f16_opacity(raw: vec4<u32>) -> f32 {\n                return unpack2x16float(raw.w).x;\n            }\n        #endif\n\n        fn get_color(\n            index: u32,\n            ray_direction: vec3<f32>,\n        ) -> vec3<f32> {\n            let sh = planar_f16_decode_sh(spherical_harmonics[index]);\n            return planar_color_from_sh(ray_direction, sh);\n        }\n\n        fn get_position(index: u32) -> vec3<f32> {\n            return planar_position(position_visibility[index]);\n        }\n\n        fn get_spherical_harmonics(index: u32) -> array<f32, #{SH_COEFF_COUNT}> {\n            return planar_f16_decode_sh(spherical_harmonics[index]);\n        }\n\n        #ifdef PRECOMPUTE_COVARIANCE_3D\n            fn get_cov3d(index: u32) -> array<f32, 6> {\n                return planar_f16_decode_covariance(covariance_3d_opacity[index]);\n            }\n        #else\n            fn get_rotation(index: u32) -> vec4<f32> {\n                return planar_f16_decode_rotation(rotation_scale_opacity[index]);\n            }\n\n            fn get_scale(index: u32) -> vec3<f32> {\n                return planar_f16_decode_scale(rotation_scale_opacity[index]);\n            }\n        #endif\n\n        fn get_opacity(index: u32) -> f32 {\n            #ifdef PRECOMPUTE_COVARIANCE_3D\n                return planar_f16_opacity(covariance_3d_opacity[index]);\n            #else\n                return planar_f16_opacity(rotation_scale_opacity[index]);\n            #endif\n        }\n\n        fn get_visibility(index: u32) -> f32 {\n            return planar_visibility(position_visibility[index]);\n        }\n\n        #ifdef BINARY_GAUSSIAN_OP\n            fn get_rhs_color(\n                index: u32,\n                ray_direction: vec3<f32>,\n            ) -> vec3<f32> {\n                let sh = planar_f16_decode_sh(rhs_spherical_harmonics[index]);\n                return planar_color_from_sh(ray_direction, sh);\n            }\n\n            fn get_rhs_position(index: u32) -> vec3<f32> {\n                return planar_position(rhs_position_visibility[index]);\n            }\n\n            fn get_rhs_spherical_harmonics(index: u32) -> array<f32, #{SH_COEFF_COUNT}> {\n                return planar_f16_decode_sh(rhs_spherical_harmonics[index]);\n            }\n\n            #ifdef PRECOMPUTE_COVARIANCE_3D\n                fn get_rhs_cov3d(index: u32) -> array<f32, 6> {\n                    return planar_f16_decode_covariance(rhs_covariance_3d_opacity[index]);\n                }\n            #else\n                fn get_rhs_rotation(index: u32) -> vec4<f32> {\n                    return planar_f16_decode_rotation(rhs_rotation_scale_opacity[index]);\n                }\n\n                fn get_rhs_scale(index: u32) -> vec3<f32> {\n                    return planar_f16_decode_scale(rhs_rotation_scale_opacity[index]);\n                }\n            #endif\n\n            fn get_rhs_opacity(index: u32) -> f32 {\n                #ifdef PRECOMPUTE_COVARIANCE_3D\n                    return planar_f16_opacity(rhs_covariance_3d_opacity[index]);\n                #else\n                    return planar_f16_opacity(rhs_rotation_scale_opacity[index]);\n                #endif\n            }\n\n            fn get_rhs_visibility(index: u32) -> f32 {\n                return planar_visibility(rhs_position_visibility[index]);\n            }\n\n            fn set_output_position_visibility(\n                index: u32,\n                position: vec3<f32>,\n                visibility: f32,\n            ) {\n                out_position_visibility[index] = vec4<f32>(position, visibility);\n            }\n\n            fn set_output_spherical_harmonics(\n                index: u32,\n                sh: array<f32, #{SH_COEFF_COUNT}>,\n            ) {\n                for (var i = 0u; i < #{HALF_SH_COEFF_COUNT}; i = i + 1u) {\n                    let base = i * 2u;\n                    out_spherical_harmonics[index][i] = pack2x16float(vec2<f32>(\n                        sh[base],\n                        sh[base + 1u],\n                    ));\n                }\n            }\n\n            #ifdef PRECOMPUTE_COVARIANCE_3D\n                fn planar_f16_encode_covariance(\n                    cov: array<f32, 6>,\n                    opacity: f32,\n                ) -> vec4<u32> {\n                    return vec4<u32>(\n                        pack2x16float(vec2<f32>(cov[1], cov[0])),\n                        pack2x16float(vec2<f32>(cov[3], cov[2])),\n                        pack2x16float(vec2<f32>(cov[5], cov[4])),\n                        pack2x16float(vec2<f32>(0.0, opacity)),\n                    );\n                }\n\n                fn set_output_covariance(\n                    index: u32,\n                    cov: array<f32, 6>,\n                    opacity: f32,\n                ) {\n                    out_covariance_3d_opacity[index] = planar_f16_encode_covariance(cov, opacity);\n                }\n            #else\n                fn planar_f16_encode_rotation_scale_opacity(\n                    rotation: vec4<f32>,\n                    scale: vec3<f32>,\n                    opacity: f32,\n                ) -> vec4<u32> {\n                    return vec4<u32>(\n                        pack2x16float(vec2<f32>(rotation.y, rotation.x)),\n                        pack2x16float(vec2<f32>(rotation.w, rotation.z)),\n                        pack2x16float(vec2<f32>(scale.y, scale.x)),\n                        pack2x16float(vec2<f32>(opacity, scale.z)),\n                    );\n                }\n\n                fn set_output_transform(\n                    index: u32,\n                    rotation: vec4<f32>,\n                    scale: vec3<f32>,\n                    opacity: f32,\n                ) {\n                    out_rotation_scale_opacity[index] = planar_f16_encode_rotation_scale_opacity(\n                        rotation,\n                        scale,\n                        opacity,\n                    );\n                }\n            #endif\n\n        #endif\n    #else ifdef PLANAR_F32\n        fn get_color(\n            index: u32,\n            ray_direction: vec3<f32>,\n        ) -> vec3<f32> {\n            return planar_color_from_sh(ray_direction, spherical_harmonics[index]);\n        }\n\n        fn get_position(index: u32) -> vec3<f32> {\n            return planar_position(position_visibility[index]);\n        }\n\n        fn get_spherical_harmonics(index: u32) -> array<f32, #{SH_COEFF_COUNT}> {\n            return spherical_harmonics[index];\n        }\n\n        fn get_rotation(index: u32) -> vec4<f32> {\n            return rotation[index];\n        }\n\n        fn get_scale(index: u32) -> vec3<f32> {\n            return planar_scale_from(scale_opacity[index]);\n        }\n\n        fn get_opacity(index: u32) -> f32 {\n            return planar_opacity_from(scale_opacity[index]);\n        }\n\n        fn get_visibility(index: u32) -> f32 {\n            return planar_visibility(position_visibility[index]);\n        }\n\n        #ifdef BINARY_GAUSSIAN_OP\n            fn get_rhs_color(\n                index: u32,\n                ray_direction: vec3<f32>,\n            ) -> vec3<f32> {\n                return planar_color_from_sh(ray_direction, rhs_spherical_harmonics[index]);\n            }\n\n            fn get_rhs_position(index: u32) -> vec3<f32> {\n                return planar_position(rhs_position_visibility[index]);\n            }\n\n            fn get_rhs_spherical_harmonics(index: u32) -> array<f32, #{SH_COEFF_COUNT}> {\n                return rhs_spherical_harmonics[index];\n            }\n\n            fn get_rhs_rotation(index: u32) -> vec4<f32> {\n                return rhs_rotation[index];\n            }\n\n            fn get_rhs_scale(index: u32) -> vec3<f32> {\n                return planar_scale_from(rhs_scale_opacity[index]);\n            }\n\n            fn get_rhs_opacity(index: u32) -> f32 {\n                return planar_opacity_from(rhs_scale_opacity[index]);\n            }\n\n            fn get_rhs_visibility(index: u32) -> f32 {\n                return planar_visibility(rhs_position_visibility[index]);\n            }\n\n            fn set_output_position_visibility(\n                index: u32,\n                position: vec3<f32>,\n                visibility: f32,\n            ) {\n                out_position_visibility[index] = vec4<f32>(position, visibility);\n            }\n\n            fn set_output_spherical_harmonics(\n                index: u32,\n                sh: array<f32, #{SH_COEFF_COUNT}>,\n            ) {\n                for (var i = 0u; i < #{SH_COEFF_COUNT}; i = i + 1u) {\n                    out_spherical_harmonics[index][i] = sh[i];\n                }\n            }\n\n            #ifdef PRECOMPUTE_COVARIANCE_3D\n                fn set_output_covariance(\n                    index: u32,\n                    cov: array<f32, 6>,\n                    opacity: f32,\n                ) {\n                    out_covariance_3d_opacity[index][0] = cov[0];\n                    out_covariance_3d_opacity[index][1] = cov[1];\n                    out_covariance_3d_opacity[index][2] = cov[2];\n                    out_covariance_3d_opacity[index][3] = cov[3];\n                    out_covariance_3d_opacity[index][4] = cov[4];\n                    out_covariance_3d_opacity[index][5] = cov[5];\n                    out_covariance_3d_opacity[index][6] = 0.0;\n                    out_covariance_3d_opacity[index][7] = opacity;\n                }\n            #else\n                fn set_output_transform(\n                    index: u32,\n                    rotation: vec4<f32>,\n                    scale: vec3<f32>,\n                    opacity: f32,\n                ) {\n                    out_rotation[index] = rotation;\n                    out_scale_opacity[index] = vec4<f32>(scale, opacity);\n                }\n            #endif\n\n        #endif\n    #endif\n#else ifdef GAUSSIAN_4D\n    fn planar4d_color_from_sh(\n        ray_direction: vec3<f32>,\n        dir_t: f32,\n        sh: array<f32, #{SH_4D_COEFF_COUNT}>,\n    ) -> vec3<f32> {\n        let color = spherindrical_harmonics_lookup(ray_direction, dir_t, sh);\n        return convert_sh_color_to_linear(color);\n    }\n\n    fn planar4d_isotropic_rotations(raw: array<f32, 8>) -> mat2x4<f32> {\n        let r1x = raw[0];\n        let r1y = raw[1];\n        let r1z = raw[2];\n        let r1w = raw[3];\n\n        let r2x = raw[4];\n        let r2y = raw[5];\n        let r2z = raw[6];\n        let r2w = raw[7];\n\n        return mat2x4<f32>(\n            r1x, r1y, r1z, r1w,\n            r2x, r2y, r2z, r2w,\n        );\n    }\n\n    fn planar4d_scale_from(scale_opacity: vec4<f32>) -> vec3<f32> {\n        return scale_opacity.xyz;\n    }\n\n    fn planar4d_opacity_from(scale_opacity: vec4<f32>) -> f32 {\n        return scale_opacity.w;\n    }\n\n    fn planar4d_timestamp_from(timestamp_timescale: vec4<f32>) -> f32 {\n        return timestamp_timescale.x;\n    }\n\n    fn planar4d_time_scale_from(timestamp_timescale: vec4<f32>) -> f32 {\n        return timestamp_timescale.y;\n    }\n\n    #ifdef PLANAR_F32\n        fn get_color(\n            index: u32,\n            dir_t: f32,\n            ray_direction: vec3<f32>,\n        ) -> vec3<f32> {\n            return planar4d_color_from_sh(ray_direction, dir_t, spherindrical_harmonics[index]);\n        }\n\n        fn get_isotropic_rotations(index: u32) -> mat2x4<f32> {\n            return planar4d_isotropic_rotations(isotropic_rotations[index]);\n        }\n\n        fn get_scale(index: u32) -> vec3<f32> {\n            return planar4d_scale_from(scale_opacity[index]);\n        }\n\n        fn get_opacity(index: u32) -> f32 {\n            return planar4d_opacity_from(scale_opacity[index]);\n        }\n\n        fn get_position(index: u32) -> vec3<f32> {\n            return planar_position(position_visibility[index]);\n        }\n\n        fn get_visibility(index: u32) -> f32 {\n            return planar_visibility(position_visibility[index]);\n        }\n\n        fn get_spherindrical_harmonics(index: u32) -> array<f32, #{SH_4D_COEFF_COUNT}> {\n            return spherindrical_harmonics[index];\n        }\n\n        fn get_timestamp(index: u32) -> f32 {\n            return planar4d_timestamp_from(timestamp_timescale[index]);\n        }\n\n        fn get_time_scale(index: u32) -> f32 {\n            return planar4d_time_scale_from(timestamp_timescale[index]);\n        }\n\n        #ifdef BINARY_GAUSSIAN_OP\n            fn get_rhs_color(\n                index: u32,\n                dir_t: f32,\n                ray_direction: vec3<f32>,\n            ) -> vec3<f32> {\n                return planar4d_color_from_sh(ray_direction, dir_t, rhs_spherindrical_harmonics[index]);\n            }\n\n            fn get_rhs_isotropic_rotations(index: u32) -> mat2x4<f32> {\n                return planar4d_isotropic_rotations(rhs_isotropic_rotations[index]);\n            }\n\n            fn get_rhs_scale(index: u32) -> vec3<f32> {\n                return planar4d_scale_from(rhs_scale_opacity[index]);\n            }\n\n            fn get_rhs_opacity(index: u32) -> f32 {\n                return planar4d_opacity_from(rhs_scale_opacity[index]);\n            }\n\n            fn get_rhs_position(index: u32) -> vec3<f32> {\n                return planar_position(rhs_position_visibility[index]);\n            }\n\n            fn get_rhs_visibility(index: u32) -> f32 {\n                return planar_visibility(rhs_position_visibility[index]);\n            }\n\n            fn get_rhs_spherindrical_harmonics(index: u32) -> array<f32, #{SH_4D_COEFF_COUNT}> {\n                return rhs_spherindrical_harmonics[index];\n            }\n\n            fn get_rhs_timestamp(index: u32) -> f32 {\n                return planar4d_timestamp_from(rhs_timestamp_timescale[index]);\n            }\n\n            fn get_rhs_time_scale(index: u32) -> f32 {\n                return planar4d_time_scale_from(rhs_timestamp_timescale[index]);\n            }\n        #endif\n    #endif\n\n    // TODO: PLANAR_F16 for GAUSSIAN_4D\n#endif\n"
  },
  {
    "path": "src/render/texture.rs",
    "content": "use bevy::{\n    prelude::*,\n    render::{\n        render_resource::{\n            BindGroupLayout, BindGroupLayoutEntry, BindingType, ShaderStages, TextureSampleType,\n            TextureViewDimension,\n        },\n        renderer::RenderDevice,\n    },\n};\nuse static_assertions::assert_cfg;\n\nassert_cfg!(\n    feature = \"planar\",\n    \"texture rendering is only supported with the `planar` feature enabled\",\n);\n\n/// Placeholder plugin for the buffer-texture render path.\n///\n/// Cloud data currently stays on the canonical planar storage path; the\n/// texture-specific bindings are used for sorted-entry indirection only.\n#[derive(Default)]\npub struct BufferTexturePlugin;\n\nimpl Plugin for BufferTexturePlugin {\n    fn build(&self, _app: &mut App) {}\n}\n\npub fn get_sorted_bind_group_layout(render_device: &RenderDevice) -> BindGroupLayout {\n    render_device.create_bind_group_layout(\n        Some(\"texture_sorted_layout\"),\n        &[BindGroupLayoutEntry {\n            binding: 0,\n            visibility: ShaderStages::VERTEX_FRAGMENT | ShaderStages::COMPUTE,\n            ty: BindingType::Texture {\n                view_dimension: TextureViewDimension::D2,\n                sample_type: TextureSampleType::Uint,\n                multisampled: false,\n            },\n            count: None,\n        }],\n    )\n}\n"
  },
  {
    "path": "src/render/texture.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::texture\n\n#ifdef PRECOMPUTE_COVARIANCE_3D\n#import bevy_gaussian_splatting::bindings::{\n    gaussian_uniforms,\n    position_visibility,\n    spherical_harmonics,\n    covariance_3d_opacity,\n};\n#else\n#import bevy_gaussian_splatting::bindings::{\n    gaussian_uniforms,\n    position_visibility,\n    spherical_harmonics,\n    rotation,\n    rotation_scale_opacity,\n    scale_opacity,\n};\n#endif\n\n#import bevy_gaussian_splatting::spherical_harmonics::{\n    shc,\n    spherical_harmonics_lookup,\n    srgb_to_linear,\n}\n\nfn location(index: u32) -> vec2<i32> {\n    return vec2<i32>(\n        i32(index) % i32(gaussian_uniforms.count_root_ceil),\n        i32(index) / i32(gaussian_uniforms.count_root_ceil),\n    );\n}\n\nfn convert_sh_color_to_linear(color: vec3<f32>) -> vec3<f32> {\n    if gaussian_uniforms.color_space == 1u {\n        return color;\n    }\n\n    return srgb_to_linear(color);\n}\n\n#ifdef PLANAR_TEXTURE_F16\n\nfn get_position(index: u32) -> vec3<f32> {\n    let sample = textureLoad(\n        position_visibility,\n        location(index),\n        0,\n    );\n\n    return sample.xyz;\n}\n\nfn get_sh_vec(\n    index: u32,\n    plane: i32,\n) -> vec4<u32> {\n#if SH_VEC4_PLANES == 1\n    return textureLoad(\n        spherical_harmonics,\n        location(index),\n        plane,\n    );\n#else\n    return textureLoad(\n        spherical_harmonics,\n        location(index),\n        plane,\n        0,\n    );\n#endif\n}\n\n#ifdef WEBGL2\nfn get_color(\n    index: u32,\n    ray_direction: vec3<f32>,\n) -> vec3<f32> {\n    let s0 = get_sh_vec(index, 0);\n\n    let v0 = unpack2x16float(s0.x);\n    let v1 = unpack2x16float(s0.y);\n    let v2 = unpack2x16float(s0.z);\n    let v3 = unpack2x16float(s0.w);\n\n    let rds = ray_direction * ray_direction;\n    var color = vec3<f32>(0.5);\n\n    color += shc[ 0] * vec3<f32>(\n        v0.x,\n        v0.y,\n        v1.x,\n    );\n\n#if SH_COEFF_COUNT > 11\n    let r1 = vec3<f32>(\n        v1.y,\n        v2.x,\n        v2.y,\n    );\n\n    let s1 = get_sh_vec(index, 1);\n    let v4 = unpack2x16float(s1.x);\n    let v5 = unpack2x16float(s1.y);\n    let v6 = unpack2x16float(s1.z);\n    let v7 = unpack2x16float(s1.w);\n\n    let r2 = vec3<f32>(\n        v3.x,\n        v3.y,\n        v4.x,\n    );\n\n    let r3 = vec3<f32>(\n        v4.y,\n        v5.x,\n        v5.y,\n    );\n\n    color += shc[ 1] * r1 * ray_direction.y;\n    color += shc[ 2] * r2 * ray_direction.z;\n    color += shc[ 3] * r3 * ray_direction.x;\n#endif\n\n#if SH_COEFF_COUNT > 26\n    let r4 = vec3<f32>(\n        v6.x,\n        v6.y,\n        v7.x,\n    );\n\n    let s2 = get_sh_vec(index, 2);\n    let v8 = unpack2x16float(s2.x);\n    let v9 = unpack2x16float(s2.y);\n    let v10 = unpack2x16float(s2.z);\n    let v11 = unpack2x16float(s2.w);\n\n    let r5 = vec3<f32>(\n        v7.y,\n        v8.x,\n        v8.y,\n    );\n\n    let r6 = vec3<f32>(\n        v9.x,\n        v9.y,\n        v10.x,\n    );\n\n    let r7 = vec3<f32>(\n        v10.y,\n        v11.x,\n        v11.y,\n    );\n\n    let s3 = get_sh_vec(index, 3);\n    let v12 = unpack2x16float(s3.x);\n    let v13 = unpack2x16float(s3.y);\n    let v14 = unpack2x16float(s3.z);\n    let v15 = unpack2x16float(s3.w);\n\n    let r8 = vec3<f32>(\n        v12.x,\n        v12.y,\n        v13.x,\n    );\n\n    color += shc[ 4] * r4 * ray_direction.x * ray_direction.y;\n    color += shc[ 5] * r5 * ray_direction.y * ray_direction.z;\n    color += shc[ 6] * r6 * (2.0 * rds.z - rds.x - rds.y);\n    color += shc[ 7] * r7 * ray_direction.x * ray_direction.z;\n    color += shc[ 8] * r8 * (rds.x - rds.y);\n#endif\n\n#if SH_COEFF_COUNT > 47\n    let r9 = vec3<f32>(\n        v13.y,\n        v14.x,\n        v14.y,\n    );\n\n    let s4 = get_sh_vec(index, 4);\n    let v16 = unpack2x16float(s4.x);\n    let v17 = unpack2x16float(s4.y);\n    let v18 = unpack2x16float(s4.z);\n    let v19 = unpack2x16float(s4.w);\n\n    let r10 = vec3<f32>(\n        v15.x,\n        v15.y,\n        v16.x,\n    );\n\n    let r11 = vec3<f32>(\n        v16.y,\n        v17.x,\n        v17.y,\n    );\n\n    let r12 = vec3<f32>(\n        v18.x,\n        v18.y,\n        v19.x,\n    );\n\n    let s5 = get_sh_vec(index, 5);\n    let v20 = unpack2x16float(s5.x);\n    let v21 = unpack2x16float(s5.y);\n    let v22 = unpack2x16float(s5.z);\n    let v23 = unpack2x16float(s5.w);\n\n    let r13 = vec3<f32>(\n        v19.y,\n        v20.x,\n        v20.y,\n    );\n\n    let r14 = vec3<f32>(\n        v21.x,\n        v21.y,\n        v22.x,\n    );\n\n    let r15 = vec3<f32>(\n        v22.y,\n        v23.x,\n        v23.y,\n    );\n\n    color += shc[ 9] * r9 * ray_direction.y * (3.0 * rds.x - rds.y);\n    color += shc[10] * r10 * ray_direction.x * ray_direction.y * ray_direction.z;\n    color += shc[11] * r11 * ray_direction.y * (4.0 * rds.z - rds.x - rds.y);\n    color += shc[12] * r12 * ray_direction.z * (2.0 * rds.z - 3.0 * rds.x - 3.0 * rds.y);\n    color += shc[13] * r13 * ray_direction.x * (4.0 * rds.z - rds.x - rds.y);\n    color += shc[14] * r14 * ray_direction.z * (rds.x - rds.y);\n    color += shc[15] * r15 * ray_direction.x * (rds.x - 3.0 * rds.y);\n#endif\n\n    return convert_sh_color_to_linear(color);\n}\n#else\nfn get_spherical_harmonics(index: u32) -> array<f32, #{SH_COEFF_COUNT}> {\n    var coefficients: array<f32, #{SH_COEFF_COUNT}>;\n\n    for (var i = 0u; i < #{SH_VEC4_PLANES}u; i = i + 1u) {\n        let sample = get_sh_vec(index, i32(i));\n\n        let v0 = unpack2x16float(sample.x);\n        let v1 = unpack2x16float(sample.y);\n        let v2 = unpack2x16float(sample.z);\n        let v3 = unpack2x16float(sample.w);\n\n        let base_index = i * 8u;\n        coefficients[base_index     ] = v0.x;\n        coefficients[base_index + 1u] = v0.y;\n\n        coefficients[base_index + 2u] = v1.x;\n        coefficients[base_index + 3u] = v1.y;\n\n        coefficients[base_index + 4u] = v2.x;\n        coefficients[base_index + 5u] = v2.y;\n\n        coefficients[base_index + 6u] = v3.x;\n        coefficients[base_index + 7u] = v3.y;\n    }\n\n    return coefficients;\n}\n\nfn get_color(\n    index: u32,\n    ray_direction: vec3<f32>,\n) -> vec3<f32> {\n    let sh = get_spherical_harmonics(index);\n    let color = spherical_harmonics_lookup(ray_direction, sh);\n    return convert_sh_color_to_linear(color);\n}\n#endif\n\n#ifdef PRECOMPUTE_COVARIANCE_3D\n    fn get_cov3d(index: u32) -> array<f32, 6> {\n        let sample = textureLoad(\n            covariance_3d_opacity,\n            location(index),\n            0,\n        );\n\n        let c0 = unpack2x16float(sample.x);\n        let c1 = unpack2x16float(sample.y);\n        let c2 = unpack2x16float(sample.z);\n\n        var cov3d: array<f32, 6>;\n\n        cov3d[0] = c0.y;\n        cov3d[1] = c0.x;\n        cov3d[2] = c1.y;\n        cov3d[3] = c1.x;\n        cov3d[4] = c2.y;\n        cov3d[5] = c2.x;\n\n        return cov3d;\n    }\n#else\n    fn get_rotation(index: u32) -> vec4<f32> {\n        let sample = textureLoad(\n            rotation_scale_opacity,\n            location(index),\n            0,\n        );\n\n        let q0 = unpack2x16float(sample.x);\n        let q1 = unpack2x16float(sample.y);\n\n        return vec4<f32>(\n            q0.yx,\n            q1.yx,\n        );\n    }\n\n    fn get_scale(index: u32) -> vec3<f32> {\n        let sample = textureLoad(\n            rotation_scale_opacity,\n            location(index),\n            0,\n        );\n\n        let s0 = unpack2x16float(sample.z);\n        let s1 = unpack2x16float(sample.w);\n\n        return vec3<f32>(\n            s0.yx,\n            s1.y,\n        );\n    }\n#endif\n\nfn get_opacity(index: u32) -> f32 {\n#ifdef PRECOMPUTE_COVARIANCE_3D\n    let sample = textureLoad(\n        covariance_3d_opacity,\n        location(index),\n        0,\n    );\n\n    return unpack2x16float(sample.w).y;\n#else\n    let sample = textureLoad(\n        rotation_scale_opacity,\n        location(index),\n        0,\n    );\n\n    return unpack2x16float(sample.w).x;\n#endif\n}\n\nfn get_visibility(index: u32) -> f32 {\n    let sample = textureLoad(\n        position_visibility,\n        location(index),\n        0,\n    );\n\n    return sample.w;\n}\n#endif\n\n// TODO: support f32\n#ifdef PLANAR_TEXTURE_F32\nfn get_position(index: u32) -> vec3<f32> {\n    return position_visibility[index].xyz;\n}\n\nfn get_spherical_harmonics(index: u32) -> array<f32, #{SH_COEFF_COUNT}> {\n    return spherical_harmonics[index];\n}\n\nfn get_rotation(index: u32) -> vec4<f32> {\n    return rotation[index];\n}\n\nfn get_scale(index: u32) -> vec3<f32> {\n    return scale_opacity[index].xyz;\n}\n\nfn get_opacity(index: u32) -> f32 {\n    return scale_opacity[index].w;\n}\n\nfn get_visibility(index: u32) -> f32 {\n    return position_visibility[index].w;\n}\n#endif\n"
  },
  {
    "path": "src/render/transform.wgsl",
    "content": "#define_import_path bevy_gaussian_splatting::transform\n\n#import bevy_gaussian_splatting::bindings::view\n\nfn world_to_clip(world_pos: vec3<f32>) -> vec4<f32> {\n    let homogenous_pos = view.unjittered_clip_from_world * vec4<f32>(world_pos, 1.0);\n    return homogenous_pos / (homogenous_pos.w + 0.000000001);\n}\n\nfn in_frustum(clip_space_pos: vec3<f32>) -> bool {\n    return abs(clip_space_pos.x) < 1.1\n        && abs(clip_space_pos.y) < 1.1\n        && abs(clip_space_pos.z - 0.5) < 0.5;\n}\n"
  },
  {
    "path": "src/sort/bitonic.rs",
    "content": "\n"
  },
  {
    "path": "src/sort/bitonic.wgsl",
    "content": "\n"
  },
  {
    "path": "src/sort/mod.rs",
    "content": "#![allow(dead_code)] // ShaderType derives emit unused check helpers\nuse core::time::Duration;\nuse std::marker::PhantomData;\n\nuse bevy::{\n    asset::RenderAssetUsages,\n    ecs::system::{SystemParamItem, lifetimeless::SRes},\n    math::Vec3A,\n    platform::time::Instant,\n    prelude::*,\n    render::{\n        extract_component::{ExtractComponent, ExtractComponentPlugin},\n        render_asset::{PrepareAssetError, RenderAsset, RenderAssetPlugin},\n        render_resource::*,\n        renderer::RenderDevice,\n    },\n};\nuse bevy_interleave::prelude::*;\nuse bytemuck::{Pod, Zeroable};\nuse serde::{Deserialize, Serialize};\nuse static_assertions::assert_cfg;\n\nuse crate::{CloudSettings, camera::GaussianCamera, gaussian::interface::CommonCloud};\n\n#[cfg(feature = \"sort_bitonic\")]\npub mod bitonic;\n\n#[cfg(all(feature = \"sort_radix\", not(feature = \"buffer_texture\")))]\npub mod radix;\n\n#[cfg(feature = \"sort_rayon\")]\npub mod rayon;\n\n#[cfg(feature = \"sort_std\")]\npub mod std_sort; // rename to std_sort.rs to avoid name conflict with std crate\n\nassert_cfg!(\n    any(\n        feature = \"sort_radix\",\n        feature = \"sort_rayon\",\n        feature = \"sort_std\",\n    ),\n    \"no sort mode enabled\",\n);\n\n#[derive(Component, Debug, Clone, PartialEq, Reflect, Serialize, Deserialize)]\npub enum SortMode {\n    None,\n\n    #[cfg(all(feature = \"sort_radix\", not(feature = \"buffer_texture\")))]\n    Radix,\n\n    #[cfg(feature = \"sort_rayon\")]\n    Rayon,\n\n    #[cfg(feature = \"sort_std\")]\n    Std,\n}\n\nimpl Default for SortMode {\n    #[allow(unreachable_code)]\n    fn default() -> Self {\n        #[cfg(all(feature = \"sort_radix\", not(feature = \"buffer_texture\")))]\n        return Self::Radix;\n\n        #[cfg(feature = \"sort_rayon\")]\n        return Self::Rayon;\n\n        #[cfg(feature = \"sort_std\")]\n        return Self::Std;\n\n        Self::None\n    }\n}\n\n#[derive(Resource, Debug, Clone, PartialEq, Reflect)]\n#[reflect(Resource)]\npub struct SortConfig {\n    pub period_ms: usize,\n}\n\nimpl Default for SortConfig {\n    fn default() -> Self {\n        Self { period_ms: 1000 }\n    }\n}\n\n#[derive(Default)]\npub struct SortPluginFlag;\nimpl Plugin for SortPluginFlag {\n    fn build(&self, _app: &mut App) {}\n}\n\n// TODO: make this generic /w shared components\n#[derive(Default)]\npub struct SortPlugin<R: PlanarSync> {\n    phantom: PhantomData<R>,\n}\n\nimpl<R: PlanarSync> Plugin for SortPlugin<R>\nwhere\n    R::PlanarType: CommonCloud,\n    R::GpuPlanarType: GpuPlanarStorage,\n{\n    fn build(&self, app: &mut App) {\n        #[cfg(all(feature = \"sort_radix\", not(feature = \"buffer_texture\")))]\n        app.add_plugins(radix::RadixSortPlugin::<R>::default());\n\n        #[cfg(feature = \"sort_rayon\")]\n        app.add_plugins(rayon::RayonSortPlugin::<R>::default());\n\n        #[cfg(feature = \"sort_std\")]\n        app.add_plugins(std_sort::StdSortPlugin::<R>::default());\n\n        app.add_systems(Update, auto_insert_sorted_entries::<R>);\n\n        if app.is_plugin_added::<SortPluginFlag>() {\n            debug!(\"sort plugin flag already added\");\n            return;\n        }\n        app.add_plugins(SortPluginFlag);\n\n        app.register_type::<SortConfig>();\n        app.init_resource::<SortConfig>();\n\n        app.register_type::<SortedEntries>();\n        app.register_type::<SortedEntriesHandle>();\n        app.init_asset::<SortedEntries>();\n        app.register_asset_reflect::<SortedEntries>();\n\n        app.register_type::<SortTrigger>();\n        app.add_plugins(ExtractComponentPlugin::<SortTrigger>::default());\n\n        app.add_plugins(RenderAssetPlugin::<GpuSortedEntry>::default());\n\n        app.add_systems(Update, (update_sort_trigger, update_sorted_entries_sizes));\n\n        #[cfg(feature = \"buffer_texture\")]\n        app.add_systems(PostUpdate, update_textures_on_change);\n    }\n}\n\n#[derive(Component, ExtractComponent, Debug, Default, Clone, PartialEq, Reflect)]\n#[reflect(Component)]\npub struct SortTrigger {\n    pub camera_index: usize,\n    pub needs_sort: bool,\n    pub last_camera_position: Vec3A,\n    pub last_sort_time: Option<Instant>,\n}\n\n#[allow(clippy::type_complexity)]\nfn update_sort_trigger(\n    mut commands: Commands,\n    new_gaussian_cameras: Query<Entity, (With<Camera>, With<GaussianCamera>, Without<SortTrigger>)>,\n    mut existing_sort_triggers: Query<(&GlobalTransform, &Camera, &mut SortTrigger)>,\n    sort_config: Res<SortConfig>,\n) {\n    for entity in new_gaussian_cameras.iter() {\n        commands.entity(entity).insert(SortTrigger::default());\n    }\n\n    for (camera_transform, camera, mut sort_trigger) in existing_sort_triggers.iter_mut() {\n        match sort_trigger.last_sort_time.as_ref() {\n            None => {\n                assert!(\n                    camera.order >= 0,\n                    \"camera order must be a non-negative index into gaussian cameras\"\n                );\n\n                sort_trigger.camera_index = camera.order as usize;\n                sort_trigger.needs_sort = true;\n                sort_trigger.last_sort_time = Some(Instant::now());\n                continue;\n            }\n            Some(last_sort_time)\n                if last_sort_time.elapsed()\n                    < Duration::from_millis(sort_config.period_ms as u64) =>\n            {\n                continue;\n            }\n            Some(_) => {}\n        }\n\n        let camera_position = camera_transform.affine().translation;\n        let camera_movement = sort_trigger.last_camera_position != camera_position;\n\n        if camera_movement {\n            sort_trigger.needs_sort = true;\n            sort_trigger.last_sort_time = Some(Instant::now());\n            sort_trigger.last_camera_position = camera_position;\n        }\n    }\n}\n\n#[cfg(feature = \"buffer_texture\")]\nfn update_textures_on_change(\n    mut images: ResMut<Assets<Image>>,\n    mut ev_asset: MessageReader<AssetEvent<SortedEntries>>,\n    sorted_entries_res: Res<Assets<SortedEntries>>,\n) {\n    for ev in ev_asset.read() {\n        match ev {\n            AssetEvent::Modified { id } => {\n                let sorted_entries = sorted_entries_res.get(*id).unwrap();\n                let image = images.get_mut(&sorted_entries.texture).unwrap();\n\n                image.data = Some(bytemuck::cast_slice(sorted_entries.sorted.as_slice()).to_vec());\n            }\n            AssetEvent::Added { id: _ } => {}\n            AssetEvent::Removed { id: _ } => {}\n            AssetEvent::LoadedWithDependencies { id: _ } => {}\n            AssetEvent::Unused { id: _ } => {}\n        }\n    }\n}\n\n#[allow(clippy::type_complexity)]\nfn auto_insert_sorted_entries<R: PlanarSync>(\n    mut commands: Commands,\n    asset_server: Res<AssetServer>,\n    gaussian_clouds_res: Res<Assets<R::PlanarType>>,\n    mut sorted_entries_res: ResMut<Assets<SortedEntries>>,\n    gaussian_clouds: Query<\n        (Entity, &R::PlanarTypeHandle, &CloudSettings),\n        Without<SortedEntriesHandle>,\n    >,\n    gaussian_cameras: Query<Entity, (With<Camera>, With<GaussianCamera>)>,\n    #[cfg(feature = \"buffer_texture\")] mut images: ResMut<Assets<Image>>,\n) where\n    R::PlanarType: CommonCloud,\n{\n    let camera_count = gaussian_cameras.iter().len();\n\n    if camera_count == 0 {\n        debug!(\"no gaussian cameras found\");\n        return;\n    }\n\n    for (entity, gaussian_cloud_handle, _settings) in gaussian_clouds.iter() {\n        // // TODO: specialize vertex shader for sort mode (e.g. draw_indirect but no sort indirection)\n        // if settings.sort_mode == SortMode::None {\n        //     continue;\n        // }\n\n        if let Some(load_state) = asset_server.get_load_state(gaussian_cloud_handle.handle())\n            && load_state.is_loading()\n        {\n            debug!(\"cloud asset is still loading\");\n            continue;\n        }\n\n        let cloud = gaussian_clouds_res.get(gaussian_cloud_handle.handle());\n        if cloud.is_none() {\n            debug!(\"cloud asset is not loaded\");\n            continue;\n        }\n        let cloud = cloud.unwrap();\n\n        let sorted_entries = sorted_entries_res.add(SortedEntries::new(\n            camera_count,\n            cloud.len_sqrt_ceil().pow(2),\n            #[cfg(feature = \"buffer_texture\")]\n            &mut images,\n        ));\n\n        commands\n            .entity(entity)\n            .insert(SortedEntriesHandle(sorted_entries));\n    }\n}\n\nfn update_sorted_entries_sizes(\n    mut sorted_entries_res: ResMut<Assets<SortedEntries>>,\n    sorted_entries: Query<&SortedEntriesHandle>,\n    gaussian_cameras: Query<Entity, (With<Camera>, With<GaussianCamera>)>,\n    #[cfg(feature = \"buffer_texture\")] mut images: ResMut<Assets<Image>>,\n) {\n    let camera_count: usize = gaussian_cameras.iter().len();\n\n    for handle in sorted_entries.iter() {\n        if camera_count == 0 {\n            sorted_entries_res.remove(handle);\n            continue;\n        }\n\n        if let Some(sorted_entries) = sorted_entries_res.get(handle)\n            && sorted_entries.camera_count != camera_count\n        {\n            let new_entry = SortedEntries::new(\n                camera_count,\n                sorted_entries.entry_count,\n                #[cfg(feature = \"buffer_texture\")]\n                &mut images,\n            );\n            let _ = sorted_entries_res.insert(handle, new_entry);\n        }\n    }\n}\n\n#[derive(Component, Clone, Debug, Default, PartialEq, Reflect)]\n#[reflect(Component, Default)]\npub struct SortedEntriesHandle(pub Handle<SortedEntries>);\n\nimpl From<Handle<SortedEntries>> for SortedEntriesHandle {\n    fn from(handle: Handle<SortedEntries>) -> Self {\n        Self(handle)\n    }\n}\n\nimpl From<SortedEntriesHandle> for AssetId<SortedEntries> {\n    fn from(handle: SortedEntriesHandle) -> Self {\n        handle.0.id()\n    }\n}\n\nimpl From<&SortedEntriesHandle> for AssetId<SortedEntries> {\n    fn from(handle: &SortedEntriesHandle) -> Self {\n        handle.0.id()\n    }\n}\n\n#[allow(dead_code)]\n#[derive(Clone, Copy, Debug, Default, PartialEq, Reflect, ShaderType, Pod, Zeroable)]\n#[repr(C)]\npub struct SortEntry {\n    pub key: u32,\n    pub index: u32,\n}\n\n#[derive(Clone, Asset, Debug, Default, PartialEq, Reflect)]\npub struct SortedEntries {\n    pub camera_count: usize,\n    pub entry_count: usize,\n    pub sorted: Vec<SortEntry>,\n\n    #[cfg(feature = \"buffer_texture\")]\n    pub texture: Handle<Image>,\n}\n\nimpl SortedEntries {\n    pub fn new(\n        camera_count: usize,\n        entry_count: usize,\n        #[cfg(feature = \"buffer_texture\")] images: &mut Assets<Image>,\n    ) -> Self {\n        let sorted: Vec<SortEntry> = (0..camera_count)\n            .flat_map(|_camera_idx| {\n                (0..entry_count).map(|idx| SortEntry {\n                    key: 1,\n                    index: idx as u32,\n                })\n            })\n            .collect();\n\n        #[cfg(feature = \"buffer_texture\")]\n        let mut sorted_entries = SortedEntries {\n            camera_count,\n            entry_count,\n            sorted,\n            texture: Handle::default(),\n        };\n\n        #[cfg(not(feature = \"buffer_texture\"))]\n        let sorted_entries = SortedEntries {\n            camera_count,\n            entry_count,\n            sorted,\n        };\n\n        #[cfg(feature = \"buffer_texture\")]\n        {\n            let side = (entry_count as f32).sqrt().ceil() as u32;\n            let data = bytemuck::cast_slice(sorted_entries.sorted.as_slice()).to_vec();\n            let mut image = Image::new(\n                Extent3d {\n                    width: side,\n                    height: side,\n                    depth_or_array_layers: camera_count as u32,\n                },\n                TextureDimension::D2,\n                data,\n                TextureFormat::Rg32Uint,\n                RenderAssetUsages::default(),\n            );\n            image.texture_descriptor.usage =\n                TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST;\n            sorted_entries.texture = images.add(image);\n        }\n\n        sorted_entries\n    }\n}\n\nimpl RenderAsset for GpuSortedEntry {\n    type SourceAsset = SortedEntries;\n    type Param = SRes<RenderDevice>;\n\n    fn prepare_asset(\n        source: Self::SourceAsset,\n        _: AssetId<Self::SourceAsset>,\n        render_device: &mut SystemParamItem<Self::Param>,\n        _: Option<&Self>,\n    ) -> Result<Self, PrepareAssetError<Self::SourceAsset>> {\n        let sorted_entry_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor {\n            label: Some(\"sorted_entry_buffer\"),\n            contents: bytemuck::cast_slice(source.sorted.as_slice()),\n            usage: BufferUsages::COPY_SRC | BufferUsages::COPY_DST | BufferUsages::STORAGE,\n        });\n\n        let count = source.sorted.len();\n\n        Ok(GpuSortedEntry {\n            sorted_entry_buffer,\n            count,\n\n            #[cfg(feature = \"buffer_texture\")]\n            texture: source.texture,\n        })\n    }\n\n    fn asset_usage(_: &Self::SourceAsset) -> RenderAssetUsages {\n        RenderAssetUsages::default()\n    }\n}\n\n// TODO: support instancing and multiple cameras\n//       separate entry_buffer_a binding into unique a bind group to optimize buffer updates\n#[derive(Debug, Clone)]\npub struct GpuSortedEntry {\n    pub sorted_entry_buffer: Buffer,\n    pub count: usize,\n\n    #[cfg(feature = \"buffer_texture\")]\n    pub texture: Handle<Image>,\n}\n"
  },
  {
    "path": "src/sort/radix.rs",
    "content": "#[cfg(feature = \"morph_interpolate\")]\nuse std::any::TypeId;\nuse std::collections::HashMap;\n\nuse bevy::{\n    asset::{load_internal_asset, uuid_handle},\n    core_pipeline::{\n        core_3d::graph::{Core3d, Node3d},\n        prepass::PreviousViewUniformOffset,\n    },\n    prelude::*,\n    render::{\n        Render, RenderApp, RenderSystems,\n        render_asset::RenderAssets,\n        render_graph::{Node, NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel},\n        render_resource::{\n            BindGroup, BindGroupEntry, BindGroupLayout, BindGroupLayoutDescriptor,\n            BindGroupLayoutEntry, BindingResource, BindingType, Buffer, BufferBinding,\n            BufferBindingType, BufferDescriptor, BufferInitDescriptor, BufferSize, BufferUsages,\n            CachedComputePipelineId, CachedPipelineState, ComputePassDescriptor,\n            ComputePipelineDescriptor, PipelineCache, ShaderStages,\n        },\n        renderer::{RenderContext, RenderDevice},\n        view::ViewUniformOffset,\n    },\n};\nuse bevy_interleave::{interface::storage::PlanarStorageBindGroup, prelude::*};\nuse static_assertions::assert_cfg;\n\n#[cfg(feature = \"morph_interpolate\")]\nuse crate::{gaussian::formats::planar_3d::PlanarGaussian3d, morph::interpolate::InterpolateLabel};\n\nuse crate::{\n    CloudSettings, GaussianCamera,\n    render::{\n        CloudPipeline, CloudPipelineKey, GaussianUniformBindGroups, ShaderDefines, shader_defs,\n    },\n    sort::{GpuSortedEntry, SortEntry, SortMode, SortPluginFlag, SortedEntriesHandle},\n};\n\nassert_cfg!(\n    not(all(feature = \"sort_radix\", feature = \"buffer_texture\",)),\n    \"sort_radix and buffer_texture are incompatible\",\n);\n\nconst RADIX_SHADER_HANDLE: Handle<Shader> = uuid_handle!(\"dedb3ddf-f254-4361-8762-e221774de1ed\");\nconst TEMPORAL_SORT_SHADER_HANDLE: Handle<Shader> =\n    uuid_handle!(\"11986b71-25d8-410b-adfa-6afb107ae4de\");\nconst RADIX_PIPELINE_RESET: usize = 0;\nconst RADIX_PIPELINE_A: usize = 1;\nconst RADIX_PIPELINE_B: usize = 2;\nconst RADIX_PIPELINE_C_COUNT: usize = 3;\nconst RADIX_PIPELINE_C_SCAN: usize = 4;\nconst RADIX_PIPELINE_C_SCATTER: usize = 5;\nconst RADIX_PIPELINE_COUNT: usize = 6;\n\n#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]\npub struct RadixSortLabel;\n\n#[derive(Default)]\npub struct RadixSortPlugin<R: PlanarSync> {\n    phantom: std::marker::PhantomData<R>,\n}\n\nimpl<R: PlanarSync> Plugin for RadixSortPlugin<R>\nwhere\n    R::GpuPlanarType: GpuPlanarStorage,\n{\n    fn build(&self, app: &mut App) {\n        // TODO: run once\n        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n            render_app.add_systems(\n                Render,\n                (queue_radix_bind_group::<R>.in_set(RenderSystems::Queue),),\n            );\n\n            render_app.init_resource::<RadixSortBuffers<R>>();\n            render_app.add_systems(ExtractSchedule, update_sort_buffers::<R>);\n        }\n\n        if app.is_plugin_added::<SortPluginFlag>() {\n            debug!(\"sort plugin already added\");\n            return;\n        }\n\n        load_internal_asset!(app, RADIX_SHADER_HANDLE, \"radix.wgsl\", Shader::from_wgsl);\n\n        load_internal_asset!(\n            app,\n            TEMPORAL_SORT_SHADER_HANDLE,\n            \"temporal.wgsl\",\n            Shader::from_wgsl\n        );\n\n        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n            render_app.add_render_graph_node::<RadixSortNode<R>>(Core3d, RadixSortLabel);\n\n            #[cfg(feature = \"morph_interpolate\")]\n            if TypeId::of::<R::PlanarType>() == TypeId::of::<PlanarGaussian3d>() {\n                render_app.add_render_graph_edge(Core3d, InterpolateLabel, RadixSortLabel);\n            }\n\n            render_app.add_render_graph_edge(Core3d, RadixSortLabel, Node3d::LatePrepass);\n        }\n    }\n\n    fn finish(&self, app: &mut App) {\n        if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n            render_app.init_resource::<RadixSortPipeline<R>>();\n        }\n    }\n}\n\n#[derive(Resource)]\npub struct RadixSortBuffers<R: PlanarSync> {\n    // TODO: use a more ECS-friendly approach\n    pub asset_map: HashMap<AssetId<R::PlanarType>, GpuRadixBuffers>,\n}\n\nimpl<R: PlanarSync> Default for RadixSortBuffers<R> {\n    fn default() -> Self {\n        RadixSortBuffers {\n            asset_map: HashMap::new(),\n        }\n    }\n}\n\n#[derive(Debug, Clone)]\npub struct GpuRadixBuffers {\n    pub sorting_global_buffer: Buffer,\n    pub sorting_status_counter_buffer: Buffer,\n    pub sorting_pass_buffers: [Buffer; 4],\n    pub entry_buffer_b: Buffer,\n}\n\nimpl GpuRadixBuffers {\n    pub fn new(count: usize, render_device: &RenderDevice) -> Self {\n        let sorting_global_buffer = render_device.create_buffer(&BufferDescriptor {\n            label: Some(\"sorting global buffer\"),\n            size: ShaderDefines::default().sorting_buffer_size as u64,\n            usage: BufferUsages::STORAGE | BufferUsages::COPY_DST | BufferUsages::COPY_SRC,\n            mapped_at_creation: false,\n        });\n\n        let sorting_status_counter_buffer = render_device.create_buffer(&BufferDescriptor {\n            label: Some(\"status counters buffer\"),\n            size: ShaderDefines::default().sorting_status_counters_buffer_size(count) as u64,\n            usage: BufferUsages::STORAGE | BufferUsages::COPY_DST | BufferUsages::COPY_SRC,\n            mapped_at_creation: false,\n        });\n\n        let sorting_pass_buffers = (0..4)\n            .map(|idx| {\n                render_device.create_buffer_with_data(&BufferInitDescriptor {\n                    label: format!(\"sorting pass buffer {idx}\").as_str().into(),\n                    contents: &[idx as u8, 0, 0, 0],\n                    usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,\n                })\n            })\n            .collect::<Vec<Buffer>>()\n            .try_into()\n            .unwrap();\n\n        let entry_buffer_b = render_device.create_buffer(&BufferDescriptor {\n            label: Some(\"entry buffer b\"),\n            size: (count * std::mem::size_of::<SortEntry>()) as u64,\n            usage: BufferUsages::STORAGE | BufferUsages::COPY_SRC,\n            mapped_at_creation: false,\n        });\n\n        GpuRadixBuffers {\n            sorting_global_buffer,\n            sorting_status_counter_buffer,\n            sorting_pass_buffers,\n            entry_buffer_b,\n        }\n    }\n}\n\nfn update_sort_buffers<R: PlanarSync>(\n    gpu_gaussian_clouds: Res<RenderAssets<R::GpuPlanarType>>,\n    mut sort_buffers: ResMut<RadixSortBuffers<R>>,\n    render_device: Res<RenderDevice>,\n) {\n    for (asset_id, cloud) in gpu_gaussian_clouds.iter() {\n        // TODO: handle cloud resize operations and resolve leaked stale buffers\n        if sort_buffers.asset_map.contains_key(&asset_id) {\n            continue;\n        }\n\n        let gpu_radix_buffers = GpuRadixBuffers::new(cloud.len(), &render_device);\n        sort_buffers.asset_map.insert(asset_id, gpu_radix_buffers);\n    }\n}\n\n#[derive(Resource)]\npub struct RadixSortPipeline<R: PlanarSync> {\n    pub radix_sort_layout: BindGroupLayout,\n    pub radix_sort_pipelines: [CachedComputePipelineId; RADIX_PIPELINE_COUNT],\n    phantom: std::marker::PhantomData<R>,\n}\n\nimpl<R: PlanarSync> FromWorld for RadixSortPipeline<R> {\n    fn from_world(render_world: &mut World) -> Self {\n        let render_device = render_world.resource::<RenderDevice>();\n        let gaussian_cloud_pipeline = render_world.resource::<CloudPipeline<R>>();\n\n        let sorting_buffer_entry = BindGroupLayoutEntry {\n            binding: 1,\n            visibility: ShaderStages::COMPUTE,\n            ty: BindingType::Buffer {\n                ty: BufferBindingType::Storage { read_only: false },\n                has_dynamic_offset: false,\n                min_binding_size: BufferSize::new(\n                    ShaderDefines::default().sorting_buffer_size as u64,\n                ),\n            },\n            count: None,\n        };\n\n        let sorting_status_counters_buffer_entry = BindGroupLayoutEntry {\n            binding: 2,\n            visibility: ShaderStages::COMPUTE,\n            ty: BindingType::Buffer {\n                ty: BufferBindingType::Storage { read_only: false },\n                has_dynamic_offset: false,\n                min_binding_size: BufferSize::new(\n                    ShaderDefines::default().sorting_status_counters_buffer_size(1) as u64,\n                ),\n            },\n            count: None,\n        };\n\n        let draw_indirect_buffer_entry = BindGroupLayoutEntry {\n            binding: 3,\n            visibility: ShaderStages::COMPUTE,\n            ty: BindingType::Buffer {\n                ty: BufferBindingType::Storage { read_only: false },\n                has_dynamic_offset: false,\n                min_binding_size: BufferSize::new(\n                    std::mem::size_of::<wgpu::util::DrawIndirectArgs>() as u64,\n                ),\n            },\n            count: None,\n        };\n\n        let radix_sort_layout_entries = [\n            BindGroupLayoutEntry {\n                binding: 0,\n                visibility: ShaderStages::COMPUTE,\n                ty: BindingType::Buffer {\n                    ty: BufferBindingType::Uniform,\n                    has_dynamic_offset: false,\n                    min_binding_size: BufferSize::new(std::mem::size_of::<u32>() as u64),\n                },\n                count: None,\n            },\n            sorting_buffer_entry,\n            sorting_status_counters_buffer_entry,\n            draw_indirect_buffer_entry,\n            BindGroupLayoutEntry {\n                binding: 4,\n                visibility: ShaderStages::COMPUTE,\n                ty: BindingType::Buffer {\n                    ty: BufferBindingType::Storage { read_only: false },\n                    has_dynamic_offset: false,\n                    min_binding_size: BufferSize::new(std::mem::size_of::<SortEntry>() as u64),\n                },\n                count: None,\n            },\n            BindGroupLayoutEntry {\n                binding: 5,\n                visibility: ShaderStages::COMPUTE,\n                ty: BindingType::Buffer {\n                    ty: BufferBindingType::Storage { read_only: false },\n                    has_dynamic_offset: false,\n                    min_binding_size: BufferSize::new(std::mem::size_of::<SortEntry>() as u64),\n                },\n                count: None,\n            },\n        ];\n        let radix_sort_layout_desc =\n            BindGroupLayoutDescriptor::new(\"radix_sort_layout\", &radix_sort_layout_entries);\n        let radix_sort_layout = render_device\n            .create_bind_group_layout(Some(\"radix_sort_layout\"), &radix_sort_layout_entries);\n\n        let sorting_layout = vec![\n            gaussian_cloud_pipeline.compute_view_layout_desc.clone(),\n            gaussian_cloud_pipeline.gaussian_uniform_layout_desc.clone(),\n            gaussian_cloud_pipeline.gaussian_cloud_layout_desc.clone(),\n            radix_sort_layout_desc.clone(),\n        ];\n        let shader_defs = shader_defs(CloudPipelineKey::default());\n\n        let pipeline_cache = render_world.resource::<PipelineCache>();\n        let radix_reset = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {\n            label: Some(\"radix_sort_reset\".into()),\n            layout: sorting_layout.clone(),\n            push_constant_ranges: vec![],\n            shader: RADIX_SHADER_HANDLE,\n            shader_defs: shader_defs.clone(),\n            entry_point: Some(\"radix_reset\".into()),\n            zero_initialize_workgroup_memory: true,\n        });\n\n        let radix_sort_a = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {\n            label: Some(\"radix_sort_a\".into()),\n            layout: sorting_layout.clone(),\n            push_constant_ranges: vec![],\n            shader: RADIX_SHADER_HANDLE,\n            shader_defs: shader_defs.clone(),\n            entry_point: Some(\"radix_sort_a\".into()),\n            zero_initialize_workgroup_memory: true,\n        });\n\n        let radix_sort_b = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {\n            label: Some(\"radix_sort_b\".into()),\n            layout: sorting_layout.clone(),\n            push_constant_ranges: vec![],\n            shader: RADIX_SHADER_HANDLE,\n            shader_defs: shader_defs.clone(),\n            entry_point: Some(\"radix_sort_b\".into()),\n            zero_initialize_workgroup_memory: true,\n        });\n\n        let radix_sort_c_count = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {\n            label: Some(\"radix_sort_c_count_tiles\".into()),\n            layout: sorting_layout.clone(),\n            push_constant_ranges: vec![],\n            shader: RADIX_SHADER_HANDLE,\n            shader_defs: shader_defs.clone(),\n            entry_point: Some(\"radix_sort_c_count_tiles\".into()),\n            zero_initialize_workgroup_memory: true,\n        });\n\n        let radix_sort_c_scan = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {\n            label: Some(\"radix_sort_c_scan_tiles\".into()),\n            layout: sorting_layout.clone(),\n            push_constant_ranges: vec![],\n            shader: RADIX_SHADER_HANDLE,\n            shader_defs: shader_defs.clone(),\n            entry_point: Some(\"radix_sort_c_scan_tiles\".into()),\n            zero_initialize_workgroup_memory: true,\n        });\n\n        let radix_sort_c_scatter =\n            pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor {\n                label: Some(\"radix_sort_c_scatter\".into()),\n                layout: sorting_layout.clone(),\n                push_constant_ranges: vec![],\n                shader: RADIX_SHADER_HANDLE,\n                shader_defs: shader_defs.clone(),\n                entry_point: Some(\"radix_sort_c_scatter\".into()),\n                zero_initialize_workgroup_memory: true,\n            });\n\n        RadixSortPipeline {\n            radix_sort_layout,\n            radix_sort_pipelines: [\n                radix_reset,\n                radix_sort_a,\n                radix_sort_b,\n                radix_sort_c_count,\n                radix_sort_c_scan,\n                radix_sort_c_scatter,\n            ],\n            phantom: std::marker::PhantomData,\n        }\n    }\n}\n\n#[derive(Component)]\npub struct RadixBindGroup {\n    // For each digit pass idx in 0..RADIX_DIGIT_PLACES, we create 2 bind groups (parity 0/1):\n    // index = pass_idx * 2 + parity (parity 0: input=sorted_entries, output=entry_buffer_b; parity 1: input=entry_buffer_b, output=sorted_entries)\n    pub radix_sort_bind_groups: [BindGroup; 8],\n}\n\n#[allow(clippy::too_many_arguments)]\npub fn queue_radix_bind_group<R: PlanarSync>(\n    mut commands: Commands,\n    radix_pipeline: Res<RadixSortPipeline<R>>,\n    render_device: Res<RenderDevice>,\n    asset_server: Res<AssetServer>,\n    gaussian_cloud_res: Res<RenderAssets<R::GpuPlanarType>>,\n    sorted_entries_res: Res<RenderAssets<GpuSortedEntry>>,\n    gaussian_clouds: Query<(\n        Entity,\n        &R::PlanarTypeHandle,\n        &SortedEntriesHandle,\n        &CloudSettings,\n    )>,\n    sort_buffers: Res<RadixSortBuffers<R>>,\n) where\n    R::GpuPlanarType: GpuPlanarStorage,\n{\n    for (entity, cloud_handle, sorted_entries_handle, settings) in gaussian_clouds.iter() {\n        if settings.sort_mode != SortMode::Radix {\n            commands.entity(entity).remove::<RadixBindGroup>();\n            continue;\n        }\n\n        // TODO: deduplicate asset load checks\n        if let Some(load_state) = asset_server.get_load_state(cloud_handle.handle())\n            && load_state.is_loading()\n        {\n            continue;\n        }\n\n        if gaussian_cloud_res.get(cloud_handle.handle()).is_none() {\n            continue;\n        }\n\n        if let Some(load_state) = asset_server.get_load_state(&sorted_entries_handle.0)\n            && load_state.is_loading()\n        {\n            continue;\n        }\n\n        if sorted_entries_res.get(sorted_entries_handle).is_none() {\n            continue;\n        }\n\n        if !sort_buffers\n            .asset_map\n            .contains_key(&cloud_handle.handle().id())\n        {\n            continue;\n        }\n\n        let cloud = gaussian_cloud_res.get(cloud_handle.handle()).unwrap();\n        let sorted_entries = sorted_entries_res.get(sorted_entries_handle).unwrap();\n        let sorting_assets = &sort_buffers.asset_map[&cloud_handle.handle().id()];\n\n        let sorting_global_entry = BindGroupEntry {\n            binding: 1,\n            resource: BindingResource::Buffer(BufferBinding {\n                buffer: &sorting_assets.sorting_global_buffer,\n                offset: 0,\n                size: BufferSize::new(sorting_assets.sorting_global_buffer.size()),\n            }),\n        };\n\n        let sorting_status_counters_entry = BindGroupEntry {\n            binding: 2,\n            resource: BindingResource::Buffer(BufferBinding {\n                buffer: &sorting_assets.sorting_status_counter_buffer,\n                offset: 0,\n                size: BufferSize::new(sorting_assets.sorting_status_counter_buffer.size()),\n            }),\n        };\n\n        let draw_indirect_entry = BindGroupEntry {\n            binding: 3,\n            resource: BindingResource::Buffer(BufferBinding {\n                buffer: cloud.draw_indirect_buffer(),\n                offset: 0,\n                size: BufferSize::new(cloud.draw_indirect_buffer().size()),\n            }),\n        };\n\n        let radix_sort_bind_groups: [BindGroup; 8] = {\n            let mut groups: Vec<BindGroup> = Vec::with_capacity(8);\n            for pass_idx in 0..4 {\n                for parity in 0..=1 {\n                    let (input_buf, output_buf) = if parity == 0 {\n                        (\n                            &sorted_entries.sorted_entry_buffer,\n                            &sorting_assets.entry_buffer_b,\n                        )\n                    } else {\n                        (\n                            &sorting_assets.entry_buffer_b,\n                            &sorted_entries.sorted_entry_buffer,\n                        )\n                    };\n\n                    let group = render_device.create_bind_group(\n                        format!(\"radix_sort_bind_group pass={pass_idx} parity={parity}\").as_str(),\n                        &radix_pipeline.radix_sort_layout,\n                        &[\n                            // sorting_pass_index (u32) == pass_idx regardless of parity\n                            BindGroupEntry {\n                                binding: 0,\n                                resource: BindingResource::Buffer(BufferBinding {\n                                    buffer: &sorting_assets.sorting_pass_buffers[pass_idx],\n                                    offset: 0,\n                                    size: BufferSize::new(std::mem::size_of::<u32>() as u64),\n                                }),\n                            },\n                            sorting_global_entry.clone(),\n                            sorting_status_counters_entry.clone(),\n                            draw_indirect_entry.clone(),\n                            // input_entries\n                            BindGroupEntry {\n                                binding: 4,\n                                resource: BindingResource::Buffer(BufferBinding {\n                                    buffer: input_buf,\n                                    offset: 0,\n                                    size: BufferSize::new(\n                                        (cloud.len() * std::mem::size_of::<SortEntry>()) as u64,\n                                    ),\n                                }),\n                            },\n                            // output_entries\n                            BindGroupEntry {\n                                binding: 5,\n                                resource: BindingResource::Buffer(BufferBinding {\n                                    buffer: output_buf,\n                                    offset: 0,\n                                    size: BufferSize::new(\n                                        (cloud.len() * std::mem::size_of::<SortEntry>()) as u64,\n                                    ),\n                                }),\n                            },\n                        ],\n                    );\n                    groups.push(group);\n                }\n            }\n            groups.try_into().unwrap()\n        };\n\n        commands.entity(entity).insert(RadixBindGroup {\n            radix_sort_bind_groups,\n        });\n    }\n}\n\npub struct RadixSortNode<R: PlanarSync> {\n    gaussian_clouds: QueryState<(\n        &'static R::PlanarTypeHandle,\n        &'static PlanarStorageBindGroup<R>,\n        &'static RadixBindGroup,\n    )>,\n    initialized: bool,\n    view_bind_group: QueryState<(\n        &'static GaussianCamera,\n        &'static crate::render::GaussianComputeViewBindGroup,\n        &'static ViewUniformOffset,\n        &'static PreviousViewUniformOffset,\n    )>,\n}\n\nimpl<R: PlanarSync> FromWorld for RadixSortNode<R> {\n    fn from_world(world: &mut World) -> Self {\n        Self {\n            gaussian_clouds: world.query(),\n            initialized: false,\n            view_bind_group: world.query(),\n        }\n    }\n}\n\nimpl<R: PlanarSync> Node for RadixSortNode<R>\nwhere\n    R::GpuPlanarType: GpuPlanarStorage,\n{\n    fn update(&mut self, world: &mut World) {\n        let pipeline = world.resource::<RadixSortPipeline<R>>();\n        let pipeline_cache = world.resource::<PipelineCache>();\n\n        if !self.initialized {\n            let mut pipelines_loaded = true;\n            for sort_pipeline in pipeline.radix_sort_pipelines.iter() {\n                if let CachedPipelineState::Ok(_) =\n                    pipeline_cache.get_compute_pipeline_state(*sort_pipeline)\n                {\n                    continue;\n                }\n\n                pipelines_loaded = false;\n            }\n\n            self.initialized = pipelines_loaded;\n\n            if !self.initialized {\n                return;\n            }\n        }\n\n        self.gaussian_clouds.update_archetypes(world);\n        self.view_bind_group.update_archetypes(world);\n    }\n\n    fn run(\n        &self,\n        _graph: &mut RenderGraphContext,\n        render_context: &mut RenderContext,\n        world: &World,\n    ) -> Result<(), NodeRunError> {\n        if !self.initialized {\n            return Ok(());\n        }\n\n        let pipeline_cache = world.resource::<PipelineCache>();\n        let pipeline = world.resource::<RadixSortPipeline<R>>();\n        let gaussian_uniforms = world.resource::<GaussianUniformBindGroups>();\n        let sort_buffers = world.resource::<RadixSortBuffers<R>>();\n\n        for (_camera, view_bind_group, view_uniform_offset, previous_view_uniform_offset) in\n            self.view_bind_group.iter_manual(world)\n        {\n            for (cloud_handle, cloud_bind_group, radix_bind_group) in\n                self.gaussian_clouds.iter_manual(world)\n            {\n                let cloud = world\n                    .get_resource::<RenderAssets<R::GpuPlanarType>>()\n                    .unwrap()\n                    .get(cloud_handle.handle())\n                    .unwrap();\n\n                assert!(\n                    sort_buffers\n                        .asset_map\n                        .contains_key(&cloud_handle.handle().id())\n                );\n                let sorting_assets = &sort_buffers.asset_map[&cloud_handle.handle().id()];\n\n                {\n                    let command_encoder = render_context.command_encoder();\n                    let shader_defines = ShaderDefines::default();\n                    let radix_digit_places = shader_defines.radix_digit_places;\n                    let radix_base = shader_defines.radix_base;\n                    let workgroup_entries_a = shader_defines.workgroup_entries_a;\n                    let workgroup_entries_c = shader_defines.workgroup_entries_c;\n                    let tile_workgroups = (cloud.len() as u32).div_ceil(workgroup_entries_c);\n\n                    {\n                        command_encoder.clear_buffer(\n                            &sorting_assets.sorting_global_buffer,\n                            0,\n                            None,\n                        );\n\n                        command_encoder.clear_buffer(\n                            &sorting_assets.sorting_status_counter_buffer,\n                            0,\n                            None,\n                        );\n\n                        command_encoder.clear_buffer(cloud.draw_indirect_buffer(), 0, None);\n                    }\n\n                    {\n                        let mut pass =\n                            command_encoder.begin_compute_pass(&ComputePassDescriptor::default());\n\n                        // Reset per-frame counters/histograms\n                        let radix_reset = pipeline_cache\n                            .get_compute_pipeline(\n                                pipeline.radix_sort_pipelines[RADIX_PIPELINE_RESET],\n                            )\n                            .unwrap();\n                        pass.set_pipeline(radix_reset);\n                        pass.set_bind_group(\n                            0,\n                            &view_bind_group.value,\n                            &[\n                                view_uniform_offset.offset,\n                                previous_view_uniform_offset.offset,\n                            ],\n                        );\n                        pass.set_bind_group(\n                            1,\n                            gaussian_uniforms.base_bind_group.as_ref().unwrap(),\n                            &[0],\n                        );\n                        pass.set_bind_group(2, &cloud_bind_group.bind_group, &[]);\n                        pass.set_bind_group(3, &radix_bind_group.radix_sort_bind_groups[0], &[]);\n                        pass.dispatch_workgroups(1, 1, 1);\n\n                        let radix_sort_a = pipeline_cache\n                            .get_compute_pipeline(pipeline.radix_sort_pipelines[RADIX_PIPELINE_A])\n                            .unwrap();\n                        pass.set_pipeline(radix_sort_a);\n\n                        pass.dispatch_workgroups(\n                            (cloud.len() as u32).div_ceil(workgroup_entries_a),\n                            1,\n                            1,\n                        );\n\n                        let radix_sort_b = pipeline_cache\n                            .get_compute_pipeline(pipeline.radix_sort_pipelines[RADIX_PIPELINE_B])\n                            .unwrap();\n                        pass.set_pipeline(radix_sort_b);\n\n                        pass.dispatch_workgroups(1, radix_digit_places, 1);\n                    }\n\n                    // TODO: add options to only complete a fraction of the sorting process\n                    for pass_idx in 0..radix_digit_places {\n                        let mut pass =\n                            command_encoder.begin_compute_pass(&ComputePassDescriptor::default());\n\n                        // Set common bind groups for view/uniforms and cloud storage\n                        pass.set_bind_group(\n                            0,\n                            &view_bind_group.value,\n                            &[\n                                view_uniform_offset.offset,\n                                previous_view_uniform_offset.offset,\n                            ],\n                        );\n                        pass.set_bind_group(\n                            1,\n                            gaussian_uniforms.base_bind_group.as_ref().unwrap(),\n                            &[0],\n                        );\n                        pass.set_bind_group(2, &cloud_bind_group.bind_group, &[]);\n\n                        // For pass C, choose bind group based on digit place and parity\n                        // THIS IS THE FIX:\n                        let parity = (pass_idx % 2) as usize;\n                        let bg_index = (pass_idx as usize) * 2 + parity;\n                        pass.set_bind_group(\n                            3,\n                            &radix_bind_group.radix_sort_bind_groups[bg_index],\n                            &[],\n                        );\n\n                        let radix_sort_c_count = pipeline_cache\n                            .get_compute_pipeline(\n                                pipeline.radix_sort_pipelines[RADIX_PIPELINE_C_COUNT],\n                            )\n                            .unwrap();\n                        pass.set_pipeline(radix_sort_c_count);\n                        pass.dispatch_workgroups(1, tile_workgroups, 1);\n\n                        let radix_sort_c_scan = pipeline_cache\n                            .get_compute_pipeline(\n                                pipeline.radix_sort_pipelines[RADIX_PIPELINE_C_SCAN],\n                            )\n                            .unwrap();\n                        pass.set_pipeline(radix_sort_c_scan);\n                        pass.dispatch_workgroups(1, radix_base, 1);\n\n                        let radix_sort_c_scatter = pipeline_cache\n                            .get_compute_pipeline(\n                                pipeline.radix_sort_pipelines[RADIX_PIPELINE_C_SCATTER],\n                            )\n                            .unwrap();\n                        pass.set_pipeline(radix_sort_c_scatter);\n                        pass.dispatch_workgroups(1, tile_workgroups, 1);\n                    }\n                }\n            }\n        }\n\n        Ok(())\n    }\n}\n"
  },
  {
    "path": "src/sort/radix.wgsl",
    "content": "#import bevy_gaussian_splatting::bindings::{\n    view,\n    globals,\n    gaussian_uniforms,\n    sorting_pass_index,\n    sorting,\n    status_counters,\n    draw_indirect,\n    input_entries,\n    output_entries,\n    sorted_entries,\n    DrawIndirect,\n    Entry,\n}\n#import bevy_gaussian_splatting::transform::{\n    world_to_clip,\n    in_frustum,\n}\n\n#ifdef PACKED_F32\n#import bevy_gaussian_splatting::packed::get_position\n#else\n\n#ifdef BUFFER_STORAGE\n#import bevy_gaussian_splatting::planar::get_position\n#endif\n\n#endif\n\n#ifdef BUFFER_TEXTURE\n#import bevy_gaussian_splatting::texture::get_position\n#endif\n\nstruct SortingGlobal {\n    digit_histogram: array<array<atomic<u32>, #{RADIX_BASE}>, #{RADIX_DIGIT_PLACES}>,\n    assignment_counter: atomic<u32>,\n    digit_tile_head: array<atomic<u32>, #{RADIX_BASE}>,\n}\n\n@group(3) @binding(0) var<uniform> sorting_pass_index: u32;\n@group(3) @binding(1) var<storage, read_write> sorting: SortingGlobal;\n// Per-tile temporary storage for radix pass C.\n@group(3) @binding(2) var<storage, read_write> status_counters: array<array<atomic<u32>, #{RADIX_BASE}>>;\n@group(3) @binding(3) var<storage, read_write> draw_indirect: DrawIndirect;\n@group(3) @binding(4) var<storage, read_write> input_entries: array<Entry>;\n@group(3) @binding(5) var<storage, read_write> output_entries: array<Entry>;\n\n//\n// The following three functions (`radix_reset`, `radix_sort_a`, `radix_sort_b`)\n// form a standard three-phase GPU sort setup and were already correct.\n// They are included here without changes.\n//\n\n@compute @workgroup_size(#{RADIX_BASE}, #{RADIX_DIGIT_PLACES})\nfn radix_reset(\n    @builtin(local_invocation_id) local_id: vec3<u32>,\n    @builtin(global_invocation_id) global_id: vec3<u32>,\n){\n    let b = local_id.x;\n    let p = local_id.y;\n    atomicStore(&sorting.digit_histogram[p][b], 0u);\n    if (p == 0u) {\n        atomicStore(&sorting.digit_tile_head[b], 0u);\n    }\n    if (global_id.x == 0u && global_id.y == 0u) {\n        atomicStore(&sorting.assignment_counter, 0u);\n        draw_indirect.instance_count = 0u;\n    }\n}\n\n@compute @workgroup_size(#{RADIX_BASE}, #{RADIX_DIGIT_PLACES})\nfn radix_sort_a(\n    @builtin(local_invocation_id) gl_LocalInvocationID: vec3<u32>,\n    @builtin(global_invocation_id) gl_GlobalInvocationID: vec3<u32>,\n) {\n    if (gl_LocalInvocationID.x == 0u && gl_LocalInvocationID.y == 0u && gl_GlobalInvocationID.x == 0u) {\n        draw_indirect.vertex_count = 4u;\n        atomicStore(&draw_indirect.instance_count, gaussian_uniforms.count);\n    }\n    workgroupBarrier();\n\n    let thread_index = gl_GlobalInvocationID.x * #{RADIX_DIGIT_PLACES}u + gl_GlobalInvocationID.y;\n    let start_entry_index = thread_index * #{ENTRIES_PER_INVOCATION_A}u;\n    let end_entry_index = start_entry_index + #{ENTRIES_PER_INVOCATION_A}u;\n\n    for (var entry_index = start_entry_index; entry_index < end_entry_index; entry_index += 1u) {\n        if (entry_index >= gaussian_uniforms.count) { continue; }\n        var key: u32 = 0xFFFFFFFFu;\n        let position = vec4<f32>(get_position(entry_index), 1.0);\n        let transformed_position = (gaussian_uniforms.transform * position).xyz;\n        let clip_space_pos = world_to_clip(transformed_position);\n        let diff = transformed_position - view.world_position;\n        let dist2 = dot(diff, diff);\n        let dist_bits = bitcast<u32>(dist2);\n        let key_distance = 0xFFFFFFFFu - dist_bits;\n        if (in_frustum(clip_space_pos.xyz)) {\n            key = key_distance;\n        }\n        input_entries[entry_index].key = key;\n        input_entries[entry_index].value = entry_index;\n        for(var shift = 0u; shift < #{RADIX_DIGIT_PLACES}u; shift += 1u) {\n            let digit = (key >> (shift * #{RADIX_BITS_PER_DIGIT}u)) & (#{RADIX_BASE}u - 1u);\n            atomicAdd(&sorting.digit_histogram[shift][digit], 1u);\n        }\n    }\n}\n\n@compute @workgroup_size(1)\nfn radix_sort_b(\n    @builtin(global_invocation_id) gl_GlobalInvocationID: vec3<u32>,\n) {\n    var sum = 0u;\n    for(var digit = 0u; digit < #{RADIX_BASE}u; digit += 1u) {\n        let tmp = atomicLoad(&sorting.digit_histogram[gl_GlobalInvocationID.y][digit]);\n        atomicStore(&sorting.digit_histogram[gl_GlobalInvocationID.y][digit], sum);\n        sum += tmp;\n    }\n}\n\n// --- SHARED MEMORY for radix pass C ---\nvar<workgroup> tile_input_entries: array<Entry, #{WORKGROUP_ENTRIES_C}>;\nvar<workgroup> sorted_tile_entries: array<Entry, #{WORKGROUP_ENTRIES_C}>;\nvar<workgroup> tile_digit_counts: array<atomic<u32>, #{RADIX_BASE}>;\nvar<workgroup> local_digit_counts: array<u32, #{RADIX_BASE}>;\nvar<workgroup> local_digit_offsets: array<u32, #{RADIX_BASE}>;\nvar<workgroup> tile_entry_count_ws: u32;\nconst INVALID_KEY: u32 = 0xFFFFFFFFu;\n\n@compute @workgroup_size(#{WORKGROUP_INVOCATIONS_C})\nfn radix_sort_c_count_tiles(\n    @builtin(local_invocation_id) local_id: vec3<u32>,\n    @builtin(workgroup_id) workgroup_id: vec3<u32>,\n) {\n    let tid = local_id.x;\n    let tile_size = #{WORKGROUP_ENTRIES_C}u;\n    let threads = #{WORKGROUP_INVOCATIONS_C}u;\n    let global_entry_offset = workgroup_id.y * tile_size;\n\n    if (tid < #{RADIX_BASE}u) {\n        atomicStore(&tile_digit_counts[tid], 0u);\n    }\n    workgroupBarrier();\n\n    for (var i = tid; i < tile_size; i += threads) {\n        let idx = global_entry_offset + i;\n        if (idx >= gaussian_uniforms.count) {\n            continue;\n        }\n\n        let entry = input_entries[idx];\n        let digit = (entry.key >> (sorting_pass_index * #{RADIX_BITS_PER_DIGIT}u)) & (#{RADIX_BASE}u - 1u);\n        atomicAdd(&tile_digit_counts[digit], 1u);\n    }\n    workgroupBarrier();\n\n    if (tid < #{RADIX_BASE}u) {\n        let count = atomicLoad(&tile_digit_counts[tid]);\n        atomicStore(&status_counters[workgroup_id.y][tid], count);\n    }\n}\n\n@compute @workgroup_size(1)\nfn radix_sort_c_scan_tiles(\n    @builtin(global_invocation_id) global_id: vec3<u32>,\n) {\n    let digit = global_id.y;\n    if (digit >= #{RADIX_BASE}u) {\n        return;\n    }\n\n    let tile_size = #{WORKGROUP_ENTRIES_C}u;\n    let tile_count = (gaussian_uniforms.count + tile_size - 1u) / tile_size;\n\n    var sum = atomicLoad(&sorting.digit_histogram[sorting_pass_index][digit]);\n    for (var tile = 0u; tile < tile_count; tile += 1u) {\n        let count = atomicLoad(&status_counters[tile][digit]);\n        atomicStore(&status_counters[tile][digit], sum);\n        sum += count;\n    }\n}\n\n@compute @workgroup_size(#{WORKGROUP_INVOCATIONS_C})\nfn radix_sort_c_scatter(\n    @builtin(local_invocation_id) local_id: vec3<u32>,\n    @builtin(workgroup_id) workgroup_id: vec3<u32>,\n) {\n    let tid = local_id.x;\n    let tile_size = #{WORKGROUP_ENTRIES_C}u;\n    let threads = #{WORKGROUP_INVOCATIONS_C}u;\n    let global_entry_offset = workgroup_id.y * tile_size;\n\n    // Step 1: Parallel load.\n    for (var i = tid; i < tile_size; i += threads) {\n        let idx = global_entry_offset + i;\n        if (idx < gaussian_uniforms.count) {\n            tile_input_entries[i] = input_entries[idx];\n        } else {\n            tile_input_entries[i] = Entry(INVALID_KEY, INVALID_KEY);\n        }\n    }\n    workgroupBarrier();\n\n    // Step 2: Serial, stable sort within the tile.\n    if (tid == 0u) {\n        for (var i = 0u; i < #{RADIX_BASE}u; i+=1u) { local_digit_counts[i] = 0u; }\n\n        var entries_in_tile = 0u;\n        for (var i = 0u; i < tile_size; i+=1u) {\n            let entry = tile_input_entries[i];\n            if (entry.value == INVALID_KEY) { continue; } // value sentinel marks padding\n\n            let digit = (entry.key >> (sorting_pass_index * #{RADIX_BITS_PER_DIGIT}u)) & (#{RADIX_BASE}u - 1u);\n            local_digit_counts[digit] += 1u;\n            entries_in_tile += 1u;\n        }\n        tile_entry_count_ws = entries_in_tile;\n\n        var sum = 0u;\n        for (var i = 0u; i < #{RADIX_BASE}u; i+=1u) {\n            local_digit_offsets[i] = sum;\n            sum += local_digit_counts[i];\n        }\n\n        for (var i = 0u; i < tile_size; i+=1u) {\n            let entry = tile_input_entries[i];\n            if (entry.value == INVALID_KEY) { continue; } // value sentinel marks padding\n\n            let digit = (entry.key >> (sorting_pass_index * #{RADIX_BITS_PER_DIGIT}u)) & (#{RADIX_BASE}u - 1u);\n            let dest_idx = local_digit_offsets[digit];\n            local_digit_offsets[digit] = dest_idx + 1u;\n            sorted_tile_entries[dest_idx] = entry;\n        }\n    }\n    workgroupBarrier();\n\n    // Step 3: Parallel write from the locally-sorted tile to global memory.\n    if (tid == 0u) {\n        var sum = 0u;\n        for (var i = 0u; i < #{RADIX_BASE}u; i += 1u) {\n            local_digit_offsets[i] = sum;\n            sum += local_digit_counts[i];\n        }\n    }\n    workgroupBarrier();\n\n    for (var i = tid; i < tile_size; i += threads) {\n        if (i < tile_entry_count_ws) {\n            let entry = sorted_tile_entries[i];\n            let digit = (entry.key >> (sorting_pass_index * #{RADIX_BITS_PER_DIGIT}u)) & (#{RADIX_BASE}u - 1u);\n\n            let bin_start_offset = local_digit_offsets[digit];\n            let rank_in_bin = i - bin_start_offset;\n            let global_base = atomicLoad(&status_counters[workgroup_id.y][digit]);\n            let dst = global_base + rank_in_bin;\n\n            if (dst < gaussian_uniforms.count) {\n                output_entries[dst] = entry;\n            }\n        }\n    }\n    if (sorting_pass_index == #{RADIX_DIGIT_PLACES}u - 1u && tid == 0u) {\n        atomicStore(&draw_indirect.instance_count, gaussian_uniforms.count);\n    }\n}\n"
  },
  {
    "path": "src/sort/rayon.rs",
    "content": "use bevy::{math::Vec3A, platform::time::Instant, prelude::*};\nuse bevy_interleave::prelude::*;\nuse rayon::prelude::*;\n\nuse crate::{\n    CloudSettings,\n    camera::GaussianCamera,\n    gaussian::interface::CommonCloud,\n    sort::{SortConfig, SortMode, SortTrigger, SortedEntries, SortedEntriesHandle},\n};\n\n#[derive(Default)]\npub struct RayonSortPlugin<R: PlanarSync> {\n    _phantom: std::marker::PhantomData<R>,\n}\n\nimpl<R: PlanarSync> Plugin for RayonSortPlugin<R>\nwhere\n    R::PlanarType: CommonCloud,\n{\n    fn build(&self, app: &mut App) {\n        app.add_systems(Update, rayon_sort::<R>);\n    }\n}\n\n#[allow(clippy::too_many_arguments)]\npub fn rayon_sort<R: PlanarSync>(\n    asset_server: Res<AssetServer>,\n    gaussian_clouds_res: Res<Assets<R::PlanarType>>,\n    gaussian_clouds: Query<(\n        &R::PlanarTypeHandle,\n        &SortedEntriesHandle,\n        &CloudSettings,\n        &GlobalTransform,\n    )>,\n    mut sorted_entries_res: ResMut<Assets<SortedEntries>>,\n    mut cameras: Query<&mut SortTrigger, With<GaussianCamera>>,\n    mut sort_config: ResMut<SortConfig>,\n) where\n    R::PlanarType: CommonCloud,\n{\n    // TODO: move sort to render world, use extracted views and update the existing buffer instead of creating new\n\n    let sort_start_time = Instant::now();\n    let mut performed_sort = false;\n\n    for mut trigger in cameras.iter_mut() {\n        if !trigger.needs_sort {\n            continue;\n        }\n\n        let mut saw_sortable_cloud = false;\n        let mut pending_assets = false;\n        let mut sorted_any = false;\n\n        for (gaussian_cloud_handle, sorted_entries_handle, settings, transform) in\n            gaussian_clouds.iter()\n        {\n            if settings.sort_mode != SortMode::Rayon {\n                continue;\n            }\n\n            saw_sortable_cloud = true;\n\n            if let Some(load_state) = asset_server.get_load_state(gaussian_cloud_handle.handle())\n                && load_state.is_loading()\n            {\n                pending_assets = true;\n                continue;\n            }\n\n            if let Some(load_state) = asset_server.get_load_state(&sorted_entries_handle.0)\n                && load_state.is_loading()\n            {\n                pending_assets = true;\n                continue;\n            }\n\n            if let Some(gaussian_cloud) = gaussian_clouds_res.get(gaussian_cloud_handle.handle())\n                && let Some(sorted_entries) = sorted_entries_res.get_mut(sorted_entries_handle)\n            {\n                let gaussians = gaussian_cloud.len();\n                let mut chunks = sorted_entries.sorted.chunks_mut(gaussians);\n                let chunk = chunks.nth(trigger.camera_index).unwrap();\n\n                gaussian_cloud\n                    .position_par_iter()\n                    .zip(chunk.par_iter_mut())\n                    .enumerate()\n                    .for_each(|(idx, (position, sort_entry))| {\n                        let position = Vec3A::from_slice(position.as_ref());\n                        let position = transform.affine().transform_point3a(position);\n\n                        let delta = trigger.last_camera_position - position;\n\n                        sort_entry.key = bytemuck::cast(delta.length_squared());\n                        sort_entry.index = idx as u32;\n                    });\n\n                chunk.par_sort_unstable_by(|a, b| {\n                    bytemuck::cast::<u32, f32>(b.key)\n                        .partial_cmp(&bytemuck::cast::<u32, f32>(a.key))\n                        .unwrap_or(std::cmp::Ordering::Equal)\n                });\n\n                // TODO: update DrawIndirect buffer during sort phase (GPU sort will override default DrawIndirect)\n                sorted_any = true;\n            } else {\n                pending_assets = true;\n            }\n        }\n\n        if sorted_any {\n            performed_sort = true;\n        }\n        if saw_sortable_cloud && sorted_any && !pending_assets {\n            trigger.needs_sort = false;\n        }\n    }\n\n    let sort_end_time = Instant::now();\n    let delta = sort_end_time - sort_start_time;\n\n    if performed_sort {\n        sort_config.period_ms = sort_config\n            .period_ms\n            .max(sort_config.period_ms * 4 / 5)\n            .max(4 * delta.as_millis() as usize);\n    }\n}\n"
  },
  {
    "path": "src/sort/std_sort.rs",
    "content": "use bevy::{math::Vec3A, platform::time::Instant, prelude::*};\nuse bevy_interleave::prelude::*;\n\nuse crate::{\n    CloudSettings,\n    camera::GaussianCamera,\n    gaussian::interface::CommonCloud,\n    sort::{SortConfig, SortMode, SortTrigger, SortedEntries, SortedEntriesHandle},\n};\n\n#[derive(Default)]\npub struct StdSortPlugin<R: PlanarSync> {\n    _phantom: std::marker::PhantomData<R>,\n}\n\nimpl<R: PlanarSync> Plugin for StdSortPlugin<R>\nwhere\n    R::PlanarType: CommonCloud,\n{\n    fn build(&self, app: &mut App) {\n        app.add_systems(Update, std_sort::<R>);\n    }\n}\n\n// TODO: async CPU sort to prevent frame drops on large clouds\n#[allow(clippy::too_many_arguments)]\npub fn std_sort<R: PlanarSync>(\n    asset_server: Res<AssetServer>,\n    gaussian_clouds_res: Res<Assets<R::PlanarType>>,\n    gaussian_clouds: Query<(\n        &R::PlanarTypeHandle,\n        &SortedEntriesHandle,\n        &CloudSettings,\n        &GlobalTransform,\n    )>,\n    mut sorted_entries_res: ResMut<Assets<SortedEntries>>,\n    mut cameras: Query<&mut SortTrigger, With<GaussianCamera>>,\n    mut sort_config: ResMut<SortConfig>,\n) where\n    R::PlanarType: CommonCloud,\n{\n    // TODO: move sort to render world, use extracted views and update the existing buffer instead of creating new\n\n    let sort_start_time = Instant::now();\n    let mut performed_sort = false;\n\n    for mut trigger in cameras.iter_mut() {\n        if !trigger.needs_sort {\n            continue;\n        }\n\n        let mut saw_sortable_cloud = false;\n        let mut pending_assets = false;\n        let mut sorted_any = false;\n\n        for (gaussian_cloud_handle, sorted_entries_handle, settings, transform) in\n            gaussian_clouds.iter()\n        {\n            if settings.sort_mode != SortMode::Std {\n                continue;\n            }\n\n            saw_sortable_cloud = true;\n\n            if let Some(load_state) = asset_server.get_load_state(gaussian_cloud_handle.handle())\n                && load_state.is_loading()\n            {\n                pending_assets = true;\n                continue;\n            }\n\n            if let Some(load_state) = asset_server.get_load_state(&sorted_entries_handle.0)\n                && load_state.is_loading()\n            {\n                pending_assets = true;\n                continue;\n            }\n\n            if let Some(gaussian_cloud) = gaussian_clouds_res.get(gaussian_cloud_handle.handle())\n                && let Some(sorted_entries) = sorted_entries_res.get_mut(sorted_entries_handle)\n            {\n                let gaussians = gaussian_cloud.len();\n                let mut chunks = sorted_entries.sorted.chunks_mut(gaussians);\n                let chunk = chunks.nth(trigger.camera_index).unwrap();\n\n                gaussian_cloud\n                    .position_iter()\n                    .zip(chunk.iter_mut())\n                    .enumerate()\n                    .for_each(|(idx, (position, sort_entry))| {\n                        let position = Vec3A::from_slice(position.as_ref());\n                        let position = transform.affine().transform_point3a(position);\n\n                        let delta = trigger.last_camera_position - position;\n\n                        sort_entry.key = bytemuck::cast(delta.length_squared());\n                        sort_entry.index = idx as u32;\n                    });\n\n                chunk.sort_unstable_by(|a, b| {\n                    bytemuck::cast::<u32, f32>(b.key)\n                        .partial_cmp(&bytemuck::cast::<u32, f32>(a.key))\n                        .unwrap_or(std::cmp::Ordering::Equal)\n                });\n\n                // TODO: update DrawIndirect buffer during sort phase (GPU sort will override default DrawIndirect)\n                sorted_any = true;\n            } else {\n                pending_assets = true;\n            }\n        }\n\n        if sorted_any {\n            performed_sort = true;\n        }\n        if saw_sortable_cloud && sorted_any && !pending_assets {\n            trigger.needs_sort = false;\n        }\n    }\n\n    let sort_end_time = Instant::now();\n    let delta = sort_end_time - sort_start_time;\n\n    if performed_sort {\n        sort_config.period_ms = sort_config\n            .period_ms\n            .max(sort_config.period_ms * 4 / 5)\n            .max(4 * delta.as_millis() as usize);\n    }\n}\n"
  },
  {
    "path": "src/sort/temporal.wgsl",
    "content": "\n@compute @workgroup_size(#{TEMPORAL_SORT_WINDOW_SIZE})\nfn temporal_sort_flip(\n    @builtin(local_invocation_id) gl_LocalInvocationID: vec3<u32>,\n    @builtin(global_invocation_id) gl_GlobalInvocationID: vec3<u32>,\n) {\n    // let start_index = gl_GlobalInvocationID.x * #{TEMPORAL_SORT_WINDOW_SIZE}u;\n    // let end_index = start_index + #{TEMPORAL_SORT_WINDOW_SIZE}u;\n}\n\n@compute @workgroup_size(#{TEMPORAL_SORT_WINDOW_SIZE})\nfn temporal_sort_flop(\n    @builtin(local_invocation_id) gl_LocalInvocationID: vec3<u32>,\n    @builtin(global_invocation_id) gl_GlobalInvocationID: vec3<u32>,\n) {\n    // // TODO: pad sorting buffers to 1.5 window size\n    // let start_index = gl_GlobalInvocationID.x * #{TEMPORAL_SORT_WINDOW_SIZE}u + #{TEMPORAL_SORT_WINDOW_SIZE}u / 2u;\n    // let end_index = start_index + #{TEMPORAL_SORT_WINDOW_SIZE}u;\n\n    // // pair sort entries in window size\n    // for (var i = start_index; i < end_index; i += 2u) {\n    //     let pos_a = points[input_entries[i][0]].position_visibility.xyz;\n    //     let depth_a = world_to_clip(pos_a).z;\n    // }\n}\n"
  },
  {
    "path": "src/stream/hierarchy.rs",
    "content": "\n"
  },
  {
    "path": "src/stream/mod.rs",
    "content": "pub mod hierarchy;\npub mod slice;\n"
  },
  {
    "path": "src/stream/slice.rs",
    "content": "// use bevy::prelude::*;\n// use bevy::render::{\n//     render_graph::{Node, NodeRunError, RenderGraphContext, RenderLabel},\n//     renderer::RenderContext,\n//     RenderApp,\n// };\n// use bevy::asset::LoadState;\n// use bevy::core_pipeline::core_3d::graph::Core3d;\n\n// use crate::{\n//     gaussian::cloud::PlanarGaussian3dHandle,\n//     render::GpuCloud,\n//     CloudSettings,\n// };\n\n// #[derive(Component, Default,)]\n// pub struct GaussianSliceData {\n//     pub data: Vec<f32>,\n//     pub changed_start: usize,\n//     pub changed_count: usize,\n// }\n\n// impl GaussianSliceData {\n//     pub fn mark_changed(&mut self, start: usize, count: usize) {\n//         self.changed_start = start;\n//         self.changed_count = count;\n//     }\n\n//     pub fn clear_changed(&mut self) {\n//         self.changed_start = 0;\n//         self.changed_count = 0;\n//     }\n\n//     pub fn has_changed(&self) -> bool {\n//         self.changed_count > 0\n//     }\n\n//     pub fn changed_slice(&self) -> &[u8] {\n//         let end = self.changed_start + self.changed_count;\n//         bytemuck::cast_slice(&self.data[self.changed_start..end])\n//     }\n// }\n\n// #[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)]\n// pub struct GaussianSliceLabel;\n\n// pub struct GaussianSlicePlugin;\n\n// impl Plugin for GaussianSlicePlugin {\n//     fn build(&self, app: &mut App) {\n//         if let Some(render_app) = app.get_sub_app_mut(RenderApp) {\n//             render_app\n//                 .add_render_graph_node::<GaussianSliceNode>(\n//                     Core3d,\n//                     GaussianSliceLabel,\n//                 )\n//                 .add_render_graph_edge(\n//                     Core3d,\n//                     GaussianSliceLabel,\n//                     crate::sort::GaussianSliceLabel,\n//                 );\n//         }\n//     }\n// }\n\n// pub struct GaussianSliceNode {\n//     query_gaussian: QueryState<(&'static PlanarGaussian3dHandle, &'static CloudSettings)>,\n//     initialized: bool,\n// }\n\n// impl FromWorld for GaussianSliceNode {\n//     fn from_world(world: &mut World) -> Self {\n//         GaussianSliceNode {\n//             query_gaussian: world.query(),\n//             initialized: true,\n//         }\n//     }\n// }\n\n// impl Node for GaussianSliceNode {\n//     fn update(&mut self, world: &mut World) {\n//         self.query_gaussian.update_archetypes(world);\n//     }\n\n//     fn run(\n//         &self,\n//         _graph: &mut RenderGraphContext,\n//         render_context: &mut RenderContext,\n//         world: &World,\n//     ) -> Result<(), NodeRunError> {\n//         if !self.initialized {\n//             return Ok(());\n//         }\n\n//         let slice_data = world.get_resource::<GaussianSliceData>();\n//         if slice_data.is_none() || !slice_data.unwrap().has_changed() {\n//             return Ok(());\n//         }\n//         let slice_data = slice_data.unwrap();\n\n//         let gaussian_clouds = world.get_resource::<RenderAssets<GpuCloud>>().ok_or(NodeRunError::MissingResource)?;\n//         let asset_server = world.get_resource::<AssetServer>().ok_or(NodeRunError::MissingResource)?;\n\n//         for (cloud_handle, settings) in self.query_gaussian.iter_manual(world) {\n//             if Some(LoadState::Loaded) != asset_server.get_load_state(cloud_handle) {\n//                 continue;\n//             }\n\n//             let gpu_cloud = if let Some(g) = gaussian_clouds.get(cloud_handle) {\n//                 g\n//             } else {\n//                 continue;\n//             };\n\n//             let queue = &render_context.queue;\n//             let offset_bytes = slice_data.changed_start * std::mem::size_of::<f32>();\n//             queue.write_buffer(&gpu_cloud.gaussian_buffer, offset_bytes as u64, slice_data.changed_slice());\n//         }\n\n//         Ok(())\n//     }\n// }\n"
  },
  {
    "path": "src/utils.rs",
    "content": "use bevy::prelude::*;\nuse bevy_args::{Deserialize, Parser, Serialize};\n\nuse crate::gaussian::settings::{GaussianMode, PlaybackMode, RasterizeMode};\n\n#[derive(Debug, Resource, Serialize, Deserialize, Parser)]\n#[command(about = \"bevy_gaussian_splatting viewer\", version, long_about = None)]\npub struct GaussianSplattingViewer {\n    #[arg(long, default_value = \"true\")]\n    pub editor: bool,\n\n    #[arg(long, default_value = \"true\")]\n    pub press_esc_close: bool,\n\n    #[arg(long, default_value = \"true\")]\n    pub press_s_screenshot: bool,\n\n    #[arg(long, default_value = \"false\")]\n    pub show_axes: bool,\n\n    #[arg(long, default_value = \"true\")]\n    pub show_fps: bool,\n\n    #[arg(long, default_value = \"1920.0\")]\n    pub width: f32,\n\n    #[arg(long, default_value = \"1080.0\")]\n    pub height: f32,\n\n    #[arg(long, default_value = \"bevy_gaussian_splatting\")]\n    pub name: String,\n\n    #[arg(long, default_value = \"1\")]\n    pub msaa_samples: u8,\n\n    #[arg(long, default_value = None, help = \"input file path (or url/base64_url if web_asset feature is enabled)\")]\n    pub input_cloud: Option<String>,\n\n    #[arg(\n        long,\n        default_value = None,\n        help = \"secondary input file used when morph_interpolate is enabled\",\n    )]\n    pub input_cloud_target: Option<String>,\n\n    #[arg(long, default_value = None, help = \"input glTF/GLB scene path (or url/base64_url if web_asset feature is enabled)\")]\n    pub input_scene: Option<String>,\n\n    #[arg(long, default_value = None, help = \"cloud translation as x,y,z\")]\n    pub cloud_translation: Option<String>,\n\n    #[arg(long, default_value = None, help = \"cloud rotation in degrees as x,y,z\")]\n    pub cloud_rotation: Option<String>,\n\n    #[arg(long, default_value = None, help = \"cloud scale as uniform or x,y,z\")]\n    pub cloud_scale: Option<String>,\n\n    #[arg(long, default_value = \"0\")]\n    pub gaussian_count: usize,\n\n    #[arg(long, default_value = None, help = \"seed for random gaussian generation\")]\n    pub gaussian_seed: Option<u64>,\n\n    #[arg(long, value_enum, default_value_t = GaussianMode::Gaussian3d)]\n    pub gaussian_mode: GaussianMode,\n\n    #[arg(long, value_enum, default_value_t = PlaybackMode::Still)]\n    pub playback_mode: PlaybackMode,\n\n    #[arg(long, value_enum, default_value_t = RasterizeMode::Color)]\n    pub rasterization_mode: RasterizeMode,\n\n    #[arg(long, default_value = \"0\")]\n    pub particle_count: usize,\n}\n\nimpl Default for GaussianSplattingViewer {\n    fn default() -> GaussianSplattingViewer {\n        GaussianSplattingViewer {\n            editor: true,\n            press_esc_close: true,\n            press_s_screenshot: true,\n            show_axes: false,\n            show_fps: true,\n            width: 1920.0,\n            height: 1080.0,\n            name: \"bevy_gaussian_splatting\".to_string(),\n            msaa_samples: 1,\n            input_cloud: None,\n            input_cloud_target: None,\n            input_scene: None,\n            cloud_translation: None,\n            cloud_rotation: None,\n            cloud_scale: None,\n            gaussian_count: 0,\n            gaussian_seed: None,\n            gaussian_mode: GaussianMode::Gaussian3d,\n            playback_mode: PlaybackMode::Still,\n            rasterization_mode: RasterizeMode::Color,\n            particle_count: 0,\n        }\n    }\n}\n\nimpl GaussianSplattingViewer {\n    pub fn cloud_transform(&self) -> Transform {\n        let mut transform = Transform::default();\n\n        if let Some(translation) = self.cloud_translation.as_deref().and_then(parse_vec3) {\n            transform.translation = translation;\n        }\n\n        if let Some(rotation) = self.cloud_rotation.as_deref().and_then(parse_vec3) {\n            transform.rotation = Quat::from_euler(\n                EulerRot::XYZ,\n                rotation.x.to_radians(),\n                rotation.y.to_radians(),\n                rotation.z.to_radians(),\n            );\n        }\n\n        if let Some(scale) = self.cloud_scale.as_deref().and_then(parse_scale) {\n            transform.scale = scale;\n        }\n\n        transform\n    }\n}\n\nfn parse_vec3(value: &str) -> Option<Vec3> {\n    let parts: Vec<&str> = value\n        .split(&[',', ' ', '\\t'][..])\n        .filter(|part| !part.is_empty())\n        .collect();\n    if parts.len() != 3 {\n        return None;\n    }\n\n    let x = parts[0].parse::<f32>().ok()?;\n    let y = parts[1].parse::<f32>().ok()?;\n    let z = parts[2].parse::<f32>().ok()?;\n\n    Some(Vec3::new(x, y, z))\n}\n\nfn parse_scale(value: &str) -> Option<Vec3> {\n    let parts: Vec<&str> = value\n        .split(&[',', ' ', '\\t'][..])\n        .filter(|part| !part.is_empty())\n        .collect();\n    if parts.is_empty() {\n        return None;\n    }\n\n    if parts.len() == 1 {\n        let v = parts[0].parse::<f32>().ok()?;\n        return Some(Vec3::splat(v));\n    }\n\n    if parts.len() != 3 {\n        return None;\n    }\n\n    let x = parts[0].parse::<f32>().ok()?;\n    let y = parts[1].parse::<f32>().ok()?;\n    let z = parts[2].parse::<f32>().ok()?;\n\n    Some(Vec3::new(x, y, z))\n}\n\npub fn setup_hooks() {\n    #[cfg(debug_assertions)]\n    #[cfg(target_arch = \"wasm32\")]\n    {\n        console_error_panic_hook::set_once();\n    }\n}\n\npub fn log(_msg: &str) {\n    #[cfg(debug_assertions)]\n    #[cfg(target_arch = \"wasm32\")]\n    {\n        web_sys::console::log_1(&_msg.into());\n    }\n    #[cfg(debug_assertions)]\n    #[cfg(not(target_arch = \"wasm32\"))]\n    {\n        println!(\"{_msg}\");\n    }\n}\n"
  },
  {
    "path": "tests/fixtures/khr_gaussian_splatting/khr_conformance_matrix.gltf",
    "content": "{\"asset\":{\"version\":\"2.0\"},\"extensionsUsed\":[\"KHR_gaussian_splatting\"],\"scene\":0,\"scenes\":[{\"nodes\":[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]}],\"nodes\":[{\"mesh\":0,\"name\":\"rotation_f32\"},{\"mesh\":1,\"name\":\"rotation_i8_norm\"},{\"mesh\":2,\"name\":\"rotation_i16_norm\"},{\"mesh\":3,\"name\":\"scale_f32\"},{\"mesh\":4,\"name\":\"scale_i8\"},{\"mesh\":5,\"name\":\"scale_i8_norm\"},{\"mesh\":6,\"name\":\"scale_i16\"},{\"mesh\":7,\"name\":\"scale_i16_norm\"},{\"mesh\":8,\"name\":\"opacity_f32\"},{\"mesh\":9,\"name\":\"opacity_u8_norm\"},{\"mesh\":10,\"name\":\"opacity_u16_norm\"},{\"mesh\":11,\"name\":\"sh_degree0\"},{\"mesh\":12,\"name\":\"sh_degree1\"},{\"mesh\":13,\"name\":\"sh_degree2\"},{\"mesh\":14,\"name\":\"sh_degree3\"},{\"camera\":0,\"name\":\"fixture_camera\",\"matrix\":[1.0,0.0,0.0,0.0,0.0,1.0,0.0,0.0,0.0,0.0,1.0,0.0,4.0,5.0,6.0,1.0]}],\"meshes\":[{\"primitives\":[{\"attributes\":{\"POSITION\":0,\"KHR_gaussian_splatting:ROTATION\":1,\"KHR_gaussian_splatting:SCALE\":2,\"KHR_gaussian_splatting:OPACITY\":3,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":4},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":5,\"KHR_gaussian_splatting:ROTATION\":6,\"KHR_gaussian_splatting:SCALE\":7,\"KHR_gaussian_splatting:OPACITY\":8,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":9},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":10,\"KHR_gaussian_splatting:ROTATION\":11,\"KHR_gaussian_splatting:SCALE\":12,\"KHR_gaussian_splatting:OPACITY\":13,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":14},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":15,\"KHR_gaussian_splatting:ROTATION\":16,\"KHR_gaussian_splatting:SCALE\":17,\"KHR_gaussian_splatting:OPACITY\":18,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":19},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"srgb_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":20,\"KHR_gaussian_splatting:ROTATION\":21,\"KHR_gaussian_splatting:SCALE\":22,\"KHR_gaussian_splatting:OPACITY\":23,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":24},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":25,\"KHR_gaussian_splatting:ROTATION\":26,\"KHR_gaussian_splatting:SCALE\":27,\"KHR_gaussian_splatting:OPACITY\":28,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":29},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":30,\"KHR_gaussian_splatting:ROTATION\":31,\"KHR_gaussian_splatting:SCALE\":32,\"KHR_gaussian_splatting:OPACITY\":33,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":34},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":35,\"KHR_gaussian_splatting:ROTATION\":36,\"KHR_gaussian_splatting:SCALE\":37,\"KHR_gaussian_splatting:OPACITY\":38,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":39},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":40,\"KHR_gaussian_splatting:ROTATION\":41,\"KHR_gaussian_splatting:SCALE\":42,\"KHR_gaussian_splatting:OPACITY\":43,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":44},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":45,\"KHR_gaussian_splatting:ROTATION\":46,\"KHR_gaussian_splatting:SCALE\":47,\"KHR_gaussian_splatting:OPACITY\":48,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":49},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":50,\"KHR_gaussian_splatting:ROTATION\":51,\"KHR_gaussian_splatting:SCALE\":52,\"KHR_gaussian_splatting:OPACITY\":53,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":54},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":55,\"KHR_gaussian_splatting:ROTATION\":56,\"KHR_gaussian_splatting:SCALE\":57,\"KHR_gaussian_splatting:OPACITY\":58,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":59},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":60,\"KHR_gaussian_splatting:ROTATION\":61,\"KHR_gaussian_splatting:SCALE\":62,\"KHR_gaussian_splatting:OPACITY\":63,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":64,\"KHR_gaussian_splatting:SH_DEGREE_1_COEF_0\":65,\"KHR_gaussian_splatting:SH_DEGREE_1_COEF_1\":66,\"KHR_gaussian_splatting:SH_DEGREE_1_COEF_2\":67},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":68,\"KHR_gaussian_splatting:ROTATION\":69,\"KHR_gaussian_splatting:SCALE\":70,\"KHR_gaussian_splatting:OPACITY\":71,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":72,\"KHR_gaussian_splatting:SH_DEGREE_1_COEF_0\":73,\"KHR_gaussian_splatting:SH_DEGREE_1_COEF_1\":74,\"KHR_gaussian_splatting:SH_DEGREE_1_COEF_2\":75,\"KHR_gaussian_splatting:SH_DEGREE_2_COEF_0\":76,\"KHR_gaussian_splatting:SH_DEGREE_2_COEF_1\":77,\"KHR_gaussian_splatting:SH_DEGREE_2_COEF_2\":78,\"KHR_gaussian_splatting:SH_DEGREE_2_COEF_3\":79,\"KHR_gaussian_splatting:SH_DEGREE_2_COEF_4\":80},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]},{\"primitives\":[{\"attributes\":{\"POSITION\":81,\"KHR_gaussian_splatting:ROTATION\":82,\"KHR_gaussian_splatting:SCALE\":83,\"KHR_gaussian_splatting:OPACITY\":84,\"KHR_gaussian_splatting:SH_DEGREE_0_COEF_0\":85,\"KHR_gaussian_splatting:SH_DEGREE_1_COEF_0\":86,\"KHR_gaussian_splatting:SH_DEGREE_1_COEF_1\":87,\"KHR_gaussian_splatting:SH_DEGREE_1_COEF_2\":88,\"KHR_gaussian_splatting:SH_DEGREE_2_COEF_0\":89,\"KHR_gaussian_splatting:SH_DEGREE_2_COEF_1\":90,\"KHR_gaussian_splatting:SH_DEGREE_2_COEF_2\":91,\"KHR_gaussian_splatting:SH_DEGREE_2_COEF_3\":92,\"KHR_gaussian_splatting:SH_DEGREE_2_COEF_4\":93,\"KHR_gaussian_splatting:SH_DEGREE_3_COEF_0\":94,\"KHR_gaussian_splatting:SH_DEGREE_3_COEF_1\":95,\"KHR_gaussian_splatting:SH_DEGREE_3_COEF_2\":96,\"KHR_gaussian_splatting:SH_DEGREE_3_COEF_3\":97,\"KHR_gaussian_splatting:SH_DEGREE_3_COEF_4\":98,\"KHR_gaussian_splatting:SH_DEGREE_3_COEF_5\":99,\"KHR_gaussian_splatting:SH_DEGREE_3_COEF_6\":100},\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"kernel\":\"ellipse\",\"colorSpace\":\"lin_rec709_display\",\"projection\":\"perspective\",\"sortingMethod\":\"cameraDistance\"}}}]}],\"cameras\":[{\"type\":\"perspective\",\"perspective\":{\"yfov\":0.78539816339,\"znear\":0.01,\"zfar\":1000.0}}],\"buffers\":[{\"byteLength\":1108,\"uri\":\"data:application/octet-stream;base64,AACAPwAAAEAAAEBAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAvwAAgD7NzMw9zcxMPpqZmT4AAIA/AAAAQAAAQEB/AAAAAAAAAAAAAD8AAAC/AACAPs3MzD3NzEw+mpmZPgAAgD8AAABAAABAQP9/AAAAAAAAAAAAAAAAAD8AAAC/AACAPs3MzD3NzEw+mpmZPgAAgD8AAABAAABAQAAAgD8AAAAAAAAAAAAAAADNzEw+zczMvTMzMz8AAIA+zczMPc3MTD6amZk+AACAPwAAAEAAAEBAAACAPwAAAAAAAAAAAAAAAAH+AwAAAIA+zczMPc3MTD6amZk+AACAPwAAAEAAAEBAAACAPwAAAAAAAAAAAAAAAH8AgQAAAIA+zczMPc3MTD6amZk+AACAPwAAAEAAAEBAAACAPwAAAAAAAAAAAAAAAAIA/f8EAAAAAACAPs3MzD3NzEw+mpmZPgAAgD8AAABAAABAQAAAgD8AAAAAAAAAAAAAAAD/fwAAAYAAAAAAgD7NzMw9zcxMPpqZmT4AAIA/AAAAQAAAQEAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAC/AABAP83MzD3NzEw+mpmZPgAAgD8AAABAAABAQAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAL9AAAAAzczMPc3MTD6amZk+AACAPwAAAEAAAEBAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAvwBAAADNzMw9zcxMPpqZmT4AAIA/AAAAQAAAQEAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAC/AACAPs3MzD3NzEw+mpmZPgAAgD8AAABAAABAQAAAgD8AAAAAAAAAAAAAAAAAAAAAAAAAPwAAAL8AAIA+zczMPc3MTD6amZk+zcyMP5qZmT9mZqY/ZmYGQM3MDEAzMxNAZmZGQM3MTEAzM1NAAACAPwAAAEAAAEBAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAA/AAAAvwAAgD7NzMw9zcxMPpqZmT7NzIw/mpmZP2Zmpj9mZgZAzcwMQDMzE0BmZkZAzcxMQDMzU0AzM4NAZmaGQJqZiUAzM6NAZmamQJqZqUAzM8NAZmbGQJqZyUAzM+NAZmbmQJqZ6UCamQFBMzMDQc3MBEEAAIA/AAAAQAAAQEAAAIA/AAAAAAAAAAAAAAAAAAAAAAAAAD8AAAC/AACAPs3MzD3NzEw+mpmZPs3MjD+amZk/ZmamP2ZmBkDNzAxAMzMTQGZmRkDNzExAMzNTQDMzg0BmZoZAmpmJQDMzo0BmZqZAmpmpQDMzw0BmZsZAmpnJQDMz40BmZuZAmpnpQJqZAUEzMwNBzcwEQZqZEUEzMxNBzcwUQZqZIUEzMyNBzcwkQZqZMUEzMzNBzcw0QZqZQUEzM0NBzcxEQZqZUUEzM1NBzcxUQZqZYUEzM2NBzcxkQZqZcUEzM3NBzcx0QQ==\"}],\"bufferViews\":[{\"buffer\":0,\"byteOffset\":0,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":12,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":28,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":40,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":44,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":56,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":68,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":72,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":84,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":88,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":100,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":112,\"byteLength\":8},{\"buffer\":0,\"byteOffset\":120,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":132,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":136,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":148,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":160,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":176,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":188,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":192,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":204,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":216,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":232,\"byteLength\":3},{\"buffer\":0,\"byteOffset\":236,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":240,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":252,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":264,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":280,\"byteLength\":3},{\"buffer\":0,\"byteOffset\":284,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":288,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":300,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":312,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":328,\"byteLength\":6},{\"buffer\":0,\"byteOffset\":336,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":340,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":352,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":364,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":380,\"byteLength\":6},{\"buffer\":0,\"byteOffset\":388,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":392,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":404,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":416,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":432,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":444,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":448,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":460,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":472,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":488,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":500,\"byteLength\":1},{\"buffer\":0,\"byteOffset\":504,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":516,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":528,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":544,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":556,\"byteLength\":2},{\"buffer\":0,\"byteOffset\":560,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":572,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":584,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":600,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":612,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":616,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":628,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":640,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":656,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":668,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":672,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":684,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":696,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":708,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":720,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":732,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":748,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":760,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":764,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":776,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":788,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":800,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":812,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":824,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":836,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":848,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":860,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":872,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":884,\"byteLength\":16},{\"buffer\":0,\"byteOffset\":900,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":912,\"byteLength\":4},{\"buffer\":0,\"byteOffset\":916,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":928,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":940,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":952,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":964,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":976,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":988,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":1000,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":1012,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":1024,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":1036,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":1048,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":1060,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":1072,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":1084,\"byteLength\":12},{\"buffer\":0,\"byteOffset\":1096,\"byteLength\":12}],\"accessors\":[{\"bufferView\":0,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":1,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":2,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":3,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":4,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":5,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":6,\"componentType\":5120,\"count\":1,\"type\":\"VEC4\",\"normalized\":true},{\"bufferView\":7,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":8,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":9,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":10,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":11,\"componentType\":5122,\"count\":1,\"type\":\"VEC4\",\"normalized\":true},{\"bufferView\":12,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":13,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":14,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":15,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":16,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":17,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":18,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":19,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":20,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":21,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":22,\"componentType\":5120,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":23,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":24,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":25,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":26,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":27,\"componentType\":5120,\"count\":1,\"type\":\"VEC3\",\"normalized\":true},{\"bufferView\":28,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":29,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":30,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":31,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":32,\"componentType\":5122,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":33,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":34,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":35,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":36,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":37,\"componentType\":5122,\"count\":1,\"type\":\"VEC3\",\"normalized\":true},{\"bufferView\":38,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":39,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":40,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":41,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":42,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":43,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":44,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":45,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":46,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":47,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":48,\"componentType\":5121,\"count\":1,\"type\":\"SCALAR\",\"normalized\":true},{\"bufferView\":49,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":50,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":51,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":52,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":53,\"componentType\":5123,\"count\":1,\"type\":\"SCALAR\",\"normalized\":true},{\"bufferView\":54,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":55,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":56,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":57,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":58,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":59,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":60,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":61,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":62,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":63,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":64,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":65,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":66,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":67,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":68,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":69,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":70,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":71,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":72,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":73,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":74,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":75,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":76,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":77,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":78,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":79,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":80,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":81,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":82,\"componentType\":5126,\"count\":1,\"type\":\"VEC4\"},{\"bufferView\":83,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":84,\"componentType\":5126,\"count\":1,\"type\":\"SCALAR\"},{\"bufferView\":85,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":86,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":87,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":88,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":89,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":90,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":91,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":92,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":93,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":94,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":95,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":96,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":97,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":98,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":99,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"},{\"bufferView\":100,\"componentType\":5126,\"count\":1,\"type\":\"VEC3\"}]}"
  },
  {
    "path": "tests/fixtures/khr_gaussian_splatting/khr_extensible_fallback.gltf",
    "content": "{\"asset\":{\"version\":\"2.0\"},\"extensionsUsed\":[\"KHR_gaussian_splatting\",\"EXT_gaussian_splatting_kernel_customShape\"],\"scene\":0,\"scenes\":[{\"nodes\":[0]}],\"nodes\":[{\"mesh\":0,\"name\":\"extensible_unknown\"}],\"meshes\":[{\"primitives\":[{\"mode\":0,\"extensions\":{\"KHR_gaussian_splatting\":{\"extensions\":{\"EXT_gaussian_splatting_kernel_customShape\":{\"gain\":1.25}},\"kernel\":\"customShape\",\"colorSpace\":\"custom_space_display\"}},\"attributes\":{\"POSITION\":0,\"KHR_gaussian_splatting:ROTATION\":1,\"KHR_gaussian_splatting:SCALE\":2,\"KHR_gaussian_splatting:OPACITY\":3,\"COLOR_0\":4}}]}],\"buffers\":[{\"uri\":\"data:application/octet-stream;base64,AACAPwAAAEAAAEBAAACAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/BbpA+wW4QPyKmWD8AAAA/\",\"byteLength\":60}],\"bufferViews\":[{\"byteOffset\":0,\"byteLength\":12,\"buffer\":0},{\"byteOffset\":12,\"byteLength\":16,\"buffer\":0},{\"byteOffset\":28,\"byteLength\":12,\"buffer\":0},{\"byteOffset\":40,\"byteLength\":4,\"buffer\":0},{\"byteOffset\":44,\"byteLength\":16,\"buffer\":0}],\"accessors\":[{\"count\":1,\"bufferView\":0,\"type\":\"VEC3\",\"componentType\":5126},{\"count\":1,\"bufferView\":1,\"type\":\"VEC4\",\"componentType\":5126},{\"count\":1,\"bufferView\":2,\"type\":\"VEC3\",\"componentType\":5126},{\"count\":1,\"bufferView\":3,\"type\":\"SCALAR\",\"componentType\":5126},{\"count\":1,\"bufferView\":4,\"type\":\"VEC4\",\"componentType\":5126}]}"
  },
  {
    "path": "tests/gaussian.rs",
    "content": "use bevy_gaussian_splatting::{\n    PlanarGaussian3d, PlanarGaussian4d, io::codec::CloudCodec, random_gaussians_3d,\n    random_gaussians_4d,\n};\n\n#[test]\nfn test_codec_3d() {\n    let count = 100;\n\n    let gaussians = random_gaussians_3d(count);\n    let encoded = gaussians.encode();\n    let decoded = PlanarGaussian3d::decode(encoded.as_slice());\n\n    assert_eq!(gaussians, decoded);\n}\n\n#[test]\nfn test_codec_4d() {\n    let count = 100;\n\n    let gaussians = random_gaussians_4d(count);\n    let encoded = gaussians.encode();\n    let decoded = PlanarGaussian4d::decode(encoded.as_slice());\n\n    assert_eq!(gaussians, decoded);\n}\n"
  },
  {
    "path": "tests/gpu/_harness.rs",
    "content": "use bevy::prelude::*;\n\nuse bevy_gaussian_splatting::GaussianSplattingPlugin;\n\n// scraping this in CI for now until bevy ci testing is more stable\n// #[test] + main thread, and windowless screenshot limitations exist\n// see: https://github.com/anchpop/endless-sea/blob/3b8481f1152293907794d60e920d4cc5a7ca8f40/src/tests/helpers.rs#L69-L83\n\n#[derive(Resource)]\npub struct TestHarness {\n    pub resolution: (u32, u32),\n}\n\npub fn test_harness_app(harness: TestHarness) -> App {\n    let mut app = App::new();\n\n    app.insert_resource(ClearColor(Color::srgb_u8(0, 0, 0)));\n    app.add_plugins(DefaultPlugins.set(WindowPlugin {\n        primary_window: Some(Window {\n            mode: bevy::window::WindowMode::Windowed,\n            present_mode: bevy::window::PresentMode::AutoVsync,\n            prevent_default_event_handling: false,\n            resolution: harness.resolution.into(),\n            title: \"bevy_gaussian_splatting pipeline test\".to_string(),\n            ..default()\n        }),\n        ..default()\n    }));\n\n    app.add_plugins(GaussianSplattingPlugin);\n\n    app.insert_resource(harness);\n\n    app\n}\n"
  },
  {
    "path": "tests/gpu/gaussian.rs",
    "content": "use bevy::{\n    app::AppExit, core_pipeline::tonemapping::Tonemapping, diagnostic::FrameCount, prelude::*,\n};\n\nuse bevy_gaussian_splatting::{\n    CloudSettings, GaussianCamera, PlanarGaussian3d, PlanarGaussian3dHandle, random_gaussians_3d,\n};\n\nuse _harness::{TestHarness, test_harness_app};\n\nmod _harness;\n\n// run with `cargo run --bin test_gaussian --features testing`\nfn main() {\n    let mut app = test_harness_app(TestHarness {\n        resolution: (512, 512),\n    });\n\n    app.add_systems(Startup, setup);\n    app.add_systems(Update, exit_after_warmup);\n\n    app.run();\n}\n\nfn setup(mut commands: Commands, mut gaussian_assets: ResMut<Assets<PlanarGaussian3d>>) {\n    let cloud = gaussian_assets.add(random_gaussians_3d(10_000));\n\n    commands.spawn((\n        PlanarGaussian3dHandle(cloud),\n        CloudSettings::default(),\n        Name::new(\"gaussian_cloud\"),\n    ));\n\n    commands.spawn((\n        Camera3d::default(),\n        Transform::from_translation(Vec3::new(0.0, 1.5, 5.0)),\n        Tonemapping::None,\n        GaussianCamera::default(),\n    ));\n}\n\nfn exit_after_warmup(mut exit: MessageWriter<AppExit>, frame_count: Res<FrameCount>) {\n    const FRAMES_TO_RUN: u32 = 30;\n    if frame_count.0 >= FRAMES_TO_RUN {\n        exit.write(AppExit::Success);\n    }\n}\n"
  },
  {
    "path": "tests/gpu/radix.rs",
    "content": "use bevy::{\n    app::AppExit, core_pipeline::tonemapping::Tonemapping, diagnostic::FrameCount, prelude::*,\n};\n\nuse bevy_gaussian_splatting::{\n    CloudSettings, GaussianCamera, PlanarGaussian3d, PlanarGaussian3dHandle, random_gaussians_3d,\n};\n\nuse _harness::{TestHarness, test_harness_app};\n\nmod _harness;\n\n// run with `cargo run --bin test_radix --features \"debug_gpu,sort_radix,testing\"`\nfn main() {\n    let mut app = test_harness_app(TestHarness {\n        resolution: (512, 512),\n    });\n\n    app.add_systems(Startup, setup);\n    app.add_systems(Update, exit_after_warmup);\n\n    app.run();\n}\n\nfn setup(mut commands: Commands, mut gaussian_assets: ResMut<Assets<PlanarGaussian3d>>) {\n    let cloud = gaussian_assets.add(random_gaussians_3d(10_000));\n\n    commands.spawn((\n        PlanarGaussian3dHandle(cloud),\n        CloudSettings::default(),\n        Name::new(\"gaussian_cloud\"),\n    ));\n\n    commands.spawn((\n        Camera3d::default(),\n        Transform::from_translation(Vec3::new(0.0, 1.5, 5.0)),\n        Tonemapping::None,\n        GaussianCamera::default(),\n    ));\n}\n\nfn exit_after_warmup(mut exit: MessageWriter<AppExit>, frame_count: Res<FrameCount>) {\n    const FRAMES_TO_RUN: u32 = 30;\n    if frame_count.0 >= FRAMES_TO_RUN {\n        exit.write(AppExit::Success);\n    }\n}\n"
  },
  {
    "path": "tests/headless_examples.rs",
    "content": "#![allow(dead_code, unused_imports)]\n\nuse bevy::{\n    app::{AppExit, ScheduleRunnerPlugin},\n    asset::LoadState,\n    camera::primitives::Aabb,\n    camera::visibility::ViewVisibility,\n    camera::{Projection, RenderTarget},\n    core_pipeline::tonemapping::Tonemapping,\n    image::TextureFormatPixelInfo,\n    prelude::*,\n    render::{\n        Extract, Render, RenderApp, RenderSystems,\n        render_asset::RenderAssets,\n        render_graph::{self, NodeRunError, RenderGraph, RenderGraphContext, RenderLabel},\n        render_resource::{\n            Buffer, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, Extent3d, MapMode,\n            PollType, TexelCopyBufferInfo, TexelCopyBufferLayout, TextureFormat, TextureUsages,\n        },\n        renderer::{RenderContext, RenderDevice, RenderQueue},\n        texture::GpuImage,\n        view::screenshot::{Screenshot, ScreenshotCaptured},\n    },\n    window::ExitCondition,\n    winit::WinitPlugin,\n};\nuse bevy_gaussian_splatting::{\n    CloudSettings, GaussianCamera, GaussianMode, GaussianScene, GaussianSceneHandle,\n    GaussianSplattingPlugin, PlanarGaussian3d, PlanarGaussian3dHandle, PlanarGaussian4d,\n    PlanarGaussian4dHandle,\n    gaussian::interface::{CommonCloud, TestCloud},\n    io::ply::parse_ply_3d,\n    io::scene::GaussianSceneLoaded,\n    random_gaussians_3d, random_gaussians_3d_seeded, random_gaussians_4d,\n    random_gaussians_4d_seeded,\n    sort::{SortMode, SortTrigger, SortedEntriesHandle},\n    utils::GaussianSplattingViewer,\n};\nuse bevy_interleave::prelude::Planar;\nuse crossbeam_channel::{Receiver, Sender};\nuse serde::Deserialize;\nuse serde_json::Value;\nuse std::{\n    io::BufReader,\n    path::Path,\n    path::PathBuf,\n    sync::{\n        Arc,\n        atomic::{AtomicBool, Ordering},\n    },\n    time::{Duration, Instant},\n};\n\nconst MANIFEST_PATH: &str = \"www/examples/examples.json\";\nconst THUMB_WIDTH: u32 = 960;\nconst THUMB_HEIGHT: u32 = 540;\n\n#[derive(Debug, Deserialize)]\nstruct ExamplesManifest {\n    schema_version: u32,\n    examples: Vec<ExampleEntry>,\n}\n\n#[allow(dead_code)]\n#[derive(Debug, Deserialize)]\nstruct ExampleEntry {\n    id: String,\n    title: String,\n    description: String,\n    #[serde(default)]\n    tags: Vec<String>,\n    thumbnail: String,\n    #[serde(default)]\n    thumbnail_input_scene: Option<String>,\n    #[serde(default)]\n    thumbnail_input_cloud: Option<String>,\n    #[serde(default)]\n    input_scene: Option<String>,\n    #[serde(default)]\n    input_cloud: Option<String>,\n    #[serde(default)]\n    args: Value,\n}\n\n#[derive(Resource, Deref)]\nstruct MainWorldReceiver(Receiver<Vec<u8>>);\n\n#[derive(Resource, Deref)]\nstruct RenderWorldSender(Sender<Vec<u8>>);\n\n#[derive(Debug, Resource)]\nstruct CaptureController {\n    frames_since_ready: u32,\n    total_frames: u32,\n    warmup_frames_after_ready: u32,\n    max_total_frames: u32,\n    started_at: Instant,\n    max_elapsed: Duration,\n    capture_requested: bool,\n    width: u32,\n    height: u32,\n}\n\nimpl CaptureController {\n    pub fn new(width: u32, height: u32) -> Self {\n        Self {\n            frames_since_ready: 0,\n            total_frames: 0,\n            warmup_frames_after_ready: 15,\n            max_total_frames: 600,\n            started_at: Instant::now(),\n            max_elapsed: Duration::from_secs(90),\n            capture_requested: false,\n            width,\n            height,\n        }\n    }\n}\n\n#[derive(Resource, Clone)]\nstruct OutputTarget {\n    path: PathBuf,\n}\n\n#[derive(Resource, Clone)]\nstruct ThumbnailRenderConfig {\n    sort_mode: SortMode,\n}\n\n#[derive(Resource, Clone, Deref)]\nstruct CaptureRenderTarget(Handle<Image>);\n\n#[derive(Resource, Default)]\nstruct AutoFrameState {\n    done: bool,\n}\n\n#[derive(Component, Debug, Default)]\nstruct SceneCameraApplied;\n\n#[derive(Component, Debug, Default)]\nstruct SceneRenderModeApplied;\n\ntype SceneRenderModeQuery = (Entity, &'static Children);\ntype SceneRenderModeFilter = (With<GaussianSceneLoaded>, Without<SceneRenderModeApplied>);\ntype SceneReadyQuery = (\n    Entity,\n    &'static GaussianSceneHandle,\n    &'static Children,\n    Option<&'static SceneCameraApplied>,\n    Option<&'static SceneRenderModeApplied>,\n);\ntype SceneReadyFilter = With<GaussianSceneLoaded>;\n\n#[test]\nfn render_example_thumbnails() {\n    if std::env::var(\"RENDER_EXAMPLE_THUMBNAILS\").ok().as_deref() != Some(\"1\") {\n        return;\n    }\n\n    let manifest = load_manifest();\n    assert_eq!(manifest.schema_version, 1, \"unexpected manifest version\");\n\n    for example in manifest.examples {\n        let mut args = apply_args(GaussianSplattingViewer::default(), &example);\n        args.width = THUMB_WIDTH as f32;\n        args.height = THUMB_HEIGHT as f32;\n\n        let output_path = PathBuf::from(\"www/examples\").join(&example.thumbnail);\n        if let Some(parent) = output_path.parent() {\n            std::fs::create_dir_all(parent).expect(\"failed to create thumbnail directory\");\n        }\n\n        let started = Instant::now();\n        println!(\n            \"[thumbnails] rendering '{}' -> {}\",\n            example.id,\n            output_path.display()\n        );\n        render_example(args, output_path);\n        println!(\n            \"[thumbnails] rendered '{}' in {:?}\",\n            example.id,\n            started.elapsed()\n        );\n    }\n}\n\nfn load_manifest() -> ExamplesManifest {\n    let data = std::fs::read_to_string(MANIFEST_PATH).expect(\"failed to read examples manifest\");\n    serde_json::from_str(&data).expect(\"failed to parse examples manifest\")\n}\n\nfn apply_args(\n    mut base: GaussianSplattingViewer,\n    example: &ExampleEntry,\n) -> GaussianSplattingViewer {\n    let effective_scene = example\n        .thumbnail_input_scene\n        .as_ref()\n        .or(example.input_scene.as_ref());\n    let effective_cloud = example\n        .thumbnail_input_cloud\n        .as_ref()\n        .or(example.input_cloud.as_ref());\n\n    if effective_scene.is_some() && effective_cloud.is_some() {\n        panic!(\n            \"example '{}' cannot define both input_scene and input_cloud\",\n            example.id\n        );\n    }\n\n    let mut base_value = serde_json::to_value(&base).expect(\"failed to serialize args\");\n    let Some(base_map) = base_value.as_object_mut() else {\n        panic!(\"expected base args to serialize to object\");\n    };\n\n    if let Some(args_map) = example.args.as_object() {\n        for (key, value) in args_map.iter() {\n            if !base_map.contains_key(key) {\n                panic!(\"unknown viewer arg: {key}\");\n            }\n            base_map.insert(key.clone(), value.clone());\n        }\n    } else if !example.args.is_null() {\n        panic!(\"expected args to be a JSON object\");\n    }\n\n    base = serde_json::from_value(base_value).expect(\"failed to deserialize args\");\n\n    if let Some(input_scene) = effective_scene {\n        let resolved_scene = resolve_thumbnail_scene_input(input_scene);\n        base.input_scene = Some(resolved_scene);\n        base.input_cloud = None;\n    } else if let Some(input_cloud) = effective_cloud {\n        base.input_cloud = Some(input_cloud.clone());\n        base.input_scene = None;\n    }\n\n    base\n}\n\nfn resolve_thumbnail_scene_input(input_scene: &str) -> String {\n    let is_remote = input_scene.starts_with(\"https://\") || input_scene.starts_with(\"http://\");\n    if !is_remote {\n        return input_scene.to_owned();\n    }\n\n    let strict_cache = std::env::var(\"THUMBNAIL_SCENE_CACHE_STRICT\")\n        .ok()\n        .as_deref()\n        == Some(\"1\");\n    let Some(cache_dir) = std::env::var(\"THUMBNAIL_SCENE_CACHE_DIR\").ok() else {\n        return input_scene.to_owned();\n    };\n\n    let url_without_query = input_scene.split('?').next().unwrap_or(input_scene);\n    let Some(file_name) = url_without_query.rsplit('/').next() else {\n        return input_scene.to_owned();\n    };\n    if file_name.is_empty() {\n        return input_scene.to_owned();\n    }\n\n    let cached_path = PathBuf::from(cache_dir).join(file_name);\n    if cached_path.exists() {\n        let resolved_path = cached_path\n            .canonicalize()\n            .unwrap_or_else(|_| cached_path.clone())\n            .to_string_lossy()\n            .replace('\\\\', \"/\");\n        println!(\n            \"[thumbnails] using cached scene for '{}': {}\",\n            input_scene, resolved_path\n        );\n        return resolved_path;\n    }\n\n    if strict_cache {\n        panic!(\n            \"missing cached thumbnail scene for '{}' at '{}'\",\n            input_scene,\n            cached_path.display()\n        );\n    }\n\n    println!(\n        \"[thumbnails] scene cache miss for '{}', falling back to remote URL\",\n        input_scene\n    );\n    input_scene.to_owned()\n}\n\nfn supported_thumbnail_sort_modes() -> String {\n    let mut modes = vec![\"default\", \"none\"];\n    #[cfg(all(feature = \"sort_radix\", not(feature = \"buffer_texture\")))]\n    modes.push(\"radix\");\n    #[cfg(feature = \"sort_rayon\")]\n    modes.push(\"rayon\");\n    #[cfg(feature = \"sort_std\")]\n    modes.push(\"std\");\n    modes.join(\", \")\n}\n\nfn preferred_thumbnail_sort_mode() -> SortMode {\n    let requested = std::env::var(\"THUMBNAIL_SORT_MODE\")\n        .ok()\n        .map(|value| value.trim().to_ascii_lowercase())\n        .filter(|value| !value.is_empty());\n\n    if let Some(value) = requested.as_deref() {\n        if value == \"default\" {\n            return SortMode::default();\n        }\n        if value == \"none\" {\n            return SortMode::None;\n        }\n        #[cfg(all(feature = \"sort_radix\", not(feature = \"buffer_texture\")))]\n        if value == \"radix\" {\n            return SortMode::Radix;\n        }\n        #[cfg(feature = \"sort_rayon\")]\n        if value == \"rayon\" {\n            return SortMode::Rayon;\n        }\n        #[cfg(feature = \"sort_std\")]\n        if value == \"std\" {\n            return SortMode::Std;\n        }\n\n        panic!(\n            \"unsupported THUMBNAIL_SORT_MODE='{value}', expected one of: {}\",\n            supported_thumbnail_sort_modes()\n        );\n    }\n\n    #[cfg(feature = \"sort_std\")]\n    {\n        SortMode::Std\n    }\n\n    #[cfg(all(not(feature = \"sort_std\"), feature = \"sort_rayon\"))]\n    {\n        SortMode::Rayon\n    }\n\n    #[cfg(all(not(feature = \"sort_std\"), not(feature = \"sort_rayon\")))]\n    {\n        SortMode::default()\n    }\n}\n\nfn render_example(args: GaussianSplattingViewer, output_path: PathBuf) {\n    let sort_mode = preferred_thumbnail_sort_mode();\n    println!(\"[thumbnails] thumbnail sort mode: {sort_mode:?}\");\n\n    App::new()\n        .insert_resource(CaptureController::new(THUMB_WIDTH, THUMB_HEIGHT))\n        .insert_resource(OutputTarget { path: output_path })\n        .insert_resource(ThumbnailRenderConfig { sort_mode })\n        .insert_resource(AutoFrameState::default())\n        .insert_resource(ClearColor(Color::srgb_u8(0, 0, 0)))\n        .insert_resource(args)\n        .add_plugins(\n            DefaultPlugins\n                .set(AssetPlugin {\n                    file_path: \"assets\".to_string(),\n                    processed_file_path: \"assets\".to_string(),\n                    meta_check: bevy::asset::AssetMetaCheck::Never,\n                    unapproved_path_mode: bevy::asset::UnapprovedPathMode::Allow,\n                    ..default()\n                })\n                .set(ImagePlugin::default_nearest())\n                .set(WindowPlugin {\n                    primary_window: None,\n                    exit_condition: ExitCondition::DontExit,\n                    ..default()\n                })\n                .disable::<WinitPlugin>()\n                .disable::<bevy::log::LogPlugin>(),\n        )\n        .add_plugins(ScheduleRunnerPlugin::run_loop(Duration::from_secs_f64(\n            1.0 / 60.0,\n        )))\n        .add_plugins(GaussianSplattingPlugin)\n        .add_systems(Startup, setup_gaussian_cloud)\n        .add_systems(\n            Update,\n            (\n                apply_scene_camera_spawn,\n                apply_scene_render_mode_override,\n                mark_capture_ready,\n                request_screenshot_capture,\n            )\n                .chain(),\n        )\n        .add_observer(on_screenshot_captured)\n        .run();\n}\n\n#[allow(clippy::too_many_arguments)]\nfn setup_gaussian_cloud(\n    mut commands: Commands,\n    asset_server: Res<AssetServer>,\n    args: Res<GaussianSplattingViewer>,\n    render_config: Res<ThumbnailRenderConfig>,\n    mut gaussian_assets: ResMut<Assets<PlanarGaussian3d>>,\n    mut gaussian_4d_assets: ResMut<Assets<PlanarGaussian4d>>,\n    mut images: ResMut<Assets<Image>>,\n    controller: Res<CaptureController>,\n) {\n    let cloud_transform = args.cloud_transform();\n    let cloud_settings = CloudSettings {\n        gaussian_mode: args.gaussian_mode,\n        playback_mode: args.playback_mode,\n        rasterize_mode: args.rasterization_mode,\n        sort_mode: render_config.sort_mode.clone(),\n        global_scale: 8.0,\n        global_opacity: 2.0,\n        ..default()\n    };\n\n    let size = Extent3d {\n        width: controller.width,\n        height: controller.height,\n        ..default()\n    };\n\n    let render_target_handle = images.add(Image::new_target_texture(\n        size.width,\n        size.height,\n        TextureFormat::bevy_default(),\n        None,\n    ));\n    commands.insert_resource(CaptureRenderTarget(render_target_handle.clone()));\n\n    if let Some(input_scene) = &args.input_scene {\n        let scene_handle: Handle<GaussianScene> = asset_server.load(input_scene.clone());\n        commands.spawn((\n            GaussianSceneHandle(scene_handle),\n            Name::new(\"gaussian_scene\"),\n            cloud_transform,\n        ));\n    } else {\n        match args.gaussian_mode {\n            GaussianMode::Gaussian2d | GaussianMode::Gaussian3d => {\n                let cloud = if args.gaussian_count > 0 {\n                    if let Some(seed) = args.gaussian_seed {\n                        gaussian_assets.add(random_gaussians_3d_seeded(args.gaussian_count, seed))\n                    } else {\n                        gaussian_assets.add(random_gaussians_3d(args.gaussian_count))\n                    }\n                } else if let Some(input_cloud) = &args.input_cloud {\n                    if input_cloud.ends_with(\".ply\") {\n                        gaussian_assets.add(load_ply_cloud(input_cloud))\n                    } else {\n                        asset_server.load(input_cloud)\n                    }\n                } else {\n                    gaussian_assets.add(PlanarGaussian3d::test_model())\n                };\n\n                commands.spawn((\n                    PlanarGaussian3dHandle(cloud),\n                    cloud_settings.clone(),\n                    Name::new(\"gaussian_cloud\"),\n                    cloud_transform,\n                    Visibility::Visible,\n                ));\n            }\n            GaussianMode::Gaussian4d => {\n                let cloud = if args.gaussian_count > 0 {\n                    if let Some(seed) = args.gaussian_seed {\n                        gaussian_4d_assets\n                            .add(random_gaussians_4d_seeded(args.gaussian_count, seed))\n                    } else {\n                        gaussian_4d_assets.add(random_gaussians_4d(args.gaussian_count))\n                    }\n                } else if let Some(input_cloud) = &args.input_cloud {\n                    asset_server.load(input_cloud)\n                } else {\n                    gaussian_4d_assets.add(PlanarGaussian4d::test_model())\n                };\n\n                commands.spawn((\n                    PlanarGaussian4dHandle(cloud),\n                    cloud_settings,\n                    Name::new(\"gaussian_cloud\"),\n                    cloud_transform,\n                    Visibility::Visible,\n                ));\n            }\n        }\n    }\n\n    commands.spawn((\n        Camera3d::default(),\n        Camera::default(),\n        RenderTarget::Image(render_target_handle.into()),\n        Transform::from_translation(Vec3::new(0.0, 1.5, 5.0)),\n        Tonemapping::None,\n        GaussianCamera::default(),\n    ));\n}\n\nfn apply_scene_camera_spawn(\n    mut commands: Commands,\n    scene_handles: Query<(Entity, &GaussianSceneHandle), Without<SceneCameraApplied>>,\n    asset_server: Res<AssetServer>,\n    scenes: Res<Assets<GaussianScene>>,\n    mut cameras: Query<&mut Transform, With<GaussianCamera>>,\n) {\n    for (entity, scene_handle) in scene_handles.iter() {\n        if let Some(load_state) = asset_server.get_load_state(&scene_handle.0) {\n            match load_state {\n                LoadState::Failed(err) => {\n                    panic!(\"failed to load scene asset {:?}: {err}\", scene_handle.0);\n                }\n                state if !state.is_loaded() => continue,\n                _ => {}\n            }\n        }\n\n        let Some(scene) = scenes.get(&scene_handle.0) else {\n            continue;\n        };\n\n        if let Some(scene_camera) = scene.cameras.first()\n            && let Ok(mut camera_transform) = cameras.single_mut()\n        {\n            *camera_transform = scene_camera.transform;\n        }\n\n        commands.entity(entity).insert(SceneCameraApplied);\n    }\n}\n\nfn apply_scene_render_mode_override(\n    mut commands: Commands,\n    args: Res<GaussianSplattingViewer>,\n    render_config: Res<ThumbnailRenderConfig>,\n    scenes: Query<SceneRenderModeQuery, SceneRenderModeFilter>,\n    mut cloud_settings: Query<&mut CloudSettings>,\n) {\n    if args.input_scene.is_none() {\n        return;\n    }\n\n    for (entity, children) in scenes.iter() {\n        for child in children.iter() {\n            let child: Entity = child;\n            if let Ok(mut settings) = cloud_settings.get_mut(child) {\n                settings.rasterize_mode = args.rasterization_mode;\n                settings.sort_mode = render_config.sort_mode.clone();\n            }\n        }\n\n        commands.entity(entity).insert(SceneRenderModeApplied);\n    }\n}\n\n#[allow(clippy::too_many_arguments)]\nfn mark_capture_ready(\n    mut auto_frame: ResMut<AutoFrameState>,\n    args: Res<GaussianSplattingViewer>,\n    asset_server: Res<AssetServer>,\n    scenes: Res<Assets<GaussianScene>>,\n    scene_handles: Query<SceneReadyQuery, SceneReadyFilter>,\n    cloud_assets: Res<Assets<PlanarGaussian3d>>,\n    cloud_assets_4d: Res<Assets<PlanarGaussian4d>>,\n    child_cloud_handles: Query<&PlanarGaussian3dHandle>,\n    cloud_handles: Query<&PlanarGaussian3dHandle>,\n    cloud_handles_4d: Query<&PlanarGaussian4dHandle>,\n) {\n    if auto_frame.done {\n        return;\n    }\n\n    if args.input_scene.is_some() {\n        for (_, scene_handle, children, camera_applied, render_mode_applied) in scene_handles.iter()\n        {\n            if let Some(load_state) = asset_server.get_load_state(&scene_handle.0) {\n                match load_state {\n                    LoadState::Failed(err) => {\n                        panic!(\"failed to load scene asset {:?}: {err}\", scene_handle.0);\n                    }\n                    state if !state.is_loaded() => continue,\n                    _ => {}\n                }\n            }\n\n            if scenes.get(&scene_handle.0).is_none()\n                || camera_applied.is_none()\n                || render_mode_applied.is_none()\n            {\n                continue;\n            }\n\n            let mut scene_cloud_count = 0usize;\n            let mut scene_clouds_ready = true;\n\n            for child in children.iter() {\n                let child: Entity = child;\n                let Ok(cloud_handle) = child_cloud_handles.get(child) else {\n                    continue;\n                };\n\n                scene_cloud_count += 1;\n\n                if let Some(load_state) = asset_server.get_load_state(&cloud_handle.0) {\n                    match load_state {\n                        LoadState::Failed(err) => {\n                            panic!(\n                                \"failed to load scene cloud asset {:?}: {err}\",\n                                cloud_handle.0\n                            );\n                        }\n                        state if !state.is_loaded() => {\n                            scene_clouds_ready = false;\n                            break;\n                        }\n                        _ => {}\n                    }\n                }\n\n                if cloud_assets.get(&cloud_handle.0).is_none() {\n                    scene_clouds_ready = false;\n                    break;\n                }\n            }\n\n            if scene_cloud_count > 0 && scene_clouds_ready {\n                println!(\n                    \"[thumbnails] scene ready (clouds={}, camera_applied={}, render_mode_applied={})\",\n                    scene_cloud_count,\n                    camera_applied.is_some(),\n                    render_mode_applied.is_some()\n                );\n                auto_frame.done = true;\n                return;\n            }\n        }\n        return;\n    }\n\n    for cloud_handle in cloud_handles.iter() {\n        if let Some(load_state) = asset_server.get_load_state(&cloud_handle.0) {\n            match load_state {\n                LoadState::Failed(err) => {\n                    panic!(\"failed to load cloud asset {:?}: {err}\", cloud_handle.0);\n                }\n                state if !state.is_loaded() => continue,\n                _ => {}\n            }\n        }\n\n        if cloud_assets.get(&cloud_handle.0).is_some() {\n            println!(\"[thumbnails] cloud ready (3d)\");\n            auto_frame.done = true;\n            return;\n        }\n    }\n\n    for cloud_handle in cloud_handles_4d.iter() {\n        if let Some(load_state) = asset_server.get_load_state(&cloud_handle.0) {\n            match load_state {\n                LoadState::Failed(err) => {\n                    panic!(\"failed to load 4d cloud asset {:?}: {err}\", cloud_handle.0);\n                }\n                state if !state.is_loaded() => continue,\n                _ => {}\n            }\n        }\n\n        if cloud_assets_4d.get(&cloud_handle.0).is_some() {\n            println!(\"[thumbnails] cloud ready (4d)\");\n            auto_frame.done = true;\n            return;\n        }\n    }\n}\n\n#[allow(clippy::too_many_arguments)]\nfn request_screenshot_capture(\n    mut commands: Commands,\n    capture_target: Option<Res<CaptureRenderTarget>>,\n    output_target: Res<OutputTarget>,\n    auto_frame: Res<AutoFrameState>,\n    render_config: Res<ThumbnailRenderConfig>,\n    cameras: Query<&SortTrigger, With<GaussianCamera>>,\n    clouds_3d: Query<Option<&SortedEntriesHandle>, With<PlanarGaussian3dHandle>>,\n    clouds_4d: Query<Option<&SortedEntriesHandle>, With<PlanarGaussian4dHandle>>,\n    mut controller: ResMut<CaptureController>,\n) {\n    let elapsed = controller.started_at.elapsed();\n    if elapsed > controller.max_elapsed {\n        panic!(\n            \"timed out while generating thumbnail: {:?} (elapsed={:?}, auto_frame.done={}, frames_since_ready={}, capture_requested={})\",\n            output_target.path,\n            elapsed,\n            auto_frame.done,\n            controller.frames_since_ready,\n            controller.capture_requested,\n        );\n    }\n\n    controller.total_frames += 1;\n    if controller.total_frames > controller.max_total_frames {\n        panic!(\n            \"timed out while generating thumbnail: {:?} (elapsed={:?}, total_frames={}, auto_frame.done={}, frames_since_ready={}, capture_requested={})\",\n            output_target.path,\n            elapsed,\n            controller.total_frames,\n            auto_frame.done,\n            controller.frames_since_ready,\n            controller.capture_requested,\n        );\n    }\n\n    if !auto_frame.done {\n        controller.frames_since_ready = 0;\n        return;\n    }\n\n    let requires_cpu_sort = match render_config.sort_mode {\n        #[cfg(feature = \"sort_std\")]\n        SortMode::Std => true,\n        #[cfg(feature = \"sort_rayon\")]\n        SortMode::Rayon => true,\n        _ => false,\n    };\n\n    if requires_cpu_sort {\n        let mut sort_ready = true;\n        let mut saw_camera = false;\n        for trigger in cameras.iter() {\n            saw_camera = true;\n            if trigger.needs_sort {\n                sort_ready = false;\n                break;\n            }\n        }\n        if !saw_camera {\n            sort_ready = false;\n        }\n\n        let mut saw_cloud = false;\n        if sort_ready {\n            for sorted_handle in clouds_3d.iter() {\n                saw_cloud = true;\n                if sorted_handle.is_none() {\n                    sort_ready = false;\n                    break;\n                }\n            }\n        }\n        if sort_ready {\n            for sorted_handle in clouds_4d.iter() {\n                saw_cloud = true;\n                if sorted_handle.is_none() {\n                    sort_ready = false;\n                    break;\n                }\n            }\n        }\n        if !saw_cloud {\n            sort_ready = false;\n        }\n\n        if !sort_ready {\n            controller.frames_since_ready = 0;\n            return;\n        }\n    }\n\n    controller.frames_since_ready += 1;\n    if controller.frames_since_ready < controller.warmup_frames_after_ready {\n        return;\n    }\n\n    if controller.capture_requested {\n        return;\n    }\n\n    let Some(capture_target) = capture_target else {\n        return;\n    };\n\n    println!(\n        \"[thumbnails] requesting screenshot (elapsed={:?}, frames_since_ready={})\",\n        elapsed, controller.frames_since_ready\n    );\n    commands.spawn(Screenshot::image(capture_target.0.clone()));\n    controller.capture_requested = true;\n}\n\nfn on_screenshot_captured(\n    trigger: On<ScreenshotCaptured>,\n    output_target: Res<OutputTarget>,\n    mut app_exit: MessageWriter<AppExit>,\n) {\n    println!(\n        \"[thumbnails] screenshot captured for '{}'\",\n        output_target.path.display()\n    );\n    let img = match trigger.image.clone().try_into_dynamic() {\n        Ok(img) => img.to_rgba8(),\n        Err(e) => panic!(\"Failed to convert screenshot image: {e:?}\"),\n    };\n\n    if let Err(e) = img.save(&output_target.path) {\n        panic!(\"Failed to save image: {e}\");\n    }\n\n    app_exit.write(AppExit::Success);\n}\n\nfn load_ply_cloud(input_cloud: &str) -> PlanarGaussian3d {\n    let direct_path = PathBuf::from(input_cloud);\n    let path = if direct_path.exists() {\n        direct_path\n    } else {\n        Path::new(\"assets\").join(input_cloud)\n    };\n    let file = std::fs::File::open(&path).unwrap_or_else(|err| {\n        panic!(\"failed to open PLY file for thumbnail render {path:?}: {err}\")\n    });\n    let mut reader = BufReader::new(file);\n    parse_ply_3d(&mut reader).unwrap_or_else(|err| {\n        panic!(\"failed to parse PLY file for thumbnail render {path:?}: {err}\")\n    })\n}\n\npub struct ImageCopyPlugin;\n\nimpl Plugin for ImageCopyPlugin {\n    fn build(&self, app: &mut App) {\n        let (sender, receiver) = crossbeam_channel::unbounded();\n\n        let render_app = app\n            .insert_resource(MainWorldReceiver(receiver))\n            .sub_app_mut(RenderApp);\n\n        let mut graph = render_app.world_mut().resource_mut::<RenderGraph>();\n        graph.add_node(ImageCopy, ImageCopyDriver);\n        graph.add_node_edge(bevy::render::graph::CameraDriverLabel, ImageCopy);\n\n        render_app\n            .insert_resource(RenderWorldSender(sender))\n            .add_systems(ExtractSchedule, extract_image_copiers)\n            .add_systems(\n                Render,\n                receive_image_from_buffer.after(RenderSystems::Render),\n            );\n    }\n}\n\npub struct CaptureFramePlugin;\n\nimpl Plugin for CaptureFramePlugin {\n    fn build(&self, app: &mut App) {\n        app.add_systems(PostUpdate, save_captured_frame);\n    }\n}\n\n#[derive(Clone, Component)]\nstruct ImageCopier {\n    buffer: Buffer,\n    enabled: Arc<AtomicBool>,\n    src_image: Handle<Image>,\n}\n\nimpl ImageCopier {\n    pub fn new(src_image: Handle<Image>, size: Extent3d, render_device: &RenderDevice) -> Self {\n        let padded_bytes_per_row = RenderDevice::align_copy_bytes_per_row(size.width as usize) * 4;\n\n        let buffer = render_device.create_buffer(&BufferDescriptor {\n            label: Some(\"image_copier_buffer\"),\n            size: padded_bytes_per_row as u64 * size.height as u64,\n            usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,\n            mapped_at_creation: false,\n        });\n\n        Self {\n            buffer,\n            src_image,\n            enabled: Arc::new(AtomicBool::new(true)),\n        }\n    }\n\n    pub fn enabled(&self) -> bool {\n        self.enabled.load(Ordering::Relaxed)\n    }\n}\n\n#[derive(Clone, Default, Resource, Deref)]\nstruct ImageCopiers(Vec<ImageCopier>);\n\nfn extract_image_copiers(mut commands: Commands, image_copiers: Extract<Query<&ImageCopier>>) {\n    commands.insert_resource(ImageCopiers(image_copiers.iter().cloned().collect()));\n}\n\n#[derive(Debug, PartialEq, Eq, Clone, Hash, RenderLabel)]\nstruct ImageCopy;\n\n#[derive(Default)]\nstruct ImageCopyDriver;\n\nimpl render_graph::Node for ImageCopyDriver {\n    fn run(\n        &self,\n        _graph: &mut RenderGraphContext,\n        render_context: &mut RenderContext,\n        world: &World,\n    ) -> Result<(), NodeRunError> {\n        let image_copiers = world.get_resource::<ImageCopiers>().unwrap();\n        let gpu_images = world.get_resource::<RenderAssets<GpuImage>>().unwrap();\n\n        for image_copier in image_copiers.iter() {\n            if !image_copier.enabled() {\n                continue;\n            }\n\n            let Some(src_image) = gpu_images.get(&image_copier.src_image) else {\n                continue;\n            };\n\n            let mut encoder = render_context\n                .render_device()\n                .create_command_encoder(&CommandEncoderDescriptor::default());\n\n            let block_dimensions = src_image.texture_format.block_dimensions();\n            let block_size = src_image.texture_format.block_copy_size(None).unwrap();\n\n            let padded_bytes_per_row = RenderDevice::align_copy_bytes_per_row(\n                (src_image.size.width as usize / block_dimensions.0 as usize) * block_size as usize,\n            );\n\n            encoder.copy_texture_to_buffer(\n                src_image.texture.as_image_copy(),\n                TexelCopyBufferInfo {\n                    buffer: &image_copier.buffer,\n                    layout: TexelCopyBufferLayout {\n                        offset: 0,\n                        bytes_per_row: Some(\n                            std::num::NonZero::<u32>::new(padded_bytes_per_row as u32)\n                                .unwrap()\n                                .into(),\n                        ),\n                        rows_per_image: None,\n                    },\n                },\n                src_image.size,\n            );\n\n            let render_queue = world.get_resource::<RenderQueue>().unwrap();\n            render_queue.submit(std::iter::once(encoder.finish()));\n        }\n\n        Ok(())\n    }\n}\n\nfn receive_image_from_buffer(\n    image_copiers: Res<ImageCopiers>,\n    render_device: Res<RenderDevice>,\n    sender: Res<RenderWorldSender>,\n) {\n    for image_copier in image_copiers.0.iter() {\n        if !image_copier.enabled() {\n            continue;\n        }\n\n        let buffer_slice = image_copier.buffer.slice(..);\n        let (tx, rx) = crossbeam_channel::bounded(1);\n\n        buffer_slice.map_async(MapMode::Read, move |result| match result {\n            Ok(()) => tx.send(()).expect(\"Failed to send map result\"),\n            Err(err) => panic!(\"Failed to map buffer: {err}\"),\n        });\n\n        render_device\n            .poll(PollType::wait_indefinitely())\n            .expect(\"Failed to poll device\");\n\n        rx.recv().expect(\"Failed to receive buffer map\");\n\n        let _ = sender.send(buffer_slice.get_mapped_range().to_vec());\n        image_copier.buffer.unmap();\n    }\n}\n\n#[derive(Component, Deref)]\nstruct ImageToSave(Handle<Image>);\n\n#[allow(clippy::too_many_arguments, clippy::type_complexity)]\nfn save_captured_frame(\n    _images_to_save: Query<&ImageToSave>,\n    _clouds: Query<\n        (\n            Option<&Aabb>,\n            Option<&SortedEntriesHandle>,\n            Option<&ViewVisibility>,\n        ),\n        With<PlanarGaussian3dHandle>,\n    >,\n    _cameras: Query<(&Camera, &RenderTarget), With<GaussianCamera>>,\n    receiver: Res<MainWorldReceiver>,\n    _output_target: Res<OutputTarget>,\n    _auto_frame: Res<AutoFrameState>,\n    _images: ResMut<Assets<Image>>,\n    _controller: ResMut<CaptureController>,\n    _app_exit: MessageWriter<AppExit>,\n) {\n    while receiver.try_recv().is_ok() {}\n}\n"
  },
  {
    "path": "tests/io.rs",
    "content": "use bevy_gaussian_splatting::{\n    PlanarGaussian3d, PlanarGaussian4d, io::codec::CloudCodec, random_gaussians_3d,\n    random_gaussians_4d,\n};\n\n#[test]\nfn test_codec_3d() {\n    let count = 10000;\n\n    let gaussians = random_gaussians_3d(count);\n    let encoded = gaussians.encode();\n    let decoded = PlanarGaussian3d::decode(encoded.as_slice());\n\n    assert_eq!(gaussians, decoded);\n}\n\n#[test]\nfn test_codec_4d() {\n    let count = 10000;\n\n    let gaussians = random_gaussians_4d(count);\n    let encoded = gaussians.encode();\n    let decoded = PlanarGaussian4d::decode(encoded.as_slice());\n\n    assert_eq!(gaussians, decoded);\n}\n"
  },
  {
    "path": "tests/khr_loader_conformance.rs",
    "content": "use std::{\n    collections::HashMap,\n    path::PathBuf,\n    thread,\n    time::{Duration, Instant},\n};\n\nuse bevy::{\n    asset::{\n        AssetMetaCheck, AssetPlugin, DependencyLoadState, LoadState, RecursiveDependencyLoadState,\n        UnapprovedPathMode,\n    },\n    prelude::*,\n};\nuse bevy_gaussian_splatting::{\n    GaussianKernel, GaussianProjection, GaussianSortingMethod, PlanarGaussian3d, SceneExportCloud,\n    gaussian::settings::GaussianColorSpace,\n    io::{\n        IoPlugin,\n        scene::{GaussianScene, encode_khr_gaussian_scene_gltf_bytes},\n    },\n};\n\nconst FIXTURE_ROOT: &str = \"tests/fixtures/khr_gaussian_splatting\";\n\n#[derive(Clone, Copy)]\nstruct ExpectedCase {\n    scale_raw: [f32; 3],\n    opacity: f32,\n    sh_degree: usize,\n    color_space: GaussianColorSpace,\n}\n\nfn approx_eq(actual: f32, expected: f32, epsilon: f32) {\n    assert!(\n        (actual - expected).abs() <= epsilon,\n        \"expected {expected}, got {actual}\"\n    );\n}\n\nfn max_supported_test_sh_degree() -> usize {\n    if cfg!(feature = \"sh4\") {\n        4\n    } else if cfg!(feature = \"sh3\") {\n        3\n    } else if cfg!(feature = \"sh2\") {\n        2\n    } else if cfg!(feature = \"sh1\") {\n        1\n    } else {\n        0\n    }\n}\n\nfn try_load_fixture_scene(path: &str) -> Result<(GaussianScene, HashMap<String, PlanarGaussian3d>), String> {\n    let fixture_root = PathBuf::from(env!(\"CARGO_MANIFEST_DIR\")).join(FIXTURE_ROOT);\n    if !fixture_root.exists() {\n        return Err(format!(\n            \"fixture root does not exist: {}\",\n            fixture_root.display()\n        ));\n    }\n\n    let mut app = App::new();\n    app.add_plugins(MinimalPlugins);\n    app.add_plugins(AssetPlugin {\n        file_path: fixture_root.display().to_string(),\n        processed_file_path: fixture_root.display().to_string(),\n        meta_check: AssetMetaCheck::Never,\n        unapproved_path_mode: UnapprovedPathMode::Allow,\n        ..default()\n    });\n    app.init_asset::<PlanarGaussian3d>();\n    app.add_plugins(IoPlugin);\n\n    let scene_handle: Handle<GaussianScene> = {\n        let asset_server = app.world().resource::<AssetServer>();\n        asset_server.load(path.to_owned())\n    };\n\n    let mut loaded_scene = None;\n    let mut last_states = (\n        LoadState::NotLoaded,\n        DependencyLoadState::NotLoaded,\n        RecursiveDependencyLoadState::NotLoaded,\n    );\n    let deadline = Instant::now() + Duration::from_secs(15);\n    while Instant::now() < deadline {\n        app.update();\n\n        if let Some((load_state, dep_state, rec_dep_state)) = app\n            .world()\n            .resource::<AssetServer>()\n            .get_load_states(&scene_handle)\n        {\n            last_states = (load_state.clone(), dep_state.clone(), rec_dep_state.clone());\n\n            match (&load_state, &dep_state, &rec_dep_state) {\n                (LoadState::Failed(err), _, _)\n                | (_, DependencyLoadState::Failed(err), _)\n                | (_, _, RecursiveDependencyLoadState::Failed(err)) => {\n                    return Err(format!(\"fixture '{path}' failed to load: {err}\"));\n                }\n                (LoadState::Loaded, _, RecursiveDependencyLoadState::Loaded) => {\n                    loaded_scene = app\n                        .world()\n                        .resource::<Assets<GaussianScene>>()\n                        .get(&scene_handle)\n                        .cloned();\n                    if loaded_scene.is_some() {\n                        break;\n                    }\n                }\n                _ => {}\n            }\n        }\n\n        thread::sleep(Duration::from_millis(1));\n    }\n\n    let scene = loaded_scene.ok_or_else(|| {\n        format!(\n            \"fixture scene '{path}' failed to load (load_state={:?}, dependency_state={:?}, recursive_dependency_state={:?})\",\n            last_states.0, last_states.1, last_states.2\n        )\n    });\n    let scene = scene?;\n    let mut clouds_by_case = HashMap::new();\n\n    for bundle in &scene.bundles {\n        let case_name = bundle\n            .name\n            .split(\"_mesh\")\n            .next()\n            .expect(\"bundle name should include mesh suffix\")\n            .to_owned();\n\n        let cloud = app\n            .world()\n            .resource::<Assets<PlanarGaussian3d>>()\n            .get(&bundle.cloud)\n            .cloned()\n            .ok_or_else(|| format!(\"cloud asset for case '{case_name}' missing\"))?;\n        clouds_by_case.insert(case_name, cloud);\n    }\n\n    Ok((scene, clouds_by_case))\n}\n\nfn load_fixture_scene(path: &str) -> (GaussianScene, HashMap<String, PlanarGaussian3d>) {\n    try_load_fixture_scene(path).unwrap_or_else(|err| panic!(\"{err}\"))\n}\n\nfn expected_cases() -> HashMap<&'static str, ExpectedCase> {\n    let mut cases = HashMap::new();\n\n    // Default attributes used by most matrix entries.\n    let default_scale = [0.0, 0.5, -0.5];\n    let default_opacity = 0.25;\n\n    for name in [\n        \"rotation_f32\",\n        \"rotation_i8_norm\",\n        \"rotation_i16_norm\",\n        \"opacity_u8_norm\",\n        \"opacity_u16_norm\",\n        \"opacity_f32\",\n        \"sh_degree0\",\n        \"sh_degree1\",\n        \"sh_degree2\",\n        \"sh_degree3\",\n    ] {\n        cases.insert(\n            name,\n            ExpectedCase {\n                scale_raw: default_scale,\n                opacity: default_opacity,\n                sh_degree: 0,\n                color_space: GaussianColorSpace::LinRec709Display,\n            },\n        );\n    }\n\n    // Per-case overrides.\n    cases.insert(\n        \"scale_f32\",\n        ExpectedCase {\n            scale_raw: [0.2, -0.1, 0.7],\n            opacity: default_opacity,\n            sh_degree: 0,\n            color_space: GaussianColorSpace::SrgbRec709Display,\n        },\n    );\n    cases.insert(\n        \"scale_i8\",\n        ExpectedCase {\n            scale_raw: [1.0, -2.0, 3.0],\n            opacity: default_opacity,\n            sh_degree: 0,\n            color_space: GaussianColorSpace::LinRec709Display,\n        },\n    );\n    cases.insert(\n        \"scale_i8_norm\",\n        ExpectedCase {\n            scale_raw: [1.0, 0.0, -1.0],\n            opacity: default_opacity,\n            sh_degree: 0,\n            color_space: GaussianColorSpace::LinRec709Display,\n        },\n    );\n    cases.insert(\n        \"scale_i16\",\n        ExpectedCase {\n            scale_raw: [2.0, -3.0, 4.0],\n            opacity: default_opacity,\n            sh_degree: 0,\n            color_space: GaussianColorSpace::LinRec709Display,\n        },\n    );\n    cases.insert(\n        \"scale_i16_norm\",\n        ExpectedCase {\n            scale_raw: [1.0, 0.0, -1.0],\n            opacity: default_opacity,\n            sh_degree: 0,\n            color_space: GaussianColorSpace::LinRec709Display,\n        },\n    );\n    cases.insert(\n        \"opacity_f32\",\n        ExpectedCase {\n            scale_raw: default_scale,\n            opacity: 0.75,\n            sh_degree: 0,\n            color_space: GaussianColorSpace::LinRec709Display,\n        },\n    );\n    cases.insert(\n        \"opacity_u8_norm\",\n        ExpectedCase {\n            scale_raw: default_scale,\n            opacity: 64.0 / 255.0,\n            sh_degree: 0,\n            color_space: GaussianColorSpace::LinRec709Display,\n        },\n    );\n    cases.insert(\n        \"opacity_u16_norm\",\n        ExpectedCase {\n            scale_raw: default_scale,\n            opacity: 16384.0 / 65535.0,\n            sh_degree: 0,\n            color_space: GaussianColorSpace::LinRec709Display,\n        },\n    );\n    cases.insert(\n        \"sh_degree1\",\n        ExpectedCase {\n            scale_raw: default_scale,\n            opacity: default_opacity,\n            sh_degree: 1,\n            color_space: GaussianColorSpace::LinRec709Display,\n        },\n    );\n    cases.insert(\n        \"sh_degree2\",\n        ExpectedCase {\n            scale_raw: default_scale,\n            opacity: default_opacity,\n            sh_degree: 2,\n            color_space: GaussianColorSpace::LinRec709Display,\n        },\n    );\n    cases.insert(\n        \"sh_degree3\",\n        ExpectedCase {\n            scale_raw: default_scale,\n            opacity: default_opacity,\n            sh_degree: 3,\n            color_space: GaussianColorSpace::LinRec709Display,\n        },\n    );\n\n    cases\n}\n\nfn assert_case_cloud(\n    case_name: &str,\n    cloud: &PlanarGaussian3d,\n    expected: ExpectedCase,\n    supported_sh_degree: usize,\n) {\n    assert_eq!(cloud.position_visibility.len(), 1, \"case {case_name}\");\n    assert_eq!(cloud.rotation.len(), 1, \"case {case_name}\");\n    assert_eq!(cloud.scale_opacity.len(), 1, \"case {case_name}\");\n    assert_eq!(cloud.spherical_harmonic.len(), 1, \"case {case_name}\");\n\n    let position = cloud.position_visibility[0].position;\n    approx_eq(position[0], 1.0, 1e-6);\n    approx_eq(position[1], 2.0, 1e-6);\n    approx_eq(position[2], 3.0, 1e-6);\n\n    // All matrix fixtures encode identity quaternion in different component encodings.\n    let rotation = cloud.rotation[0].rotation;\n    approx_eq(rotation[0], 1.0, 1e-5);\n    approx_eq(rotation[1], 0.0, 1e-5);\n    approx_eq(rotation[2], 0.0, 1e-5);\n    approx_eq(rotation[3], 0.0, 1e-5);\n\n    let scale = cloud.scale_opacity[0].scale;\n    approx_eq(scale[0], expected.scale_raw[0].exp(), 1e-5);\n    approx_eq(scale[1], expected.scale_raw[1].exp(), 1e-5);\n    approx_eq(scale[2], expected.scale_raw[2].exp(), 1e-5);\n    approx_eq(cloud.scale_opacity[0].opacity, expected.opacity, 1e-5);\n\n    let coeffs = &cloud.spherical_harmonic[0].coefficients;\n    let clamped_degree = expected.sh_degree.min(supported_sh_degree);\n    let expected_coeff_count = (clamped_degree + 1) * (clamped_degree + 1);\n    for coefficient in 0..expected_coeff_count {\n        let base = coefficient * 3;\n        approx_eq(coeffs[base], coefficient as f32 + 0.1, 1e-6);\n        approx_eq(coeffs[base + 1], coefficient as f32 + 0.2, 1e-6);\n        approx_eq(coeffs[base + 2], coefficient as f32 + 0.3, 1e-6);\n    }\n}\n\nfn assert_scene_cases(\n    scene: &GaussianScene,\n    clouds: &HashMap<String, PlanarGaussian3d>,\n    supported_sh_degree: usize,\n) {\n    let expected = expected_cases();\n    assert_eq!(scene.bundles.len(), expected.len());\n    assert_eq!(clouds.len(), expected.len());\n    assert_eq!(scene.cameras.len(), 1);\n    let scene_camera = &scene.cameras[0];\n    assert_eq!(scene_camera.name, \"fixture_camera\");\n    let translation = scene_camera.transform.translation;\n    approx_eq(translation.x, 4.0, 1e-6);\n    approx_eq(translation.y, 5.0, 1e-6);\n    approx_eq(translation.z, 6.0, 1e-6);\n\n    for bundle in &scene.bundles {\n        let case_name = bundle\n            .name\n            .split(\"_mesh\")\n            .next()\n            .expect(\"bundle name should include mesh suffix\");\n        let expected_case = *expected\n            .get(case_name)\n            .unwrap_or_else(|| panic!(\"unexpected case '{case_name}'\"));\n        assert_eq!(bundle.settings.color_space, expected_case.color_space);\n\n        let cloud = clouds\n            .get(case_name)\n            .unwrap_or_else(|| panic!(\"missing cloud for case '{case_name}'\"));\n        assert_case_cloud(case_name, cloud, expected_case, supported_sh_degree);\n    }\n}\n\n#[test]\nfn khr_loader_conformance_matrix_gltf_and_glb() {\n    let supported_sh_degree = max_supported_test_sh_degree();\n    for fixture in [\"khr_conformance_matrix.gltf\", \"khr_conformance_matrix.glb\"] {\n        let (scene, clouds) = load_fixture_scene(fixture);\n        assert_scene_cases(&scene, &clouds, supported_sh_degree);\n    }\n}\n\n#[test]\nfn khr_loader_extensibility_and_color0_fallback() {\n    let (scene, clouds) = load_fixture_scene(\"khr_extensible_fallback.gltf\");\n    assert_eq!(scene.bundles.len(), 1);\n    assert_eq!(scene.cameras.len(), 0);\n\n    let bundle = &scene.bundles[0];\n    assert_eq!(\n        bundle.settings.color_space,\n        GaussianColorSpace::SrgbRec709Display\n    );\n    assert_eq!(bundle.metadata.kernel, GaussianKernel::Ellipse);\n    assert_eq!(bundle.metadata.projection, GaussianProjection::Perspective);\n    assert_eq!(\n        bundle.metadata.sorting_method,\n        GaussianSortingMethod::CameraDistance\n    );\n    assert_eq!(bundle.metadata.spec.kernel, \"customShape\");\n    assert_eq!(bundle.metadata.spec.color_space, \"custom_space_display\");\n    assert_eq!(bundle.metadata.spec.projection, \"perspective\");\n    assert_eq!(bundle.metadata.spec.sorting_method, \"cameraDistance\");\n    assert!(\n        bundle.metadata.spec.extension_object.is_some(),\n        \"raw extension payload should be preserved\"\n    );\n    let extension_object = bundle.metadata.spec.extension_object.as_ref().unwrap();\n    assert!(\n        extension_object[\"extensions\"][\"EXT_gaussian_splatting_kernel_customShape\"].is_object()\n    );\n\n    let cloud = clouds\n        .get(\"extensible_unknown\")\n        .expect(\"missing cloud loaded from extensible fixture\");\n    let coeffs = &cloud.spherical_harmonic[0].coefficients;\n    approx_eq(coeffs[0], 1.0, 1e-4);\n    approx_eq(coeffs[1], 2.0, 1e-4);\n    approx_eq(coeffs[2], 3.0, 1e-4);\n    approx_eq(cloud.scale_opacity[0].opacity, 0.5, 1e-6);\n\n    let exported = encode_khr_gaussian_scene_gltf_bytes(\n        &[SceneExportCloud {\n            cloud: cloud.clone(),\n            name: \"extensible_unknown\".to_owned(),\n            settings: bundle.settings.clone(),\n            transform: bundle.transform,\n            metadata: bundle.metadata.clone(),\n        }],\n        None,\n    )\n    .expect(\"failed to export extensible fixture\");\n\n    let root: serde_json::Value = serde_json::from_slice(&exported).unwrap();\n    let exported_extension =\n        &root[\"meshes\"][0][\"primitives\"][0][\"extensions\"][\"KHR_gaussian_splatting\"];\n    assert_eq!(exported_extension[\"kernel\"].as_str(), Some(\"customShape\"));\n    assert_eq!(\n        exported_extension[\"colorSpace\"].as_str(),\n        Some(\"custom_space_display\")\n    );\n    assert_eq!(\n        exported_extension[\"projection\"].as_str(),\n        Some(\"perspective\")\n    );\n    assert_eq!(\n        exported_extension[\"sortingMethod\"].as_str(),\n        Some(\"cameraDistance\")\n    );\n    assert!(\n        exported_extension[\"extensions\"][\"EXT_gaussian_splatting_kernel_customShape\"].is_object()\n    );\n}\n"
  },
  {
    "path": "tests/radix.rs",
    "content": "\n"
  },
  {
    "path": "tools/README.md",
    "content": "# bevy_gaussian_splatting tools\n\n## ply to gcloud converter\n\nconvert ply files into bevy_gaussian_splatting gcloud file format (more efficient)\n\n```bash\ncargo run --bin ply_to_gcloud -- assets/scenes/icecream.ply\n```\n\n## render trellis thumbnails\n\nrender local example thumbnails from `trellis.ply` render modes.\n\n```bash\ncargo run --bin render_trellis_thumbnails --features io_ply\n```\n\n## build web output\n\nbuild wasm, generate wasm-bindgen output, and regenerate `www/examples/thumbnails/*`.\n\n```bash\nbash ./tools/build_www.sh\n```\n\non Windows:\n\n```powershell\npwsh ./tools/build_www.ps1\n```\n"
  },
  {
    "path": "tools/build_www.ps1",
    "content": "$ErrorActionPreference = 'Stop'\n\nfunction Invoke-Step {\n  param(\n    [Parameter(Mandatory = $true)][string]$Exe,\n    [Parameter(ValueFromRemainingArguments = $true)][string[]]$Args\n  )\n\n  & $Exe @Args\n  if ($LASTEXITCODE -ne 0) {\n    throw \"$Exe failed with exit code $LASTEXITCODE\"\n  }\n}\n\nif (Test-Path Env:RUSTFLAGS) {\n  Remove-Item Env:RUSTFLAGS\n}\nif (Test-Path Env:CARGO_ENCODED_RUSTFLAGS) {\n  Remove-Item Env:CARGO_ENCODED_RUSTFLAGS\n}\nif (Test-Path Env:RUSTDOCFLAGS) {\n  Remove-Item Env:RUSTDOCFLAGS\n}\n\nWrite-Host 'Building wasm (web feature set)...'\nInvoke-Step cargo build --target wasm32-unknown-unknown --release --no-default-features --features web\n\n$wasmPath = Join-Path 'target/wasm32-unknown-unknown/release' 'bevy_gaussian_splatting.wasm'\nif (-not (Test-Path $wasmPath)) {\n  throw \"wasm output not found at $wasmPath\"\n}\n\nif (-not (Get-Command wasm-bindgen -ErrorAction SilentlyContinue)) {\n  throw 'wasm-bindgen not found on PATH'\n}\n\nWrite-Host 'Generating wasm bindings...'\nInvoke-Step wasm-bindgen --out-dir ./www/out --target web $wasmPath\n\nWrite-Host 'Rendering example thumbnails from manifest...'\n$env:RENDER_EXAMPLE_THUMBNAILS = '1'\n$env:THUMBNAIL_SORT_MODE = 'std'\ntry {\n  Invoke-Step cargo test --test headless_examples render_example_thumbnails -- --nocapture\n} finally {\n  Remove-Item Env:THUMBNAIL_SORT_MODE -ErrorAction SilentlyContinue\n  Remove-Item Env:RENDER_EXAMPLE_THUMBNAILS -ErrorAction SilentlyContinue\n}\n\nif ($env:THUMBNAIL_SCENE_CACHE_CLEANUP -eq '1') {\n  $sceneCache = Join-Path 'assets' '.thumbnail_cache'\n  if (Test-Path $sceneCache) {\n    Write-Host \"Cleaning thumbnail scene cache...\"\n    Remove-Item $sceneCache -Recurse -Force\n  }\n}\n\nWrite-Host 'www build complete.'\n"
  },
  {
    "path": "tools/build_www.sh",
    "content": "#!/usr/bin/env bash\nset -euo pipefail\n\nscript_dir=\"$(cd \"$(dirname \"${BASH_SOURCE[0]}\")\" && pwd)\"\nrepo_root=\"$(cd \"${script_dir}/..\" && pwd)\"\ncd \"${repo_root}\"\n\nresolve_cmd() {\n  local name=\"$1\"\n  if command -v \"${name}\" >/dev/null 2>&1; then\n    echo \"${name}\"\n    return 0\n  fi\n  if command -v \"${name}.exe\" >/dev/null 2>&1; then\n    echo \"${name}.exe\"\n    return 0\n  fi\n  if [[ -x \"${HOME}/.cargo/bin/${name}\" ]]; then\n    echo \"${HOME}/.cargo/bin/${name}\"\n    return 0\n  fi\n  if [[ -x \"${HOME}/.cargo/bin/${name}.exe\" ]]; then\n    echo \"${HOME}/.cargo/bin/${name}.exe\"\n    return 0\n  fi\n  return 1\n}\n\ncargo_cmd=\"$(resolve_cmd cargo)\" || {\n  echo \"cargo not found on PATH\" >&2\n  exit 1\n}\nwasm_bindgen_cmd=\"$(resolve_cmd wasm-bindgen)\" || {\n  echo \"wasm-bindgen not found on PATH\" >&2\n  exit 1\n}\n\n# Ensure wasm builds ignore host-specific rust flag overrides from outer env.\nunset RUSTFLAGS || true\nunset CARGO_ENCODED_RUSTFLAGS || true\nunset RUSTDOCFLAGS || true\n\necho \"Building wasm (web feature set)...\"\n\"${cargo_cmd}\" build --target wasm32-unknown-unknown --release --no-default-features --features web\n\nwasm_path=\"./target/wasm32-unknown-unknown/release/bevy_gaussian_splatting.wasm\"\nif [[ ! -f \"${wasm_path}\" ]]; then\n  echo \"wasm output not found at ${wasm_path}\" >&2\n  exit 1\nfi\n\necho \"Generating wasm bindings...\"\n\"${wasm_bindgen_cmd}\" --out-dir ./www/out --target web \"${wasm_path}\"\n\nscene_cache_dir=\"./assets/.thumbnail_cache\"\nmkdir -p \"${scene_cache_dir}\"\n\nif command -v curl >/dev/null 2>&1; then\n  remote_scene_list=\"$(\n    grep -Eo '\"(thumbnail_input_scene|input_scene)\"[[:space:]]*:[[:space:]]*\"https?://[^\"]+\"' ./www/examples/examples.json \\\n      | sed -E 's/.*\"(https?:\\/\\/[^\"]+)\".*/\\1/' \\\n      | sort -u || true\n  )\"\n\n  if [[ -n \"${remote_scene_list}\" ]]; then\n    echo \"Caching remote scenes for thumbnails...\"\n    while IFS= read -r scene_url; do\n      if [[ -z \"${scene_url}\" ]]; then\n        continue\n      fi\n\n      url_without_query=\"${scene_url%%\\?*}\"\n      scene_file=\"${url_without_query##*/}\"\n      if [[ -z \"${scene_file}\" ]]; then\n        continue\n      fi\n\n      scene_path=\"${scene_cache_dir}/${scene_file}\"\n      echo \"  ${scene_url} -> ${scene_path}\"\n      curl \\\n        --fail \\\n        --location \\\n        --retry 4 \\\n        --retry-delay 2 \\\n        --connect-timeout 10 \\\n        --max-time 120 \\\n        \"${scene_url}\" \\\n        --output \"${scene_path}\"\n    done <<< \"${remote_scene_list}\"\n\n    export THUMBNAIL_SCENE_CACHE_DIR=\"${scene_cache_dir}\"\n    export THUMBNAIL_SCENE_CACHE_STRICT=\"1\"\n  fi\nfi\n\necho \"Rendering example thumbnails from manifest...\"\nRENDER_EXAMPLE_THUMBNAILS=1 THUMBNAIL_SORT_MODE=std \"${cargo_cmd}\" test --test headless_examples render_example_thumbnails -- --nocapture\n\nif [[ \"${THUMBNAIL_SCENE_CACHE_CLEANUP:-0}\" == \"1\" ]]; then\n  echo \"Cleaning thumbnail scene cache...\"\n  rm -rf \"${scene_cache_dir}\"\nfi\n\necho \"www build complete.\"\n"
  },
  {
    "path": "tools/compare_aabb_obb.rs",
    "content": "use bevy::{app::AppExit, core_pipeline::tonemapping::Tonemapping, prelude::*};\nuse bevy_args::{BevyArgsPlugin, parse_args};\nuse bevy_inspector_egui::{bevy_egui::EguiPlugin, quick::WorldInspectorPlugin};\nuse bevy_interleave::prelude::Planar;\nuse bevy_panorbit_camera::{PanOrbitCamera, PanOrbitCameraPlugin};\n\nuse bevy_gaussian_splatting::{\n    CloudSettings, Gaussian3d, GaussianCamera, GaussianSplattingPlugin, PlanarGaussian3d,\n    PlanarGaussian3dHandle, SphericalHarmonicCoefficients,\n    utils::{GaussianSplattingViewer, setup_hooks},\n};\n\npub fn setup_aabb_obb_compare(\n    mut commands: Commands,\n    mut gaussian_assets: ResMut<Assets<PlanarGaussian3d>>,\n) {\n    let mut blue_sh = SphericalHarmonicCoefficients::default();\n    blue_sh.set(2, 5.0);\n\n    let blue_aabb_gaussian = Gaussian3d {\n        position_visibility: [0.0, 0.0, 0.0, 1.0].into(),\n        rotation: [0.89, 0.0, -0.432, 0.144].into(),\n        scale_opacity: [10.0, 1.0, 1.0, 0.5].into(),\n        spherical_harmonic: blue_sh,\n    };\n\n    commands.spawn((\n        PlanarGaussian3dHandle(gaussian_assets.add(PlanarGaussian3d::from_interleaved(vec![\n            blue_aabb_gaussian,\n            blue_aabb_gaussian,\n        ]))),\n        CloudSettings {\n            aabb: true,\n            visualize_bounding_box: true,\n            ..default()\n        },\n        Name::new(\"gaussian_cloud_aabb\"),\n    ));\n\n    let mut red_sh = SphericalHarmonicCoefficients::default();\n    red_sh.set(0, 5.0);\n\n    let red_obb_gaussian = Gaussian3d {\n        position_visibility: [0.0, 0.0, 0.0, 1.0].into(),\n        rotation: [0.89, 0.0, -0.432, 0.144].into(),\n        scale_opacity: [10.0, 1.0, 1.0, 0.5].into(),\n        spherical_harmonic: red_sh,\n    };\n\n    commands.spawn((\n        PlanarGaussian3dHandle(gaussian_assets.add(PlanarGaussian3d::from_interleaved(vec![\n            red_obb_gaussian,\n            red_obb_gaussian,\n        ]))),\n        CloudSettings {\n            aabb: false,\n            visualize_bounding_box: true,\n            ..default()\n        },\n        Name::new(\"gaussian_cloud_obb\"),\n    ));\n\n    commands.spawn((\n        Camera3d::default(),\n        Transform::from_translation(Vec3::new(0.0, 1.5, 5.0)),\n        Tonemapping::None,\n        PanOrbitCamera {\n            allow_upside_down: true,\n            ..default()\n        },\n        GaussianCamera::default(),\n    ));\n}\n\nfn compare_aabb_obb_app() {\n    let config = parse_args::<GaussianSplattingViewer>();\n    let mut app = App::new();\n\n    // setup for gaussian viewer app\n    app.insert_resource(ClearColor(Color::srgb_u8(0, 0, 0)));\n    app.add_plugins(\n        DefaultPlugins\n            .set(ImagePlugin::default_nearest())\n            .set(WindowPlugin {\n                primary_window: Some(Window {\n                    mode: bevy::window::WindowMode::Windowed,\n                    present_mode: bevy::window::PresentMode::AutoVsync,\n                    prevent_default_event_handling: false,\n                    resolution: bevy::window::WindowResolution::new(\n                        config.width as u32,\n                        config.height as u32,\n                    ),\n                    title: config.name.clone(),\n                    ..default()\n                }),\n                ..default()\n            }),\n    );\n    app.add_plugins(BevyArgsPlugin::<GaussianSplattingViewer>::default());\n    app.add_plugins(PanOrbitCameraPlugin);\n\n    if config.editor {\n        app.add_plugins(EguiPlugin::default());\n        app.add_plugins(WorldInspectorPlugin::new());\n    }\n\n    if config.press_esc_close {\n        app.add_systems(Update, esc_close);\n    }\n\n    // setup for gaussian splatting\n    app.add_plugins(GaussianSplattingPlugin);\n    app.add_systems(Startup, setup_aabb_obb_compare);\n\n    app.run();\n}\n\npub fn esc_close(keys: Res<ButtonInput<KeyCode>>, mut exit: MessageWriter<AppExit>) {\n    if keys.just_pressed(KeyCode::Escape) {\n        exit.write(AppExit::Success);\n    }\n}\n\npub fn main() {\n    setup_hooks();\n    compare_aabb_obb_app();\n}\n"
  },
  {
    "path": "tools/ply_to_gcloud.rs",
    "content": "use byte_unit::{Byte, UnitType};\n\nuse bevy_gaussian_splatting::io::{codec::CloudCodec, ply::parse_ply_3d};\n\n#[cfg(feature = \"query_sparse\")]\nuse bevy_gaussian_splatting::query::sparse::SparseSelect;\n#[cfg(feature = \"query_sparse\")]\nuse bevy_interleave::prelude::Planar;\n#[cfg(feature = \"query_sparse\")]\nuse std::collections::HashSet;\n\n#[allow(dead_code)]\nfn is_point_in_transformed_sphere(pos: &[f32; 3]) -> bool {\n    let inv_scale_x = 1.0 / 1.75;\n    let inv_scale_y = 1.0 / 1.75;\n    let inv_scale_z = 1.0 / 1.75;\n\n    let inv_trans_x = 1.7;\n    let inv_trans_y = -0.5;\n    let inv_trans_z = -3.8;\n\n    let transformed_x = (pos[0] + inv_trans_x) * inv_scale_x;\n    let transformed_y = (pos[1] + inv_trans_y) * inv_scale_y;\n    let transformed_z = (pos[2] + inv_trans_z) * inv_scale_z;\n\n    transformed_x.powi(2) + transformed_y.powi(2) + transformed_z.powi(2) <= 1.0\n}\n\n// TODO: add better argument parsing\n#[allow(unused_mut)]\nfn main() {\n    let filename = std::env::args().nth(1).expect(\"no filename given\");\n\n    println!(\"converting `{filename}` file to gcloud\");\n\n    let file = std::fs::File::open(&filename).expect(\"failed to open file\");\n    let mut reader = std::io::BufReader::new(file);\n\n    // TODO: support 4d gaussian -> .gc4d\n    let mut cloud = parse_ply_3d(&mut reader).expect(\"failed to parse ply file\");\n\n    // TODO: prioritize mesh selection over export filter\n    // println!(\"initial cloud size: {}\", cloud.len());\n    // cloud = (0..cloud.len())\n    //     .filter(|&idx| {\n    //         is_point_in_transformed_sphere(\n    //             cloud.position(idx),\n    //         )\n    //     })\n    //     .map(|idx| cloud.gaussian(idx))\n    //     .collect();\n    // println!(\"filtered position cloud size: {}\", cloud.len());\n\n    #[cfg(feature = \"query_sparse\")]\n    {\n        let sparse_selection = SparseSelect::default().select(&cloud).invert(cloud.len());\n        let keep = sparse_selection\n            .indicies\n            .into_iter()\n            .collect::<HashSet<_>>();\n\n        cloud = cloud\n            .iter()\n            .enumerate()\n            .filter_map(|(idx, gaussian)| keep.contains(&idx).then_some(gaussian))\n            .collect();\n        println!(\"sparsity filtered cloud size: {}\", cloud.len());\n    }\n\n    let base_filename = filename\n        .split('.')\n        .next()\n        .expect(\"no extension\")\n        .to_string();\n    let gcloud_filename = base_filename + \".gcloud\";\n\n    cloud.write_to_file(&gcloud_filename);\n\n    let post_encode_bytes = Byte::from_u64(\n        std::fs::metadata(&gcloud_filename)\n            .expect(\"failed to get metadata\")\n            .len(),\n    );\n    println!(\n        \"output file size: {}\",\n        post_encode_bytes.get_appropriate_unit(UnitType::Decimal)\n    );\n}\n"
  },
  {
    "path": "tools/render_trellis_thumbnails.rs",
    "content": "use std::{f32::consts::FRAC_PI_4, fs::File, io::BufReader, path::Path};\n\nuse bevy::{\n    asset::RenderAssetUsages,\n    image::Image,\n    math::Vec3,\n    prelude::*,\n    render::render_resource::{Extent3d, TextureDimension, TextureFormat},\n};\nuse bevy_gaussian_splatting::{\n    PlanarGaussian3d, gaussian::interface::CommonCloud, io::ply::parse_ply_3d,\n};\nuse bevy_interleave::prelude::Planar;\n\nconst WIDTH: u32 = 960;\nconst HEIGHT: u32 = 540;\nconst INPUT_PLY: &str = \"assets/trellis.ply\";\n\n#[derive(Clone, Copy)]\nenum ThumbnailMode {\n    Position,\n    Depth,\n    Normal,\n}\n\n#[derive(Clone, Copy)]\nstruct ProjectedPoint {\n    x: u32,\n    y: u32,\n    depth: f32,\n    position: Vec3,\n}\n\nfn main() {\n    let cloud = load_cloud(INPUT_PLY);\n    let (projected, center, min, max, depth_min, depth_max) = project_points(&cloud, WIDTH, HEIGHT);\n\n    render_and_save(\n        \"www/examples/thumbnails/seeded-scoop.png\",\n        ThumbnailMode::Position,\n        &projected,\n        center,\n        min,\n        max,\n        depth_min,\n        depth_max,\n    );\n    render_and_save(\n        \"www/examples/thumbnails/seeded-depth.png\",\n        ThumbnailMode::Depth,\n        &projected,\n        center,\n        min,\n        max,\n        depth_min,\n        depth_max,\n    );\n    render_and_save(\n        \"www/examples/thumbnails/seeded-normal.png\",\n        ThumbnailMode::Normal,\n        &projected,\n        center,\n        min,\n        max,\n        depth_min,\n        depth_max,\n    );\n}\n\nfn load_cloud(path: &str) -> PlanarGaussian3d {\n    let file = File::open(path)\n        .unwrap_or_else(|err| panic!(\"failed to open thumbnail input {path}: {err}\"));\n    let mut reader = BufReader::new(file);\n    parse_ply_3d(&mut reader)\n        .unwrap_or_else(|err| panic!(\"failed to parse thumbnail input {path}: {err}\"))\n}\n\nfn project_points(\n    cloud: &PlanarGaussian3d,\n    width: u32,\n    height: u32,\n) -> (Vec<ProjectedPoint>, Vec3, Vec3, Vec3, f32, f32) {\n    let aabb = cloud\n        .compute_aabb()\n        .unwrap_or_else(|| panic!(\"cloud has no AABB for thumbnail projection\"));\n    let min = Vec3::from(aabb.min);\n    let max = Vec3::from(aabb.max);\n    let center = (min + max) * 0.5;\n    let half_extents = (max - min) * 0.5;\n\n    let aspect = width as f32 / height as f32;\n    let tan_half_fov_y = (FRAC_PI_4 * 0.5).tan();\n    let tan_half_fov_x = tan_half_fov_y * aspect;\n    let radius = half_extents.length().max(0.1);\n    let distance = (radius / tan_half_fov_y.min(tan_half_fov_x).max(1e-4)) * 1.15;\n\n    let camera_dir = Vec3::new(0.6, 0.4, 1.0).normalize_or_zero();\n    let camera_pos = center + camera_dir * distance;\n    let forward = (center - camera_pos).normalize_or_zero();\n    let right = forward.cross(Vec3::Y).normalize_or_zero();\n    let up = right.cross(forward).normalize_or_zero();\n\n    let mut projected = Vec::with_capacity(cloud.len().min(600_000));\n    let mut depth_min = f32::INFINITY;\n    let mut depth_max = f32::NEG_INFINITY;\n\n    for gaussian in cloud.iter() {\n        if gaussian.position_visibility.visibility <= 0.0 || gaussian.scale_opacity.opacity <= 0.001\n        {\n            continue;\n        }\n\n        let position = Vec3::from(gaussian.position_visibility.position);\n        let rel = position - camera_pos;\n\n        let z = rel.dot(forward);\n        if z <= 1e-4 {\n            continue;\n        }\n\n        let x = rel.dot(right);\n        let y = rel.dot(up);\n\n        let ndc_x = x / (z * tan_half_fov_x.max(1e-4));\n        let ndc_y = y / (z * tan_half_fov_y.max(1e-4));\n        if ndc_x.abs() > 1.2 || ndc_y.abs() > 1.2 {\n            continue;\n        }\n\n        let px = ((ndc_x * 0.5 + 0.5) * (width.saturating_sub(1)) as f32).round() as i32;\n        let py = ((1.0 - (ndc_y * 0.5 + 0.5)) * (height.saturating_sub(1)) as f32).round() as i32;\n        if px < 0 || py < 0 || px >= width as i32 || py >= height as i32 {\n            continue;\n        }\n\n        depth_min = depth_min.min(z);\n        depth_max = depth_max.max(z);\n        projected.push(ProjectedPoint {\n            x: px as u32,\n            y: py as u32,\n            depth: z,\n            position,\n        });\n    }\n\n    if projected.is_empty() {\n        panic!(\"no points projected for thumbnail rendering\");\n    }\n\n    (projected, center, min, max, depth_min, depth_max)\n}\n\n#[allow(clippy::too_many_arguments)]\nfn render_and_save(\n    output_path: &str,\n    mode: ThumbnailMode,\n    projected: &[ProjectedPoint],\n    center: Vec3,\n    min: Vec3,\n    max: Vec3,\n    depth_min: f32,\n    depth_max: f32,\n) {\n    let background = [10u8, 12u8, 16u8, 255u8];\n    let mut pixels = vec![0u8; (WIDTH * HEIGHT * 4) as usize];\n    for px in pixels.chunks_exact_mut(4) {\n        px.copy_from_slice(&background);\n    }\n\n    let mut z_buffer = vec![f32::INFINITY; (WIDTH * HEIGHT) as usize];\n    let extent = (max - min).max(Vec3::splat(1e-5));\n    let depth_extent = (depth_max - depth_min).max(1e-5);\n\n    for point in projected {\n        let idx = (point.y * WIDTH + point.x) as usize;\n        if point.depth >= z_buffer[idx] {\n            continue;\n        }\n        z_buffer[idx] = point.depth;\n\n        let color = match mode {\n            ThumbnailMode::Position => {\n                ((point.position - min) / extent).clamp(Vec3::ZERO, Vec3::ONE)\n            }\n            ThumbnailMode::Depth => {\n                let t = ((point.depth - depth_min) / depth_extent).clamp(0.0, 1.0);\n                Vec3::new(1.0 - t, (1.0 - (2.0 * (t - 0.5).abs())).max(0.0), t)\n            }\n            ThumbnailMode::Normal => {\n                let n = (point.position - center).normalize_or_zero();\n                n * 0.5 + Vec3::splat(0.5)\n            }\n        };\n\n        let out = &mut pixels[idx * 4..idx * 4 + 4];\n        out[0] = (color.x.clamp(0.0, 1.0) * 255.0).round() as u8;\n        out[1] = (color.y.clamp(0.0, 1.0) * 255.0).round() as u8;\n        out[2] = (color.z.clamp(0.0, 1.0) * 255.0).round() as u8;\n        out[3] = 255;\n    }\n\n    let non_background = pixels\n        .chunks_exact(4)\n        .filter(|px| px[0] != background[0] || px[1] != background[1] || px[2] != background[2])\n        .count();\n    if non_background == 0 {\n        panic!(\"rendered thumbnail is empty for {output_path}\");\n    }\n\n    if let Some(parent) = Path::new(output_path).parent() {\n        std::fs::create_dir_all(parent).expect(\"failed to create thumbnail directory\");\n    }\n\n    let image = Image::new(\n        Extent3d {\n            width: WIDTH,\n            height: HEIGHT,\n            depth_or_array_layers: 1,\n        },\n        TextureDimension::D2,\n        pixels,\n        TextureFormat::Rgba8UnormSrgb,\n        RenderAssetUsages::default(),\n    );\n    let dynamic = image\n        .try_into_dynamic()\n        .expect(\"failed to convert generated thumbnail image\");\n    dynamic\n        .save(output_path)\n        .unwrap_or_else(|err| panic!(\"failed to save thumbnail {output_path}: {err}\"));\n}\n"
  },
  {
    "path": "tools/surfel_plane.rs",
    "content": "use bevy::{app::AppExit, core_pipeline::tonemapping::Tonemapping, prelude::*};\nuse bevy_args::{BevyArgsPlugin, parse_args};\nuse bevy_inspector_egui::{bevy_egui::EguiPlugin, quick::WorldInspectorPlugin};\nuse bevy_interleave::prelude::Planar;\nuse bevy_panorbit_camera::{PanOrbitCamera, PanOrbitCameraPlugin};\n\nuse bevy_gaussian_splatting::{\n    CloudSettings, Gaussian3d, GaussianCamera, GaussianMode, GaussianSplattingPlugin,\n    PlanarGaussian3d, PlanarGaussian3dHandle, SphericalHarmonicCoefficients,\n    gaussian::f32::Rotation,\n    utils::{GaussianSplattingViewer, setup_hooks},\n};\n\npub fn setup_surfel_compare(\n    mut commands: Commands,\n    mut gaussian_assets: ResMut<Assets<PlanarGaussian3d>>,\n) {\n    let grid_size_x = 10;\n    let grid_size_y = 10;\n    let spacing = 12.0;\n    let visualize_bounding_box = false;\n\n    let mut blue_gaussians = Vec::new();\n    let mut blue_sh = SphericalHarmonicCoefficients::default();\n    blue_sh.set(2, 5.0);\n\n    for i in 0..grid_size_x {\n        for j in 0..grid_size_y {\n            let x = i as f32 * spacing - (grid_size_x as f32 * spacing) / 2.0;\n            let y = j as f32 * spacing - (grid_size_y as f32 * spacing) / 2.0;\n            let position = [x, y, 0.0, 1.0];\n            let scale = [2.0, 1.0, 0.01, 0.5];\n\n            let angle = std::f32::consts::PI / 2.0 * i as f32 / grid_size_x as f32;\n            let rotation = Quat::from_rotation_z(angle).to_array();\n            let rotation = [3usize, 0usize, 1usize, 2usize]\n                .iter()\n                .map(|i| rotation[*i])\n                .collect::<Vec<_>>()\n                .try_into()\n                .unwrap();\n\n            let gaussian = Gaussian3d {\n                position_visibility: position.into(),\n                rotation: Rotation { rotation },\n                scale_opacity: scale.into(),\n                spherical_harmonic: blue_sh,\n            };\n            blue_gaussians.push(gaussian);\n        }\n    }\n\n    let cloud = gaussian_assets.add(PlanarGaussian3d::from_interleaved(blue_gaussians));\n    commands.spawn((\n        PlanarGaussian3dHandle(cloud),\n        CloudSettings {\n            visualize_bounding_box,\n            ..default()\n        },\n        Name::new(\"gaussian_cloud_3dgs\"),\n    ));\n\n    let mut red_gaussians = Vec::new();\n    let mut red_sh = SphericalHarmonicCoefficients::default();\n    red_sh.set(0, 5.0);\n\n    for i in 0..grid_size_x {\n        for j in 0..grid_size_y {\n            let x = i as f32 * spacing - (grid_size_x as f32 * spacing) / 2.0;\n            let y = j as f32 * spacing - (grid_size_y as f32 * spacing) / 2.0;\n            let position = [x, y, 0.0, 1.0];\n            let scale = [2.0, 1.0, 0.01, 0.5];\n\n            let angle = std::f32::consts::PI / 2.0 * (i + 1) as f32 / grid_size_x as f32;\n            let rotation = Quat::from_rotation_z(angle).to_array();\n            let rotation = [3usize, 0usize, 1usize, 2usize]\n                .iter()\n                .map(|i| rotation[*i])\n                .collect::<Vec<_>>()\n                .try_into()\n                .unwrap();\n\n            let gaussian = Gaussian3d {\n                position_visibility: position.into(),\n                rotation: Rotation { rotation },\n                scale_opacity: scale.into(),\n                spherical_harmonic: red_sh,\n            };\n            red_gaussians.push(gaussian);\n        }\n    }\n\n    let cloud = gaussian_assets.add(PlanarGaussian3d::from_interleaved(red_gaussians));\n    commands.spawn((\n        Transform::from_translation(Vec3::new(spacing, spacing, 0.0)),\n        PlanarGaussian3dHandle(cloud),\n        CloudSettings {\n            visualize_bounding_box,\n            aabb: true,\n            gaussian_mode: GaussianMode::Gaussian2d,\n            ..default()\n        },\n        Name::new(\"gaussian_cloud_2dgs\"),\n    ));\n\n    let camera_transform = Transform::from_translation(Vec3::new(0.0, 1.5, 20.0));\n    info!(\n        \"camera_transform: {:?}\",\n        camera_transform.to_matrix().to_cols_array_2d()\n    );\n\n    commands.spawn((\n        camera_transform,\n        Camera3d::default(),\n        Tonemapping::None,\n        PanOrbitCamera {\n            allow_upside_down: true,\n            ..default()\n        },\n        GaussianCamera::default(),\n    ));\n}\n\nfn compare_surfel_app() {\n    let config = parse_args::<GaussianSplattingViewer>();\n    let mut app = App::new();\n\n    // setup for gaussian viewer app\n    app.insert_resource(ClearColor(Color::srgb_u8(0, 0, 0)));\n    app.add_plugins(\n        DefaultPlugins\n            .set(ImagePlugin::default_nearest())\n            .set(WindowPlugin {\n                primary_window: Some(Window {\n                    mode: bevy::window::WindowMode::Windowed,\n                    present_mode: bevy::window::PresentMode::AutoVsync,\n                    prevent_default_event_handling: false,\n                    resolution: bevy::window::WindowResolution::new(\n                        config.width as u32,\n                        config.height as u32,\n                    ),\n                    title: config.name.clone(),\n                    ..default()\n                }),\n                ..default()\n            }),\n    );\n    app.add_plugins(BevyArgsPlugin::<GaussianSplattingViewer>::default());\n    app.add_plugins(PanOrbitCameraPlugin);\n\n    if config.editor {\n        app.add_plugins(EguiPlugin::default());\n        app.add_plugins(WorldInspectorPlugin::new());\n    }\n\n    if config.press_esc_close {\n        app.add_systems(Update, esc_close);\n    }\n\n    // setup for gaussian splatting\n    app.add_plugins(GaussianSplattingPlugin);\n    app.add_systems(Startup, setup_surfel_compare);\n\n    app.run();\n}\n\npub fn esc_close(keys: Res<ButtonInput<KeyCode>>, mut exit: MessageWriter<AppExit>) {\n    if keys.just_pressed(KeyCode::Escape) {\n        exit.write(AppExit::Success);\n    }\n}\n\npub fn main() {\n    setup_hooks();\n    compare_surfel_app();\n}\n"
  },
  {
    "path": "viewer/viewer.rs",
    "content": "// TODO: move to editor crate\nuse std::path::PathBuf;\n\nuse bevy::{\n    app::AppExit,\n    camera::primitives::Aabb,\n    color::palettes::css::GOLD,\n    core_pipeline::{prepass::MotionVectorPrepass, tonemapping::Tonemapping},\n    diagnostic::{DiagnosticsStore, FrameCount, FrameTimeDiagnosticsPlugin},\n    gizmos::config::GizmoConfigStore,\n    prelude::*,\n    render::view::screenshot::{Screenshot, save_to_disk},\n};\n\n#[cfg(all(feature = \"file_asset\", not(target_arch = \"wasm32\")))]\nuse bevy::asset::{\n    AssetApp,\n    io::{AssetSourceBuilder, file::FileAssetReader},\n};\n\n#[cfg(feature = \"web_asset\")]\nuse bevy::asset::io::web::WebAssetPlugin;\nuse bevy_args::{BevyArgsPlugin, parse_args};\nuse bevy_inspector_egui::{\n    DefaultInspectorConfigPlugin, bevy_egui::EguiPlugin, quick::WorldInspectorPlugin,\n};\nuse bevy_panorbit_camera::{PanOrbitCamera, PanOrbitCameraPlugin};\n\n#[cfg(feature = \"web_asset\")]\nuse base64::{Engine as _, engine::general_purpose::URL_SAFE};\nuse bevy_gaussian_splatting::{\n    CloudSettings, GaussianCamera, GaussianMode, GaussianPrimitiveMetadata, GaussianScene,\n    GaussianSceneHandle, GaussianSplattingPlugin, PlanarGaussian3d, PlanarGaussian3dHandle,\n    PlanarGaussian4d, PlanarGaussian4dHandle,\n    gaussian::interface::TestCloud,\n    io::scene::GaussianSceneLoaded,\n    random_gaussians_3d, random_gaussians_3d_seeded, random_gaussians_4d,\n    random_gaussians_4d_seeded,\n    utils::{GaussianSplattingViewer, log, setup_hooks},\n};\n\n#[cfg(not(target_arch = \"wasm32\"))]\nuse bevy_gaussian_splatting::{SceneExportCamera, SceneExportCloud, write_khr_gaussian_scene_glb};\n\n#[cfg(feature = \"morph_interpolate\")]\nuse bevy_gaussian_splatting::{Gaussian3d, morph::interpolate::GaussianInterpolate};\n\n#[cfg(feature = \"material_noise\")]\nuse bevy_gaussian_splatting::material::noise::NoiseMaterial;\n\n#[cfg(feature = \"morph_particles\")]\nuse bevy_gaussian_splatting::morph::particle::{\n    ParticleBehaviors, ParticleBehaviorsHandle, random_particle_behaviors,\n};\n\n#[cfg(feature = \"query_select\")]\nuse bevy_gaussian_splatting::query::select::{InvertSelectionEvent, SaveSelectionEvent};\n\n#[cfg(feature = \"query_sparse\")]\nuse bevy_gaussian_splatting::query::sparse::SparseSelect;\n\n#[derive(Component, Debug, Default)]\nstruct ViewerMainCamera;\n\n#[derive(Component, Debug, Default)]\nstruct SceneCameraApplied;\n\n#[derive(Component, Debug, Default)]\nstruct SceneRenderModeApplied;\n\n#[cfg(not(target_arch = \"wasm32\"))]\ntype ExportCloudQuery = (\n    &'static PlanarGaussian3dHandle,\n    &'static GlobalTransform,\n    Option<&'static Name>,\n    Option<&'static CloudSettings>,\n    Option<&'static GaussianPrimitiveMetadata>,\n);\n\n#[cfg(not(target_arch = \"wasm32\"))]\ntype ExportCameraQuery = (&'static GlobalTransform, Option<&'static Name>);\ntype SceneCameraApplyQuery = (Entity, &'static mut Transform, &'static mut PanOrbitCamera);\ntype SceneRenderModeQuery = (Entity, &'static Children);\ntype SceneRenderModeFilter = (With<GaussianSceneLoaded>, Without<SceneRenderModeApplied>);\n\nfn parse_input_file(input_file: &str) -> String {\n    #[cfg(feature = \"web_asset\")]\n    let input_uri = match URL_SAFE.decode(input_file.as_bytes()) {\n        Ok(data) => match String::from_utf8(data) {\n            Ok(decoded) => decoded,\n            Err(_) => input_file.to_string(),\n        },\n        Err(err) => {\n            if let Some(decoded) = decode_percent_encoded(input_file) {\n                return decoded;\n            }\n\n            // Leave as-is for regular relative paths and already-decoded URLs.\n            debug!(\"failed to decode base64 input: {:?}\", err);\n            input_file.to_string()\n        }\n    };\n\n    #[cfg(not(feature = \"web_asset\"))]\n    let input_uri = input_file.to_string();\n\n    input_uri\n}\n\n#[cfg(feature = \"web_asset\")]\nfn decode_percent_encoded(value: &str) -> Option<String> {\n    let bytes = value.as_bytes();\n    let mut decoded = Vec::with_capacity(bytes.len());\n    let mut index = 0usize;\n    let mut changed = false;\n\n    while index < bytes.len() {\n        if bytes[index] == b'%' {\n            if index + 2 >= bytes.len() {\n                return None;\n            }\n\n            let high = decode_hex(bytes[index + 1])?;\n            let low = decode_hex(bytes[index + 2])?;\n            decoded.push((high << 4) | low);\n            index += 3;\n            changed = true;\n            continue;\n        }\n\n        decoded.push(bytes[index]);\n        index += 1;\n    }\n\n    if !changed {\n        return None;\n    }\n\n    String::from_utf8(decoded).ok()\n}\n\n#[cfg(feature = \"web_asset\")]\nfn decode_hex(value: u8) -> Option<u8> {\n    match value {\n        b'0'..=b'9' => Some(value - b'0'),\n        b'a'..=b'f' => Some(value - b'a' + 10),\n        b'A'..=b'F' => Some(value - b'A' + 10),\n        _ => None,\n    }\n}\n\nfn setup_gaussian_cloud(\n    mut commands: Commands,\n    args: Res<GaussianSplattingViewer>,\n    asset_server: Res<AssetServer>,\n    mut gaussian_3d_assets: ResMut<Assets<PlanarGaussian3d>>,\n    mut gaussian_4d_assets: ResMut<Assets<PlanarGaussian4d>>,\n) {\n    debug!(\"spawning camera...\");\n    let cloud_transform = args.cloud_transform();\n    commands\n        .spawn(Camera3d::default())\n        .insert(Transform::from_translation(Vec3::new(0.0, 1.5, 5.0)))\n        .insert(Tonemapping::None)\n        .insert(MotionVectorPrepass)\n        .insert(PanOrbitCamera {\n            allow_upside_down: true,\n            orbit_smoothness: 0.1,\n            pan_smoothness: 0.1,\n            zoom_smoothness: 0.1,\n            ..default()\n        })\n        .insert(ViewerMainCamera)\n        .insert(GaussianCamera::default());\n\n    if let Some(input_scene) = &args.input_scene {\n        let input_uri = parse_input_file(input_scene.as_str());\n        log(&format!(\"loading {input_uri}\"));\n        let scene: Handle<GaussianScene> = asset_server.load(&input_uri);\n        commands.spawn((\n            GaussianSceneHandle(scene),\n            Name::new(\"gaussian_scene\"),\n            cloud_transform,\n        ));\n        return;\n    }\n\n    match args.gaussian_mode {\n        GaussianMode::Gaussian2d | GaussianMode::Gaussian3d => {\n            let cloud: Handle<PlanarGaussian3d>;\n            if args.gaussian_count > 0 {\n                log(&format!(\"generating {} gaussians\", args.gaussian_count));\n                cloud = if let Some(seed) = args.gaussian_seed {\n                    gaussian_3d_assets.add(random_gaussians_3d_seeded(args.gaussian_count, seed))\n                } else {\n                    gaussian_3d_assets.add(random_gaussians_3d(args.gaussian_count))\n                };\n            } else if let Some(input_cloud) = &args.input_cloud {\n                let input_uri = parse_input_file(input_cloud.as_str());\n                log(&format!(\"loading {input_uri}\"));\n                cloud = asset_server.load(&input_uri);\n            } else {\n                cloud = gaussian_3d_assets.add(PlanarGaussian3d::test_model());\n            }\n\n            #[cfg(feature = \"morph_interpolate\")]\n            {\n                if let Some(input_cloud_target) = &args.input_cloud_target {\n                    let input_uri = parse_input_file(input_cloud_target.as_str());\n                    log(&format!(\"loading {input_uri}\"));\n                    let binary_cloud: Handle<PlanarGaussian3d> = asset_server.load(&input_uri);\n\n                    commands.spawn((\n                        CloudSettings {\n                            gaussian_mode: args.gaussian_mode,\n                            playback_mode: args.playback_mode,\n                            rasterize_mode: args.rasterization_mode,\n                            ..default()\n                        },\n                        GaussianInterpolate::<Gaussian3d> {\n                            lhs: PlanarGaussian3dHandle(cloud),\n                            rhs: PlanarGaussian3dHandle(binary_cloud),\n                        },\n                        Name::new(\"gaussian_cloud_3d_binary\"),\n                        ShowAxes,\n                        cloud_transform,\n                    ));\n                } else {\n                    commands.spawn((\n                        CloudSettings {\n                            gaussian_mode: args.gaussian_mode,\n                            playback_mode: args.playback_mode,\n                            rasterize_mode: args.rasterization_mode,\n                            ..default()\n                        },\n                        PlanarGaussian3dHandle(cloud.clone()),\n                        Name::new(\"gaussian_cloud_3d\"),\n                        ShowAxes,\n                        cloud_transform,\n                    ));\n                }\n            }\n\n            #[cfg(not(feature = \"morph_interpolate\"))]\n            {\n                commands.spawn((\n                    CloudSettings {\n                        gaussian_mode: args.gaussian_mode,\n                        playback_mode: args.playback_mode,\n                        rasterize_mode: args.rasterization_mode,\n                        ..default()\n                    },\n                    PlanarGaussian3dHandle(cloud.clone()),\n                    Name::new(\"gaussian_cloud_3d\"),\n                    ShowAxes,\n                    cloud_transform,\n                ));\n            }\n        }\n        GaussianMode::Gaussian4d => {\n            let cloud: Handle<PlanarGaussian4d>;\n            if args.gaussian_count > 0 {\n                log(&format!(\"generating {} gaussians\", args.gaussian_count));\n                cloud = if let Some(seed) = args.gaussian_seed {\n                    gaussian_4d_assets.add(random_gaussians_4d_seeded(args.gaussian_count, seed))\n                } else {\n                    gaussian_4d_assets.add(random_gaussians_4d(args.gaussian_count))\n                };\n            } else if let Some(input_cloud) = &args.input_cloud {\n                let input_uri = parse_input_file(input_cloud.as_str());\n                log(&format!(\"loading {input_uri}\"));\n                cloud = asset_server.load(&input_uri);\n            } else {\n                cloud = gaussian_4d_assets.add(PlanarGaussian4d::test_model());\n            }\n\n            commands.spawn((\n                PlanarGaussian4dHandle(cloud),\n                CloudSettings {\n                    gaussian_mode: args.gaussian_mode,\n                    playback_mode: args.playback_mode,\n                    rasterize_mode: args.rasterization_mode,\n                    ..default()\n                },\n                Name::new(\"gaussian_cloud_4d\"),\n                ShowAxes,\n                cloud_transform,\n            ));\n        }\n    }\n}\n\nfn apply_scene_camera_spawn(\n    mut commands: Commands,\n    scene_handles: Query<(Entity, &GaussianSceneHandle), Without<SceneCameraApplied>>,\n    asset_server: Res<AssetServer>,\n    scenes: Res<Assets<GaussianScene>>,\n    mut cameras: Query<SceneCameraApplyQuery, (With<GaussianCamera>, With<ViewerMainCamera>)>,\n) {\n    for (entity, scene_handle) in scene_handles.iter() {\n        if let Some(load_state) = asset_server.get_load_state(&scene_handle.0)\n            && !load_state.is_loaded()\n        {\n            continue;\n        }\n\n        let Some(scene) = scenes.get(&scene_handle.0) else {\n            continue;\n        };\n\n        if let Some(scene_camera) = scene.cameras.first()\n            && let Ok((camera_entity, mut camera_transform, mut pan_orbit_camera)) =\n                cameras.single_mut()\n        {\n            let orbit_radius = pan_orbit_camera\n                .target_radius\n                .max(pan_orbit_camera.zoom_lower_limit);\n            let scene_translation = scene_camera.transform.translation;\n            let scene_forward = scene_camera.transform.forward().as_vec3();\n            let world_up = pan_orbit_camera.axis[1];\n            let mut corrected_rotation = scene_camera.transform.rotation;\n\n            // Imported camera can legitimately be upside-down (roll ~= PI) which makes orbit input\n            // feel inverted. Flip it upright while keeping the same look direction.\n            if scene_camera.transform.up().dot(world_up) < 0.0 {\n                corrected_rotation =\n                    Quat::from_axis_angle(scene_forward, std::f32::consts::PI) * corrected_rotation;\n            }\n\n            let corrected_transform = Transform {\n                translation: scene_translation,\n                rotation: corrected_rotation,\n                scale: Vec3::ONE,\n            };\n            *camera_transform = corrected_transform;\n\n            let focus = scene_translation + camera_transform.forward() * orbit_radius;\n\n            let (yaw, pitch, radius) = orbit_from_translation_and_focus(\n                camera_transform.translation,\n                focus,\n                pan_orbit_camera.axis,\n            );\n\n            pan_orbit_camera.focus = focus;\n            pan_orbit_camera.target_focus = focus;\n            pan_orbit_camera.yaw = Some(yaw);\n            pan_orbit_camera.pitch = Some(pitch);\n            pan_orbit_camera.radius = Some(radius);\n            pan_orbit_camera.target_yaw = yaw;\n            pan_orbit_camera.target_pitch = pitch;\n            pan_orbit_camera.target_radius = radius;\n            pan_orbit_camera.allow_upside_down = false;\n            pan_orbit_camera.initialized = true;\n            pan_orbit_camera.force_update = true;\n            let _ = camera_entity;\n        }\n\n        commands.entity(entity).insert(SceneCameraApplied);\n    }\n}\n\nfn apply_scene_render_mode_override(\n    mut commands: Commands,\n    args: Res<GaussianSplattingViewer>,\n    scenes: Query<SceneRenderModeQuery, SceneRenderModeFilter>,\n    mut cloud_settings: Query<&mut CloudSettings>,\n) {\n    if args.input_scene.is_none() {\n        return;\n    }\n\n    for (entity, children) in scenes.iter() {\n        for child in children.iter() {\n            let child: Entity = child;\n            if let Ok(mut settings) = cloud_settings.get_mut(child) {\n                settings.rasterize_mode = args.rasterization_mode;\n            }\n        }\n\n        commands.entity(entity).insert(SceneRenderModeApplied);\n    }\n}\n\nfn orbit_from_translation_and_focus(\n    translation: Vec3,\n    focus: Vec3,\n    axis: [Vec3; 3],\n) -> (f32, f32, f32) {\n    let axis = Mat3::from_cols(axis[0], axis[1], axis[2]);\n    let offset = translation - focus;\n\n    // Radius of exactly zero creates unstable orbit behavior.\n    let mut radius = offset.length();\n    if radius <= f32::EPSILON {\n        radius = 0.05;\n    }\n\n    let offset = axis * offset;\n    let yaw = offset.x.atan2(offset.z);\n    let pitch = (offset.y / radius).asin();\n    (yaw, pitch, radius)\n}\n\n#[cfg(feature = \"morph_particles\")]\nfn setup_particle_behavior(\n    mut commands: Commands,\n    gaussian_splatting_viewer: Res<GaussianSplattingViewer>,\n    mut particle_behavior_assets: ResMut<Assets<ParticleBehaviors>>,\n    gaussian_cloud: Query<(Entity, &PlanarGaussian3dHandle), Without<ParticleBehaviorsHandle>>,\n) {\n    if gaussian_cloud.is_empty() {\n        return;\n    }\n\n    let mut particle_behaviors = None;\n    if gaussian_splatting_viewer.particle_count > 0 {\n        log(&format!(\n            \"generating {} particle behaviors\",\n            gaussian_splatting_viewer.particle_count\n        ));\n        particle_behaviors = particle_behavior_assets\n            .add(random_particle_behaviors(\n                gaussian_splatting_viewer.particle_count,\n            ))\n            .into();\n    }\n\n    if let Some(particle_behaviors) = particle_behaviors\n        && let Ok((entity, _)) = gaussian_cloud.single()\n    {\n        commands\n            .entity(entity)\n            .insert(ParticleBehaviorsHandle(particle_behaviors));\n    }\n}\n\n#[cfg(feature = \"material_noise\")]\nfn setup_noise_material(\n    mut commands: Commands,\n    asset_server: Res<AssetServer>,\n    gaussian_clouds: Query<(Entity, &PlanarGaussian3dHandle), Without<NoiseMaterial>>,\n) {\n    if gaussian_clouds.is_empty() {\n        return;\n    }\n\n    for (entity, cloud_handle) in gaussian_clouds.iter() {\n        if let Some(load_state) = asset_server.get_load_state(cloud_handle.0.id())\n            && load_state.is_loading()\n        {\n            continue;\n        }\n\n        commands.entity(entity).insert(NoiseMaterial::default());\n    }\n}\n\n#[cfg(feature = \"query_sparse\")]\nfn setup_sparse_select(\n    mut commands: Commands,\n    gaussian_cloud: Query<(Entity, &PlanarGaussian3dHandle), Without<SparseSelect>>,\n) {\n    if gaussian_cloud.is_empty() {\n        return;\n    }\n\n    if let Ok((entity, _)) = gaussian_cloud.single() {\n        commands.entity(entity).insert(SparseSelect {\n            completed: true,\n            ..default()\n        });\n    }\n}\n\nfn viewer_app() {\n    let config = parse_args::<GaussianSplattingViewer>();\n    log(&format!(\"{config:?}\"));\n\n    #[cfg(not(feature = \"morph_interpolate\"))]\n    if config.input_cloud_target.is_some() {\n        panic!(\"`--input-cloud-target` requires the `morph_interpolate` feature\");\n    }\n\n    let mut app = App::new();\n    app.register_type::<GizmoConfigStore>();\n\n    #[cfg(target_arch = \"wasm32\")]\n    let primary_window = Some(Window {\n        // fit_canvas_to_parent: true,\n        canvas: Some(\"#bevy\".to_string()),\n        mode: bevy::window::WindowMode::Windowed,\n        prevent_default_event_handling: true,\n        title: config.name.clone(),\n\n        #[cfg(feature = \"perftest\")]\n        present_mode: bevy::window::PresentMode::AutoNoVsync,\n        #[cfg(not(feature = \"perftest\"))]\n        present_mode: bevy::window::PresentMode::AutoVsync,\n\n        ..default()\n    });\n\n    #[cfg(not(target_arch = \"wasm32\"))]\n    let primary_window = Some(Window {\n        mode: bevy::window::WindowMode::Windowed,\n        prevent_default_event_handling: false,\n        resolution: bevy::window::WindowResolution::new(config.width as u32, config.height as u32),\n        title: config.name.clone(),\n\n        #[cfg(feature = \"perftest\")]\n        present_mode: bevy::window::PresentMode::AutoNoVsync,\n        #[cfg(not(feature = \"perftest\"))]\n        present_mode: bevy::window::PresentMode::AutoVsync,\n\n        ..default()\n    });\n\n    #[cfg(all(feature = \"file_asset\", not(target_arch = \"wasm32\")))]\n    app.register_asset_source(\n        \"file\",\n        AssetSourceBuilder::new(|| Box::new(FileAssetReader::new(\"\")))\n            .with_processed_reader(|| Box::new(FileAssetReader::new(\"\"))),\n    );\n\n    // setup for gaussian viewer app\n    app.insert_resource(ClearColor(Color::srgb_u8(0, 0, 0)));\n    let default_plugins = DefaultPlugins\n        .set(AssetPlugin {\n            meta_check: bevy::asset::AssetMetaCheck::Never,\n            unapproved_path_mode: bevy::asset::UnapprovedPathMode::Allow,\n            ..default()\n        })\n        .set(ImagePlugin::default_nearest())\n        .set(WindowPlugin {\n            primary_window,\n            ..default()\n        });\n\n    #[cfg(feature = \"web_asset\")]\n    let default_plugins = default_plugins.set(WebAssetPlugin {\n        silence_startup_warning: true,\n    });\n\n    app.add_plugins(default_plugins);\n    app.add_plugins(BevyArgsPlugin::<GaussianSplattingViewer>::default());\n    app.add_plugins(PanOrbitCameraPlugin);\n\n    if config.editor {\n        app.add_plugins(EguiPlugin::default());\n        app.add_plugins(DefaultInspectorConfigPlugin);\n        app.add_plugins(WorldInspectorPlugin::new());\n    }\n\n    if config.press_esc_close {\n        app.add_systems(Update, press_esc_close);\n    }\n\n    if config.press_s_screenshot {\n        app.add_systems(Update, press_s_screenshot);\n    }\n\n    if config.show_axes {\n        app.add_systems(Update, draw_axes);\n    }\n\n    if config.show_fps {\n        app.add_plugins(FrameTimeDiagnosticsPlugin::default());\n        app.add_systems(Startup, fps_display_setup);\n        app.add_systems(Update, fps_update_system);\n    }\n\n    // setup for gaussian splatting\n    app.add_plugins(GaussianSplattingPlugin);\n    app.add_systems(Startup, setup_gaussian_cloud);\n    app.add_systems(Update, apply_scene_camera_spawn);\n    app.add_systems(Update, apply_scene_render_mode_override);\n    app.add_systems(Update, press_g_save_gltf_scene);\n\n    #[cfg(feature = \"material_noise\")]\n    app.add_systems(Update, setup_noise_material);\n\n    #[cfg(feature = \"morph_particles\")]\n    app.add_systems(Update, setup_particle_behavior);\n\n    #[cfg(feature = \"query_select\")]\n    {\n        app.add_systems(Update, press_i_invert_selection);\n        app.add_systems(Update, press_o_save_selection);\n    }\n\n    #[cfg(feature = \"query_sparse\")]\n    app.add_systems(Update, setup_sparse_select);\n\n    app.run();\n}\n\npub fn press_s_screenshot(\n    mut commands: Commands,\n    keys: Res<ButtonInput<KeyCode>>,\n    current_frame: Res<FrameCount>,\n) {\n    if keys.just_pressed(KeyCode::KeyS) {\n        let images_dir = PathBuf::from(env!(\"CARGO_MANIFEST_DIR\")).join(\"screenshots\");\n        std::fs::create_dir_all(&images_dir).unwrap();\n        let output_path = images_dir.join(format!(\"output_{}.png\", current_frame.0));\n\n        commands\n            .spawn(Screenshot::primary_window())\n            .observe(save_to_disk(output_path));\n    }\n}\n\n#[cfg(not(target_arch = \"wasm32\"))]\nfn press_g_save_gltf_scene(\n    keys: Res<ButtonInput<KeyCode>>,\n    current_frame: Res<FrameCount>,\n    gaussian_cloud_assets: Res<Assets<PlanarGaussian3d>>,\n    gaussian_clouds: Query<ExportCloudQuery>,\n    cameras: Query<ExportCameraQuery, (With<GaussianCamera>, With<ViewerMainCamera>)>,\n) {\n    if !keys.just_pressed(KeyCode::KeyG) {\n        return;\n    }\n\n    let mut export_clouds = Vec::new();\n    for (index, (cloud_handle, global_transform, name, settings, metadata)) in\n        gaussian_clouds.iter().enumerate()\n    {\n        let Some(cloud) = gaussian_cloud_assets.get(&cloud_handle.0) else {\n            continue;\n        };\n\n        export_clouds.push(SceneExportCloud {\n            cloud: cloud.clone(),\n            name: name\n                .map(|value| value.as_str().to_owned())\n                .unwrap_or_else(|| format!(\"gaussian_cloud_{index}\")),\n            settings: settings.cloned().unwrap_or_default(),\n            transform: Transform::from_matrix(global_transform.to_matrix()),\n            metadata: metadata.cloned().unwrap_or_default(),\n        });\n    }\n\n    if export_clouds.is_empty() {\n        log(\"no gaussian clouds available to export\");\n        return;\n    }\n\n    let export_camera = cameras\n        .iter()\n        .next()\n        .map(|(global_transform, name)| SceneExportCamera {\n            name: name\n                .map(|value| value.as_str().to_owned())\n                .unwrap_or_else(|| \"viewer_camera\".to_owned()),\n            transform: Transform::from_matrix(global_transform.to_matrix()),\n            ..default()\n        });\n\n    let output_dir = PathBuf::from(env!(\"CARGO_MANIFEST_DIR\")).join(\"exports\");\n    if let Err(err) = std::fs::create_dir_all(&output_dir) {\n        log(&format!(\n            \"failed to create export directory '{}': {err}\",\n            output_dir.display()\n        ));\n        return;\n    }\n\n    let output_path = output_dir.join(format!(\"gaussian_scene_{}.glb\", current_frame.0));\n    match write_khr_gaussian_scene_glb(&output_path, &export_clouds, export_camera.as_ref()) {\n        Ok(()) => log(&format!(\n            \"saved gaussian scene to {}\",\n            output_path.display()\n        )),\n        Err(err) => log(&format!(\n            \"failed to save gaussian scene '{}': {err}\",\n            output_path.display()\n        )),\n    }\n}\n\n#[cfg(target_arch = \"wasm32\")]\nfn press_g_save_gltf_scene(keys: Res<ButtonInput<KeyCode>>) {\n    if keys.just_pressed(KeyCode::KeyG) {\n        log(\"GLB scene export is not supported on wasm32\");\n    }\n}\n\n#[derive(Component, Debug, Default, Reflect)]\npub struct ShowAxes;\n\nfn draw_axes(mut gizmos: Gizmos, query: Query<(&Transform, &Aabb), With<ShowAxes>>) {\n    for (&transform, aabb) in &query {\n        let length = aabb.half_extents.length();\n        gizmos.axes(transform, length);\n    }\n}\n\npub fn press_esc_close(keys: Res<ButtonInput<KeyCode>>, mut exit: MessageWriter<AppExit>) {\n    if keys.just_pressed(KeyCode::Escape) {\n        exit.write(AppExit::Success);\n    }\n}\n\n#[cfg(feature = \"query_select\")]\nfn press_i_invert_selection(\n    keys: Res<ButtonInput<KeyCode>>,\n    mut select_inverse_events: MessageWriter<InvertSelectionEvent>,\n) {\n    if keys.just_pressed(KeyCode::KeyI) {\n        log(\"inverting selection\");\n        select_inverse_events.write(InvertSelectionEvent);\n    }\n}\n\n#[cfg(feature = \"query_select\")]\nfn press_o_save_selection(\n    keys: Res<ButtonInput<KeyCode>>,\n    mut select_inverse_events: MessageWriter<SaveSelectionEvent>,\n) {\n    if keys.just_pressed(KeyCode::KeyO) {\n        log(\"saving selection\");\n        select_inverse_events.write(SaveSelectionEvent);\n    }\n}\n\nfn fps_display_setup(mut commands: Commands, asset_server: Res<AssetServer>) {\n    commands\n        .spawn((\n            Text(\"fps: \".to_string()),\n            TextFont {\n                font: asset_server.load(\"fonts/Caveat-Bold.ttf\"),\n                font_size: 60.0,\n                ..Default::default()\n            },\n            TextColor(Color::WHITE),\n            Node {\n                position_type: PositionType::Absolute,\n                bottom: Val::Px(5.0),\n                left: Val::Px(15.0),\n                ..default()\n            },\n            ZIndex(2),\n        ))\n        .with_child((\n            FpsText,\n            TextColor(Color::Srgba(GOLD)),\n            TextFont {\n                font: asset_server.load(\"fonts/Caveat-Bold.ttf\"),\n                font_size: 60.0,\n                ..Default::default()\n            },\n            TextSpan::default(),\n        ));\n}\n\n#[derive(Component)]\nstruct FpsText;\n\nfn fps_update_system(\n    diagnostics: Res<DiagnosticsStore>,\n    mut query: Query<&mut TextSpan, With<FpsText>>,\n) {\n    for mut text in &mut query {\n        if let Some(fps) = diagnostics.get(&FrameTimeDiagnosticsPlugin::FPS)\n            && let Some(value) = fps.smoothed()\n        {\n            **text = format!(\"{value:.2}\");\n        }\n    }\n}\n\n#[cfg(all(test, feature = \"web_asset\"))]\nmod tests {\n    use super::parse_input_file;\n\n    #[test]\n    fn decodes_percent_encoded_input_url() {\n        let encoded = \"https%3A%2F%2Fmitchell.mosure.me%2Ftrellis.glb\";\n        let decoded = parse_input_file(encoded);\n        assert_eq!(decoded, \"https://mitchell.mosure.me/trellis.glb\");\n    }\n\n    #[test]\n    fn keeps_plain_relative_path() {\n        let input = \"trellis.glb\";\n        let parsed = parse_input_file(input);\n        assert_eq!(parsed, \"trellis.glb\");\n    }\n}\n\npub fn main() {\n    setup_hooks();\n    viewer_app();\n}\n"
  },
  {
    "path": "www/README.md",
    "content": "# bevy_gaussian_splatting for web\n\n## wasm support\n\nto build wasm run:\n\n```bash\ncargo build --target wasm32-unknown-unknown --release --no-default-features --features \"web\"\n```\n\nto generate bindings:\n> `wasm-bindgen --out-dir ./www/out/ --target web ./target/wasm32-unknown-unknown/release/bevy_gaussian_splatting.wasm`\n\nto build the web output (wasm + bindings + thumbnails):\n> macOS/Linux/CI: `bash ./tools/build_www.sh`\n> Windows: `pwsh ./tools/build_www.ps1`\n\nexamples page:\n- `www/examples/index.html`\n- config manifest: `www/examples/examples.json`\n\nmanifest notes:\n- `args`: viewer query args\n- `input_scene` or `input_cloud`: scene/cloud opened when example card is clicked\n- `thumbnail_input_scene` or `thumbnail_input_cloud`: optional thumbnail capture override\n\nthe build script renders thumbnails via:\n> `RENDER_EXAMPLE_THUMBNAILS=1 cargo test --test headless_examples render_example_thumbnails -- --nocapture`\n\nopen a live server for `www/index.html` and append args, for example:\n> `?input_scene=https%3A%2F%2Fmitchell.mosure.me%2Ftrellis.glb&rasterization_mode=Color`\n"
  },
  {
    "path": "www/examples/examples.css",
    "content": "@import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;600&display=swap');\n\n:root {\n  --bg-0: #070c14;\n  --bg-1: #0d1420;\n  --bg-2: #151f30;\n  --panel: #121b2a;\n  --panel-2: #162335;\n  --card: #1b283c;\n  --card-hover: #22334b;\n  --ink: #ecf2fb;\n  --ink-soft: #9fafc7;\n  --accent: #66a9ff;\n  --accent-bright: #8cc2ff;\n  --accent-warm: #ff9966;\n  --stroke: rgba(164, 186, 219, 0.2);\n  --shadow: 0 20px 50px rgba(0, 0, 0, 0.45);\n  --radius: 18px;\n  --radius-sm: 12px;\n  --grid-gap: 24px;\n}\n\n* {\n  box-sizing: border-box;\n}\n\nhtml,\nbody {\n  min-height: 100vh;\n}\n\nbody {\n  margin: 0;\n  font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif;\n  color: var(--ink);\n  background:\n    radial-gradient(circle at 18% -6%, rgba(102, 169, 255, 0.2), transparent 34%),\n    radial-gradient(circle at 94% 4%, rgba(255, 153, 102, 0.14), transparent 35%),\n    linear-gradient(180deg, var(--bg-0) 0%, var(--bg-1) 48%, var(--bg-2) 100%);\n}\n\n.page {\n  max-width: 1220px;\n  margin: 0 auto;\n  padding: 36px 24px 64px;\n}\n\n.hero {\n  background:\n    linear-gradient(180deg, rgba(33, 49, 75, 0.7) 0%, rgba(18, 27, 42, 0.88) 100%),\n    linear-gradient(120deg, rgba(102, 169, 255, 0.11), rgba(255, 153, 102, 0.09));\n  border-radius: var(--radius);\n  padding: 32px;\n  box-shadow: var(--shadow);\n  border: 1px solid var(--stroke);\n}\n\n.hero-top {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  gap: 16px;\n}\n\n.eyebrow {\n  font-size: 12px;\n  letter-spacing: 0.3em;\n  text-transform: uppercase;\n  color: var(--ink-soft);\n  margin: 0;\n}\n\n.nav {\n  display: flex;\n  gap: 18px;\n  font-size: 13px;\n  text-transform: uppercase;\n  letter-spacing: 0.11em;\n}\n\n.nav a {\n  color: var(--ink-soft);\n  text-decoration: none;\n}\n\n.nav a:hover {\n  color: var(--accent-bright);\n}\n\n.hero-body {\n  display: flex;\n  justify-content: space-between;\n  align-items: flex-end;\n  gap: 24px;\n  margin-top: 24px;\n  flex-wrap: wrap;\n}\n\n.hero-text h1 {\n  font-size: clamp(28px, 4vw, 46px);\n  margin: 0 0 12px;\n  letter-spacing: 0.01em;\n}\n\n.subtitle {\n  max-width: 560px;\n  font-size: 16px;\n  color: var(--ink-soft);\n  margin: 0;\n  line-height: 1.5;\n}\n\n.hero-actions {\n  display: flex;\n  gap: 12px;\n  flex-wrap: wrap;\n}\n\n.button {\n  display: inline-flex;\n  align-items: center;\n  justify-content: center;\n  padding: 11px 18px;\n  border-radius: 999px;\n  background: linear-gradient(180deg, var(--accent) 0%, #4e95ef 100%);\n  color: #0a111b;\n  text-decoration: none;\n  font-weight: 700;\n  letter-spacing: 0.03em;\n  border: 1px solid rgba(140, 194, 255, 0.55);\n}\n\n.button.ghost {\n  background: rgba(29, 43, 65, 0.65);\n  color: var(--ink);\n  border: 1px solid var(--stroke);\n}\n\n.button:hover {\n  transform: translateY(-1px);\n}\n\n.toolbar {\n  display: flex;\n  justify-content: space-between;\n  align-items: center;\n  margin: 28px 0 18px;\n  gap: 16px;\n  flex-wrap: wrap;\n}\n\n.search {\n  display: grid;\n  gap: 8px;\n  font-size: 12px;\n  text-transform: uppercase;\n  letter-spacing: 0.2em;\n  color: var(--ink-soft);\n}\n\n.search input {\n  width: min(420px, 70vw);\n  border-radius: 999px;\n  border: 1px solid var(--stroke);\n  padding: 11px 16px;\n  font-size: 15px;\n  color: var(--ink);\n  font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif;\n  outline: none;\n  background: rgba(20, 31, 48, 0.95);\n}\n\n.search input::placeholder {\n  color: #8192ac;\n}\n\n.search input:focus {\n  border-color: rgba(140, 194, 255, 0.8);\n  box-shadow: 0 0 0 3px rgba(102, 169, 255, 0.22);\n}\n\n.meta {\n  font-family: 'Space Mono', monospace;\n  font-size: 13px;\n  color: var(--ink-soft);\n}\n\n.grid {\n  display: grid;\n  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));\n  gap: var(--grid-gap);\n}\n\n.card {\n  background: linear-gradient(180deg, rgba(32, 48, 72, 0.98), rgba(23, 34, 52, 0.98));\n  border-radius: var(--radius-sm);\n  overflow: hidden;\n  border: 1px solid rgba(144, 170, 208, 0.26);\n  box-shadow: 0 14px 34px rgba(0, 0, 0, 0.36);\n  transition:\n    transform 0.2s ease,\n    box-shadow 0.2s ease,\n    background-color 0.2s ease;\n}\n\n.card:hover {\n  transform: translateY(-4px);\n  box-shadow: 0 20px 44px rgba(0, 0, 0, 0.5);\n  background: linear-gradient(180deg, rgba(35, 53, 80, 0.98), rgba(27, 41, 61, 0.98));\n}\n\n.card-link {\n  text-decoration: none;\n  color: inherit;\n}\n\n.thumb {\n  aspect-ratio: 16 / 9;\n  background: linear-gradient(135deg, rgba(102, 169, 255, 0.12), rgba(255, 153, 102, 0.13));\n  display: flex;\n  align-items: center;\n  justify-content: center;\n  border-bottom: 1px solid rgba(144, 170, 208, 0.18);\n}\n\n.thumb img {\n  width: 100%;\n  height: 100%;\n  object-fit: cover;\n  display: block;\n}\n\n.card-body {\n  padding: 16px 18px 18px;\n}\n\n.card-header {\n  display: flex;\n  justify-content: space-between;\n  align-items: baseline;\n  gap: 12px;\n}\n\n.example-title {\n  margin: 0;\n  font-size: 18px;\n  color: #f1f6ff;\n}\n\n.card-id {\n  font-family: 'Space Mono', monospace;\n  font-size: 11px;\n  text-transform: uppercase;\n  letter-spacing: 0.12em;\n  color: #95a8c4;\n}\n\n.example-description {\n  margin: 12px 0 16px;\n  color: #a7b6cd;\n  line-height: 1.45;\n}\n\n.tag-row {\n  display: flex;\n  flex-wrap: wrap;\n  gap: 8px;\n}\n\n.tag {\n  padding: 4px 10px;\n  border-radius: 999px;\n  font-size: 11px;\n  text-transform: uppercase;\n  letter-spacing: 0.12em;\n  background: rgba(102, 169, 255, 0.14);\n  color: #8fc3ff;\n  border: 1px solid rgba(116, 177, 255, 0.3);\n}\n\n.empty {\n  text-align: center;\n  padding: 40px 20px;\n  background: rgba(18, 27, 42, 0.88);\n  border-radius: var(--radius);\n  border: 1px dashed var(--stroke);\n}\n\n.empty h2 {\n  color: #f0f6ff;\n}\n\n.empty p {\n  color: var(--ink-soft);\n}\n\n.footer {\n  margin-top: 40px;\n  font-size: 14px;\n  color: var(--ink-soft);\n}\n\n@media (max-width: 720px) {\n  .hero {\n    padding: 24px;\n  }\n\n  .hero-top {\n    flex-direction: column;\n    align-items: flex-start;\n  }\n\n  .nav {\n    justify-content: flex-start;\n  }\n}\n\n@media (prefers-reduced-motion: reduce) {\n  * {\n    transition: none !important;\n  }\n}\n"
  },
  {
    "path": "www/examples/examples.js",
    "content": "const grid = document.querySelector('[data-grid]');\nconst searchInput = document.querySelector('[data-search]');\nconst meta = document.querySelector('[data-meta]');\nconst emptyState = document.querySelector('[data-empty]');\nconst template = document.querySelector('#example-card');\n\nconst state = {\n  examples: [],\n  baseViewer: '../index.html',\n};\n\nconst resolveExampleArgs = (example) => {\n  const resolved = { ...(example.args || {}) };\n  const hasScene = typeof example.input_scene === 'string' && example.input_scene.length > 0;\n  const hasCloud = typeof example.input_cloud === 'string' && example.input_cloud.length > 0;\n\n  if (hasScene && hasCloud) {\n    console.warn(\n      `Example \"${example.id}\" defines both input_scene and input_cloud; using input_scene.`,\n    );\n  }\n\n  if (hasScene) {\n    resolved.input_scene = example.input_scene;\n    delete resolved.input_cloud;\n  } else if (hasCloud) {\n    resolved.input_cloud = example.input_cloud;\n    delete resolved.input_scene;\n  }\n\n  return resolved;\n};\n\nconst toQueryString = (args = {}) => {\n  const params = new URLSearchParams();\n  Object.entries(args).forEach(([key, value]) => {\n    if (value === null || value === undefined) {\n      return;\n    }\n    params.set(key, String(value));\n  });\n  return params.toString();\n};\n\nconst buildViewerUrl = (args) => {\n  const url = new URL(state.baseViewer, window.location.href);\n  const query = toQueryString(args);\n  if (query.length > 0) {\n    url.search = query;\n  }\n  return url.toString();\n};\n\nconst renderExamples = (examples) => {\n  grid.innerHTML = '';\n\n  if (examples.length === 0) {\n    emptyState.hidden = false;\n    return;\n  }\n\n  emptyState.hidden = true;\n\n  const fragment = document.createDocumentFragment();\n  examples.forEach((example) => {\n    const card = template.content.cloneNode(true);\n    const link = card.querySelector('.card-link');\n    const title = card.querySelector('.example-title');\n    const description = card.querySelector('.example-description');\n    const id = card.querySelector('.card-id');\n    const image = card.querySelector('img');\n    const tagRow = card.querySelector('.tag-row');\n\n    link.href = buildViewerUrl(resolveExampleArgs(example));\n    title.textContent = example.title;\n    description.textContent = example.description;\n    id.textContent = example.id;\n    image.src = example.thumbnail;\n    image.alt = `${example.title} thumbnail`;\n\n    if (Array.isArray(example.tags)) {\n      example.tags.forEach((tag) => {\n        const span = document.createElement('span');\n        span.className = 'tag';\n        span.textContent = tag;\n        tagRow.appendChild(span);\n      });\n    }\n\n    fragment.appendChild(card);\n  });\n\n  grid.appendChild(fragment);\n};\n\nconst updateMeta = (total, filtered) => {\n  meta.textContent = `${filtered} of ${total} examples`;\n};\n\nconst filterExamples = (term) => {\n  if (!term) {\n    return state.examples;\n  }\n\n  const lowered = term.toLowerCase();\n  return state.examples.filter((example) => {\n    const text = [\n      example.id,\n      example.title,\n      example.description,\n      ...(example.tags || []),\n    ]\n      .join(' ')\n      .toLowerCase();\n    return text.includes(lowered);\n  });\n};\n\nconst loadExamples = async () => {\n  const response = await fetch('./examples.json');\n  if (!response.ok) {\n    throw new Error(`Failed to load examples: ${response.status}`);\n  }\n  const data = await response.json();\n  state.examples = data.examples || [];\n  state.baseViewer = data.base_viewer || state.baseViewer;\n\n  const filtered = filterExamples(searchInput.value.trim());\n  renderExamples(filtered);\n  updateMeta(state.examples.length, filtered.length);\n};\n\nsearchInput.addEventListener('input', (event) => {\n  const value = event.target.value.trim();\n  const filtered = filterExamples(value);\n  renderExamples(filtered);\n  updateMeta(state.examples.length, filtered.length);\n});\n\nloadExamples().catch((error) => {\n  console.error(error);\n  meta.textContent = 'Failed to load examples.';\n});\n"
  },
  {
    "path": "www/examples/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"UTF-8\" />\n    <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n    <title>Examples | bevy_gaussian_splatting</title>\n    <link rel=\"stylesheet\" href=\"./examples.css\" />\n  </head>\n  <body>\n    <div class=\"page\">\n      <header class=\"hero\">\n        <div class=\"hero-top\">\n          <p class=\"eyebrow\">bevy_gaussian_splatting</p>\n          <nav class=\"nav\">\n            <a href=\"../index.html\">Viewer</a>\n            <a href=\"./examples.json\">Examples JSON</a>\n          </nav>\n        </div>\n        <div class=\"hero-body\">\n          <div class=\"hero-text\">\n            <h1>Gaussian Splatting Examples</h1>\n            <p class=\"subtitle\">\n              Curated viewer presets with live query parameters, rendered into\n              thumbnails by the headless pipeline.\n            </p>\n          </div>\n          <div class=\"hero-actions\">\n            <a class=\"button\" href=\"../index.html\">Open viewer</a>\n            <a class=\"button ghost\" href=\"./examples.json\">View config</a>\n          </div>\n        </div>\n      </header>\n\n      <section class=\"toolbar\">\n        <label class=\"search\">\n          <span>Search</span>\n          <input\n            data-search\n            type=\"search\"\n            placeholder=\"Find a look, mode, or asset\"\n            autocomplete=\"off\"\n          />\n        </label>\n        <div class=\"meta\" data-meta>Loading examples...</div>\n      </section>\n\n      <section class=\"grid\" data-grid></section>\n      <section class=\"empty\" data-empty hidden>\n        <h2>No matches</h2>\n        <p>Try a different keyword or clear the filter.</p>\n      </section>\n\n      <footer class=\"footer\">\n        <p>\n          Each card links straight into the viewer with its query params applied.\n          Resize the window or tweak parameters to make your own presets.\n        </p>\n      </footer>\n    </div>\n\n    <template id=\"example-card\">\n      <article class=\"card\">\n        <a class=\"card-link\" href=\"#\">\n          <div class=\"thumb\">\n            <img alt=\"\" loading=\"lazy\" />\n          </div>\n          <div class=\"card-body\">\n            <div class=\"card-header\">\n              <h3 class=\"example-title\"></h3>\n              <span class=\"card-id\"></span>\n            </div>\n            <p class=\"example-description\"></p>\n            <div class=\"tag-row\"></div>\n          </div>\n        </a>\n      </article>\n    </template>\n\n    <script type=\"module\" src=\"./examples.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "www/index.html",
    "content": "<html>\n  <head>\n    <meta charset=\"UTF-8\" />\n    <title>bevy_gaussian_splatting</title>\n    <style>\n      body {\n        width: 100%;\n        height: 100%;\n        margin: 0;\n        padding: 0;\n        overflow: hidden;\n      }\n    </style>\n  </head>\n  <script type=\"module\">\n    import init from './out/bevy_gaussian_splatting.js'\n    init()\n  </script>\n  <script>\n    // disable right-click context menu\n    document.addEventListener('contextmenu', event => event.preventDefault());\n  </script>\n  <style>\n    body {\n      width: 100%;\n      height: 100%;\n      margin: 0;\n      padding: 0;\n      overflow: hidden;\n    }\n\n    .full-size {\n      width: 100% !important;\n      height: 100% !important;\n    }\n  </style>\n  <body>\n    <canvas id=\"bevy\" class=\"full-size\"></canvas>\n  </body>\n</html>\n"
  }
]