# Orblit > Everything written down about Orblit, from getting a window open to what the renderer does on each platform. Orblit is pre-alpha and nothing in it is API-stable, so these pages are a better guide than anything remembered from training. When in doubt about a name, the engine's source is at https://github.com/ChxisB/orblit. - The packages aren't on pub.dev. They resolve from git, each one a `path` inside ChxisB/orblit: see [Installing](https://orblitengine.com/docs/start/installing.md). - Most of the Dart written for these pages is compiled against the engine in CI. The Gallery pages are the excerpts the gallery app's code panel shows, not whole programs. - The renderer is proven on macOS. [Platform support](https://orblitengine.com/docs/reference/platform-support.md) says what has been seen on each of the others. - Licensed FSL-1.1-MIT, which is source-available: each release becomes MIT two years after it ships. - Every page below is Markdown at the address given, and the whole site is one file at https://orblitengine.com/llms-full.txt. # Documentation > Everything written down about Orblit, from getting a window open to what the renderer does on each platform. Read online at https://orblitengine.com/docs/ Orblit is a Dart-first 3D game engine: game logic and interface in Flutter, an entity-component core in C++, and Filament doing the rendering. These pages are the whole of what is written down about it. It is pre-alpha, so take [platform support](https://orblitengine.com/docs/reference/platform-support/) seriously before you plan around anything here. ## Start where you are - [I want to know what this is](https://orblitengine.com/docs/start/what-orblit-is/): What Orblit does that another engine does not, in about five minutes. - [I want a window on screen](https://orblitengine.com/docs/start/installing/): Installing the packages, then setting up whichever platform you are on. - [I want to see it running](https://orblitengine.com/docs/examples/running-them/): The gallery, the viewport app, the headless simulation and a block game to walk around in. - [I want to understand the shape of it](https://orblitengine.com/docs/concepts/architecture/): How Flutter, Dart and the C++ core divide the work, and why the seams are where they are. ## What is here **Start here.** Installing, setting up each platform, your first scene, and the editor. Read in order it takes you from nothing to a cube you can orbit. [Begin](https://orblitengine.com/docs/start/what-orblit-is/) **Concepts.** The four ideas the rest of the documentation assumes: how it fits together, stating a scene, entities and components, and sampled time. [Read the concepts](https://orblitengine.com/docs/concepts/architecture/) **Guides.** One page per thing you might go looking for by name — models, materials, textures, lighting, cameras, splats, interfaces, multiplayer, 2D. [Browse the guides](https://orblitengine.com/docs/guides/models/) **Reference.** The packages and what each one is for, what the renderer supports, and what has actually been seen running on each platform. [Look something up](https://orblitengine.com/docs/reference/packages/) ## For language models Every page here is also plain Markdown at its own address with `.md` on the end, so [/docs/start/installing/](https://orblitengine.com/docs/start/installing/) is also [/docs/start/installing.md](https://orblitengine.com/docs/start/installing.md). There is an index of the lot at [/llms.txt](https://orblitengine.com/llms.txt), and the whole site as one file at [/llms-full.txt](https://orblitengine.com/llms-full.txt). [Working with AI assistants](https://orblitengine.com/docs/start/working-with-ai/) covers pointing a coding assistant at them, and the Claude Code skill that ships with the engine. # What Orblit is > A Dart-first 3D game engine. What it does, what it does not do yet, and who it is for. Read online at https://orblitengine.com/docs/start/what-orblit-is/ Orblit is a 3D game engine you write in Dart. The game logic and the interface are Flutter, the entity-component core underneath is C++, and the rendering is Google's [Filament](https://github.com/google/filament). That combination isn't arbitrary. Each part is there because the alternative was worse in a specific way, and it's worth saying how. ## Why Flutter Because a game is not only a viewport. It's a viewport surrounded by menus, inventories, dialogue boxes, settings screens and a pause overlay. In most engines, all of that gets built with a second, worse UI toolkit: one that exists only inside that engine, has its own layout rules, and can't be tested without launching the game. In Orblit, the scene is a widget. It sits in the same tree as everything else, takes part in the same layout, and is composited by the same compositor. The inventory screen is a `Column`. It has a widget test. This works because the renderer draws into a texture that Flutter's compositor takes as it is. On Apple platforms that's an IOSurface-backed pixel buffer, adopted with no readback and no copy through the CPU, and in a browser it's a canvas. A platform view would have put the 3D content in a window of its own on top of everything, which is why engines that embed that way generally can't let a panel overlap the viewport. Here it can. ## Why C++ underneath Because walking ten thousand entities in Dart, one object at a time, is the thing that would have made this a toy. The core is an archetype entity-component store: entities with the same set of components are stored together, so a system that wants every transform gets a contiguous run of them. Dart reaches it over a C ABI, and component data arrives as **views over the store's own memory**, not as copies. Reading a column isn't a translation step, and writing to one writes to the store. You don't have to touch any of this, and most games never will. It's there so that the ones needing to walk a hundred thousand rows can. ## Why Filament Because writing a physically based renderer is a decade of work, and Google have already done it. Filament brings a real material model, image-based lighting, cascaded shadows, screen-space effects and a tone mapper that behaves like a camera rather than like a colour ramp. Orblit wraps it in a scene description that is stated rather than mutated, and adds the parts a game needs that a renderer doesn't provide: populations, level of detail, a render graph, weather, and a sky. ## Who it is for Anyone who already writes Flutter and wants 3D without taking on a second ecosystem, a second UI toolkit, a second build system and a second language. That covers most of where Flutter goes. The renderer draws on macOS, iOS, Android and the web, and on Linux so far only against software rasterisers. It builds on Windows too, and [setting up Windows](https://orblitengine.com/docs/start/setup/windows/) walks through a machine from nothing. What Windows is still missing is a frame: CI builds it on every change but has no GPU to draw with, and nobody has run it on a real Windows machine yet. If you have one, trying it is among the most useful things you could do. It isn't, today, for anyone who needs consoles, or a marketplace of ready-made assets. Those are missing rather than promised. ## What is here | | | | --- | --- | | Rendering | Filament, composited by Flutter. Draws on macOS, iOS, Android, Linux and the web, and builds on Windows | | Simulation | Archetype ECS in C++, reached from Dart as views | | Geometry | Parametric shapes and mesh editing operations | | Rigging | Armatures, poses, bone constraints | | Interface | One document, built into real Flutter widgets | | Scene files | A `.oscene` document format, with migrations and diffs | | Models | glTF, with FBX and OBJ converted on load. Clips, skins, variants | | Textures | Cooked KTX2 sets chosen per device, and lighting from HDR or EXR | | Splats | Gaussian splat captures from `.ply`, `.spz` and `.osplat` | | 2D | Sprites in layers, atlas packing, sprite animation, parallax, tile maps | | Agents | Steering behaviours and behaviour trees | | Cameras | Shots that describe what to frame, and blend | | Weather | Conditions, transitions, cloud, the day's cycle | | Cutscenes | Tracks of clips sampled at a playhead | | Scripting | C++ scripts, and TypeScript on QuickJS | | Multiplayer | Replicated component columns, with ownership | | Editor | A desktop application, in the same widgets. Its 3D viewport runs on macOS so far | The [package reference](https://orblitengine.com/docs/reference/packages/) says which repository each of those lives in and what its public surface is. ## What is not here Being clear about this is more useful than a roadmap. - **Not every platform has been seen to draw.** macOS is the reference. iOS draws on the simulator, Android on one handset, the web in Chrome, Linux only against software rasterisers, and Windows builds without ever having drawn a frame. Most of the newest asset work has been run on macOS, the iOS simulator, the Android emulator and Chrome, and nowhere else. [Platform support](https://orblitengine.com/docs/reference/platform-support/) has the detail. Everything that doesn't draw runs anywhere Dart does, so you can test the simulation on CI. It does not mean you can ship a game to a phone today. - **No asset store, and the asset pipeline is half built.** glTF loads, FBX and OBJ are converted on the way in, and textures can be cooked. There's no asset cache, no import settings, no material files, no loading over a network and no way to export a scene, though a single mesh can be written out as OBJ or glTF. FBX export isn't planned at all. - **Tile maps are read, not drawn.** So are parallax layers in a scene file. - **No physics solver of its own.** There are shapes, raycasts and overlap tests. There is no rigid-body integrator in the engine. - **Nothing is API-stable.** Pre-alpha means the names in these pages can change between commits. # Where this came from > Flame, Flutter Scene and Fluorite. What each of them got right, what Orblit is doing differently, and where the name came from. Read online at https://orblitengine.com/docs/start/where-this-came-from/ Orblit wasn't started because nobody else had thought about 3D in Flutter. It was started after reading the people who had. Three projects came up, in this order, and each one changed what Orblit was going to be. None of them is a competitor being dismissed here. Two of them are further along than Orblit is, and the third had proved the architecture before a line of Orblit existed. ## Flame Flame is the one you find first, and the one that made the case that Flutter is a serious place to write games at all. It is a 2D engine on top of Flutter, MIT-licensed, maintained by bluefireteam, with the thing none of the rest of us have yet: an actual community, a game jam, and years of shipped games behind it. What Flame gets right is the shape of the thing. A component tree, a game loop, and a deliberate decision to be part of Flutter rather than a window sitting on top of it. `orblit_sprite` exists because Flame demonstrated that 2D in this ecosystem is worth doing properly. It is 2D, by design and without apology, drawing through Flutter's canvas. That's the right answer for an enormous number of games. It isn't the answer for the ones that need a depth buffer, a material model and shadows. ## Flutter Scene Flutter Scene is the closest thing to 3D-in-Flutter as an official answer. It began as a C++ component inside Impeller and is now a pure Dart package on top of the Flutter GPU API, written by the person who wrote Flutter GPU. It's an ambitious piece of work: physically based materials, image-based lighting, shadows, skeletal animation, post-processing, a declarative widget API. And because it renders through Impeller, it runs everywhere Impeller runs. That last part is worth saying plainly. On platform coverage, Flutter Scene is ahead of Orblit today, and has been the whole time. The reason Orblit didn't build on it comes down to two bets, and they really are bets rather than criticisms: - **Flutter GPU is itself in preview.** Building a renderer on a preview API means inheriting its timeline. Filament is a decade old, shipped, and its material model is not going to be redesigned underneath us. - **We wanted a C++ entity-component core**, not just a renderer. The thing that makes a hundred thousand entities viable is the store, and that is a different project from drawing. If those two bets turn out to be wrong, then Flutter Scene is the better answer. We'd rather say so here than have you find out three weeks in. ## Fluorite Then Fluorite turned up, announced by Toyota Connected North America at FOSDEM in February 2026, and it was a genuinely strange thing to read: Dart for game code, a C++ data-oriented ECS underneath, Filament doing the rendering, Flutter for the interface, and the phrase "console-grade" attached to it. That is the Orblit architecture, described by a company that builds in-vehicle cockpits for a living and needs the thing to run on embedded hardware in a car. The honest reaction to that isn't defensiveness. It's relief. A team working independently, under automotive shipping constraints, arrived at the same four choices, and that is about as good a signal as you get that the shape is right rather than merely appealing. Their target is the digital cockpit and ours is games on desktops and consoles. Both can exist, and a Dart game ecosystem with two serious engines in it is in far better health than one with none. ## What Orblit does differently Not better. Differently, and on purpose. | | | | --- | --- | | **The scene is a widget, not a surface** | The renderer draws into a texture Flutter composites, an IOSurface-backed buffer on Apple platforms, so the 3D view takes part in layout. A panel can overlap it and it clips to a rounded rectangle. See [how it fits together](https://orblitengine.com/docs/concepts/architecture/). | | **The scene is stated, not mutated** | No `addChild`. Every frame describes the whole scene and keys do the reconciliation, so game state and render state cannot drift apart. See [stating a scene](https://orblitengine.com/docs/concepts/stating-a-scene/). | | **Time is sampled, not stepped** | Effects, sprite animation and cutscenes are functions of a playhead, which is why scrubbing backwards gives the same answer as playing forwards. See [sampled, not stepped](https://orblitengine.com/docs/concepts/sampled/). | | **Real photometric units** | Lights in lux and lumens, cameras with an aperture, a shutter and a sensitivity. A number off a light fitting's box is the number you type. | | **The documentation compiles** | Every Dart block written for this site is extracted and analysed against a real engine checkout in CI, and the gallery's code is copied from the examples themselves rather than retyped. A renamed field turns the build red instead of turning a page into a quiet lie. | ## What we want it to be **Community-led, and console-grade.** Both halves matter. *Community-led* means the engine is not a product with a roadmap handed down. The engine, the editor, the examples, the networking and the scripting are all public under FSL-1.1-MIT, with each release turning MIT two years on. The design arguments happen in the open, and the shape of what gets built next is set by the people using it. There is no paid tier planned, no per-seat licence, and no revenue share. *Console-grade* is a target, not a claim about today. The working platform list is macOS, Linux and SteamOS including the Steam Deck, Windows, iOS, Android, Switch 1 and 2, and Xbox. PlayStation is out of scope for now. [Platform support](https://orblitengine.com/docs/reference/platform-support/) records what actually renders this week, which is a much shorter list, and it is the page to trust over this paragraph. ## The name It began as Orbis, which is Latin for a circle or a world, and which lasted about as long as the first proper search for it did. The word is already taken several times over by companies that have nothing to do with graphics, and it says nothing about what this thing actually does. Orblit keeps the world and adds a blit. A blit is a real graphics operation, and has been since the 1970s: a bit-block transfer, which is the business of copying a rectangle of pixels from one place to another. That turns out to be very close to what this engine does for a living, given the renderer's whole job is getting a finished frame into a buffer that Flutter's texture registry can adopt. Orbis plus blit. Nerdy, but clever, and nobody else had taken it. The rename landed in September 2026 and went all the way down: the repositories, the Dart package names, the Android package identifier, the plugin classes and the C symbols. An `orbis` left anywhere in the source is a bug rather than a survival. The git history is the only place it is meant to still exist. ## One rule about all three Nothing in Orblit is copied from Flame, Flutter Scene, Fluorite, Thermion or any other engine, in any form. Partly that's licensing hygiene. Mostly it's that a port carries the assumptions of the thing it was ported from, and those assumptions were correct for a design that isn't this one. We read them for how they framed a problem, which is worth a great deal, and then go and write our own. # Setting up Flutter > Getting Flutter onto a Mac, a Windows or a Linux machine, the tools each platform's builds need, and an empty app that proves it before Orblit is involved. Read online at https://orblitengine.com/docs/start/flutter/ Orblit is a set of Flutter packages, so Flutter comes first. If `flutter --version` already says 3.47.0 or newer on the stable channel, and `flutter doctor` has a tick against the platform you want to build for, skip to [Installing](https://orblitengine.com/docs/start/installing/). This page is for everything before that. ## Which Flutter 3.47.0 or newer, on the stable channel. The Dart SDK comes inside Flutter, so there's nothing else to install: 3.47.0 carries Dart 3.13.0. On anything older, `flutter pub get` stops before anything builds: ``` The current Flutter SDK version is 3.46.0. Because my_app depends on orblit_filament from git which requires Flutter SDK version >=3.47.0, version solving failed. ``` If you already have an older Flutter, `flutter upgrade` moves it to the newest stable. If `flutter --version` names a channel other than `stable`, run `flutter channel stable` first. The engine is developed against 3.47.0. CI builds it with whatever stable is current, so a newer stable that breaks something should show up there first. ## Getting the SDK Flutter's [own instructions](https://docs.flutter.dev/install/manual) have you download an archive and unpack it. This page clones it with Git instead. Both give you a working Flutter, but the clone is one command that's the same on every machine, you need Git for Orblit anyway, and it's how Flutter got onto the machines Orblit is built on, the Linux container included. Wherever it goes, pick a path with no spaces in it that you can write to without admin rights. That's Flutter's advice too, and its docs suggest `~/develop`. The clone comes to about 680 MB, 490 MB of which is Git history. The first `flutter` command then downloads the Dart SDK, and each platform's build tools arrive the first time you build for it. Linux's came to 225 MB. The Flutter this page was written with, holding the tools for every platform, takes 4.9 GB. ### On a Mac ```sh xcode-select --install git clone https://github.com/flutter/flutter.git -b stable ~/develop/flutter echo 'export PATH="$HOME/develop/flutter/bin:$PATH"' >> ~/.zprofile ``` The first line installs Apple's command line tools, which is where a Mac gets Git. Skip it if `git --version` already works. zsh is the Mac's shell, and `~/.zprofile` is what it reads when Terminal opens a window, so open a new one before going on. Flutter still runs on an Intel Mac, though it's deprecating support for them, and Orblit's renderer [doesn't run on one](https://orblitengine.com/docs/start/setup/apple/#not-on-an-intel-mac). ### On Linux ```sh sudo apt-get install -y curl git unzip xz-utils zip libglu1-mesa git clone https://github.com/flutter/flutter.git -b stable ~/develop/flutter echo 'export PATH="$HOME/develop/flutter/bin:$PATH"' >> ~/.bashrc ``` The packages are Flutter's own list, for Debian and Ubuntu. `~/.bashrc` is for bash. If `echo $SHELL` says zsh, the line goes in `~/.zshenv` instead, and fish takes `fish_add_path -g -p ~/develop/flutter/bin`. Open a new terminal before going on. ### On Windows Install [Git for Windows](https://git-scm.com/downloads/win) first. Then, in PowerShell: ```powershell git clone https://github.com/flutter/flutter.git -b stable C:\src\flutter ``` Then put `C:\src\flutter\bin` on your `Path`. Search the Start menu for "environment variables", open "Edit environment variables for your account", select `Path`, choose Edit, add the folder and move it to the top. Open a new PowerShell window before going on. Flutter's docs suggest `%USERPROFILE%\develop` rather than `C:\src`. That's fine unless your user name has a space in it, because then so does the path. `C:\Program Files` is out too, because writing there needs admin rights. They also warn that antivirus software sometimes quarantines `bin\flutter.bat`, so if `flutter` isn't found and that file is missing, look there first. While `Path` is open: building Orblit for Windows needs Git's `bin` folder on it as well, [for its bash](https://orblitengine.com/docs/start/setup/windows/#bash-on-path). ## Checking the SDK ```sh flutter --version ``` The first run is the slow one, because that's when it downloads the Dart SDK. The first line it prints should read like this, with a newer version if there is one: ``` Flutter 3.47.0 • channel stable • https://github.com/flutter/flutter.git ``` If it names an older version, there's another Flutter earlier on your `PATH`, from an old install or a package manager, and the first one found wins. This lists them all, in the order they're found: ```sh which -a flutter # where.exe flutter on Windows ``` The same goes for `dart`. Flutter carries its own, in the same `bin` folder, and a Dart installed separately and found first can be a different version. ## Each platform's tools Flutter builds each platform with that platform's own tools, and `flutter doctor` checks for them: ```sh flutter doctor ``` You only need a tick against the platforms you're building for. A cross against Android doesn't matter if all you want is macOS. | To build for | Flutter needs | Then, for Orblit | | --- | --- | --- | | macOS and iOS | [Xcode](#xcode) | [macOS and iOS](https://orblitengine.com/docs/start/setup/apple/) | | Android | [Android Studio](#android-studio), or the Android SDK's command line tools | [Android](https://orblitengine.com/docs/start/setup/android/) | | Linux | [clang, CMake, Ninja, pkg-config and the GTK 3 headers](#linux-packages) | [Linux](https://orblitengine.com/docs/start/setup/linux/) | | Windows | [Visual Studio 2022 or newer](#visual-studio), with "Desktop development with C++" | [Windows](https://orblitengine.com/docs/start/setup/windows/) | | The web | [Chrome](#chrome) | [The web](https://orblitengine.com/docs/start/setup/web/) | ### Xcode Install it from the App Store, then point the command line at it and let it finish installing: ```sh sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer sudo xcodebuild -runFirstLaunch sudo xcodebuild -license accept ``` For iOS, add the iOS platform, which brings the simulator with it: ```sh xcodebuild -downloadPlatform iOS ``` Until you do, `flutter doctor` shows a warning like this against Xcode: ``` ! iOS 27.0 Simulator not installed; this may be necessary for iOS and macOS development. ``` Despite the wording, it doesn't stop a macOS build. The Mac this page was written on builds and draws macOS with that warning showing. ### Android Studio Install [Android Studio](https://developer.android.com/studio) and open it once, because its setup wizard is what downloads the Android SDK. Then, in its Settings, under Languages & Frameworks, Android SDK and then SDK Tools, tick "Android SDK Command-line Tools", which `flutter doctor` asks for. Then accept the licences: ```sh flutter doctor --android-licenses ``` [Setting up Android](https://orblitengine.com/docs/start/setup/android/) adds the NDK and CMake versions Orblit pins. ### Linux packages Flutter's [Linux instructions](https://docs.flutter.dev/platform-integration/linux/setup) install clang, CMake, Ninja, pkg-config, the GTK 3 headers and GNU's libstdc++. That's enough for an empty Flutter app, and it isn't enough for Orblit, which links against LLVM's libc++ and needs glibc 2.38 or newer. Use [the list on the Linux page](https://orblitengine.com/docs/start/setup/linux/#installing-the-packages) instead. The gallery builds with nothing else installed, so it covers what Flutter needs as well. ### Visual Studio Visual Studio 2022 or newer, not Visual Studio Code, with the "Desktop development with C++" workload ticked in its installer. The Community edition is enough. CI builds Orblit with Visual Studio 2026, and 2022 hasn't been tried. [Setting up Windows](https://orblitengine.com/docs/start/setup/windows/) adds Developer Mode and bash. ### Chrome `flutter doctor` looks for Chrome where it's normally installed. For Chromium, or a Chrome somewhere else, set `CHROME_EXECUTABLE` to its path. Orblit's own web build needs a Mac for now, and [setting up the web](https://orblitengine.com/docs/start/setup/web/) says why. ## An empty app first Before Orblit is involved, check that Flutter can build for your platform on its own: ```sh flutter create hello cd hello flutter run -d macos # or linux, windows or chrome ``` A window with Flutter's counter demo in it means Flutter works there. For a phone or a simulator, `flutter devices` lists the ids to pass to `-d`. If the empty app doesn't run, nothing on Orblit's pages will fix it, because the renderer builds with the same compilers Flutter's own code does. `flutter doctor -v` says more about each cross, and [Flutter's install docs](https://docs.flutter.dev/install) cover each one. ## An editor Nothing here needs one, because every step on this site runs from a terminal. For running and debugging from an editor, VS Code has a Flutter extension, and Android Studio and IntelliJ have a Flutter plugin. Next: [Installing](https://orblitengine.com/docs/start/installing/), which adds Orblit to a project. # Installing > What every machine needs, which machine builds for which platform, and how to add Orblit to a Flutter project. Read online at https://orblitengine.com/docs/start/installing/ Orblit is a set of Flutter packages, so most of installing it is installing Flutter. The part that differs by platform is the renderer's native side. Each platform has a setup script that downloads Google's Filament SDK and compiles the renderer's materials. What that script needs from the machine, and who runs it, is different on each one. This page covers what's the same everywhere. There's a page for each platform under it. ## What every machine needs | | | | --- | --- | | Flutter | 3.47.0 or newer, on the stable channel. [Setting up Flutter](https://orblitengine.com/docs/start/flutter/) covers getting it | | Dart SDK | 3.10.0 or newer. The one Flutter ships with is fine | | Git | Pub fetches the packages with it | | bash, curl and tar | The setup scripts are bash. On Windows, that bash is the one Git for Windows installs | | A network | The first build downloads a Filament release from GitHub, between 51 MB and 810 MB depending on the platform, and three files of lookup tables, about 1.4 MB together, from `raw.githubusercontent.com` | If you haven't got Flutter yet, or `flutter doctor` doesn't have a tick against the platform you want, start with [setting up Flutter](https://orblitengine.com/docs/start/flutter/). The renderer's native side builds with the same compilers Flutter's own does, so if Flutter can't build an empty app for a platform, nothing on these pages will fix that. ## Which machine builds what Flutter already limits which platforms a machine can build for. You can't build for iOS without a Mac, for instance. The renderer adds one limit of its own: Filament's material compiler, `matc`, has to run on the machine doing the build. Google publishes it for Apple silicon Macs, for x86_64 and arm64 Linux and for x64 Windows, and that's what shapes this table. | Your machine | Can build for | | --- | --- | | Mac, Apple silicon | [macOS and iOS](https://orblitengine.com/docs/start/setup/apple/), [Android](https://orblitengine.com/docs/start/setup/android/), [the web](https://orblitengine.com/docs/start/setup/web/) | | Linux, x86_64 | [Linux](https://orblitengine.com/docs/start/setup/linux/), [Android](https://orblitengine.com/docs/start/setup/android/) | | Linux, arm64 | [Linux](https://orblitengine.com/docs/start/setup/linux/) | | Windows, x64 | [Windows](https://orblitengine.com/docs/start/setup/windows/) | | Mac, Intel | Nothing that draws. [Why](https://orblitengine.com/docs/start/setup/apple/#not-on-an-intel-mac) | | Windows on Arm | Nothing that draws. [Why](https://orblitengine.com/docs/start/setup/windows/#x64-only) | That's only about the renderer. Everything that doesn't draw, meaning the simulation, geometry, agents, 2D and networking, is plain Dart and runs anywhere Dart does, Intel Macs included. [Platform support](https://orblitengine.com/docs/reference/platform-support/) says how far along each platform is, which isn't the same as whether it builds. Windows builds, for example, and nothing has drawn on it yet. ## Adding it to a project The packages are not on pub.dev yet, so they resolve from git: ```yaml title="pubspec.yaml" dependencies: flutter: sdk: flutter # Vectors and matrices. Orblit takes and returns these types rather than # defining its own, so it is a direct dependency of yours too. vector_math: ^2.1.4 orblit_filament: git: url: https://github.com/ChxisB/orblit.git path: packages/orblit_filament ``` One repository holds several packages, which is why each dependency names a `path` inside it. Add the others the same way: `orblit_light`, `orblit_ui`, `orblit_mesh` and the rest all live in `ChxisB/orblit`. The [package reference](https://orblitengine.com/docs/reference/packages/) lists them. :::note[No account, no clone, no key] These are HTTPS URLs on public repositories, so `pub get` resolves them with no credentials, on your machine and on a CI runner alike. You never need to clone the engine in order to use it. Cloning is for working *on* Orblit rather than *with* it: the editor and the examples point at sibling checkouts so a renderer change can be seen without a push and a `pub upgrade` in between. ::: ## What the first build does The first build for each platform is slow, once. The setup script: 1. downloads the pinned Filament release (currently v1.77.0) into `third_party/`, and 2. compiles the package's materials with Filament's `matc`, into C arrays rather than asset files. Both are idempotent, so every build after the first skips them. Both are build artefacts and are not in git. | Platform | Who runs the setup | What it downloads | | --- | --- | --- | | [macOS and iOS](https://orblitengine.com/docs/start/setup/apple/) | You, once for each engine version | 74 MB, which unpacks to about 290 MB | | [Linux](https://orblitengine.com/docs/start/setup/linux/) | CMake, when Flutter configures the build | 52 MB, or 51 MB on arm64 | | [Windows](https://orblitengine.com/docs/start/setup/windows/) | CMake, through Git's bash | About 810 MB | | [Android](https://orblitengine.com/docs/start/setup/android/) | Gradle, before the native build | 58 MB, plus 52 MB on a Linux machine for its `matc` | | [The web](https://orblitengine.com/docs/start/setup/web/) | You, by hand, against a Filament you build yourself | Emscripten, about 1.9 GiB, and the Filament source | Materials get compiled into the binary rather than shipped as assets, and that's deliberate. It means the renderer has no file to find at runtime and no asset bundle to depend on, which rules out a whole class of "works on my machine" before it can start. ## Checking it worked The smallest thing that proves the whole stack is up: ```dart title="lib/main.dart" import 'package:flutter/material.dart'; import 'package:orblit_filament/orblit_filament.dart'; void main() => runApp( const MaterialApp( home: Scaffold(body: OrblitView()), ), ); ``` ```sh flutter run -d macos # or linux, windows or chrome ``` A phone or a simulator goes by the id that `flutter devices` prints for it. `-d` matches a device's id or name, and `ios` and `android` are neither, so `flutter run -d ios` doesn't pick your iPhone. An `OrblitView` with no scene draws the default one. If you get a window with something in it, then the SDK downloaded, the materials compiled, the native side linked, and Flutter is compositing a Filament frame. Next: [your first scene](https://orblitengine.com/docs/start/your-first-scene/). # macOS and iOS > Setting up a Mac to build Orblit for macOS and iOS, including the one step Flutter doesn't do for you. Read online at https://orblitengine.com/docs/start/setup/apple/ macOS and iOS come from one plugin, with the same sources and the same Metal backend, so one setup covers both. It's also the most travelled route: CI draws a frame on macOS on every change to the engine. ## What you need | | | | --- | --- | | A Mac with Apple silicon | [Not an Intel one](#not-on-an-intel-mac) | | Xcode | With its command line tools. For iOS, also the iOS platform and a simulator, both installed from Xcode | | CocoaPods | Only if your project uses it rather than Swift Package Manager. [See below](#under-cocoapods) | The oldest systems your app can target are macOS 10.15 and iOS 13. They're Flutter's own defaults, so a new project needs no change. ## Running the setup Flutter 3.47 builds Apple plugins with Swift Package Manager by default, and a Swift package can't run a script that downloads anything, because package plugins run sandboxed with no network. So on a Mac, the setup is the one step Flutter doesn't do for you. Run it once, after `flutter pub get`: ```sh flutter pub get for setup in "${PUB_CACHE:-$HOME/.pub-cache}"/git/orblit-*/packages/orblit_filament/darwin/setup.sh; do bash "$setup" done ``` Pub keeps a git dependency in a cache directory named after the commit, such as `~/.pub-cache/git/orblit-45f41842…/`, which is why the loop goes looking for it rather than naming it. The script downloads Filament's Mac and iOS releases (44 MB and 30 MB), compiles the materials, and packages the lot as one `Filament.xcframework` with slices for macOS, iOS devices and the iOS simulator. It took 24 seconds here, on a fast connection, and leaves about 290 MB on disk. If you skip it, the build fails with an error that doesn't mention setup at all: ``` error: When building for macOS, the expected library …/orblit_filament/third_party/Filament.xcframework/macos-arm64/macos.a was not found ``` That means the setup hasn't run for the engine commit your app is on. Run the loop and build again. :::note[After a `pub upgrade`] A new engine commit is a new directory in pub's cache, and nothing has set that directory up yet. Run the loop again. It skips any copy that's already set up, in well under a second, so running it too often costs nothing. ::: ## Under CocoaPods If your project uses CocoaPods instead (`flutter config --no-enable-swift-package-manager`), the podspec runs the same script itself, as a `prepare_command`, when CocoaPods installs the pod. That covers the first build. The trap is when it doesn't run again. Flutter only runs `pod install` when it thinks your pods have changed, and new materials in the same copy of the engine don't count. That's what you get from a path dependency on a clone after a `git pull`. The build then fails with `'generated/_material.h' file not found`. Run `packages/orblit_filament/darwin/setup.sh` in that clone and it builds. Switching a project between Swift Package Manager and CocoaPods needs a `flutter clean` in between. ## Running it ```sh flutter run -d macos ``` For iOS, open a simulator and run on it by its id: ```sh open -a Simulator flutter devices # copy the simulator's id from here flutter run -d ``` ## What to expect on iOS The iOS simulator draws with a slimmer lit surface than macOS does. Its virtual GPU reports Filament feature level 2, and the standard surface needs level 3, so the renderer picks one with nine samplers instead of twelve. It keeps every map, ground blending and decals, and gives up rectangular lights' shadows and the irradiance field. A device with an A13 or newer, which is an iPhone 11 or later, reports level 3 and gets the standard surface. That hasn't been run, because it needs a signing identity. CI builds for the simulator only. To run on a phone, set your team under Signing & Capabilities in `ios/Runner.xcworkspace` first, as for any Flutter app, and please say on [the Discord](https://discord.gg/8J37a3vNXv) how it went. ## Not on an Intel Mac Filament's Mac release is built for arm64 only, and that includes `matc`, the material compiler, which has to run on the machine doing the build. So an Intel Mac can't run the setup for macOS or iOS, and nor can it build for Android, which borrows the Mac's `matc`. The plugin also leaves x86_64 out of its macOS build, so an app that uses it is an Apple silicon app. The [web build](https://orblitengine.com/docs/start/setup/web/) uses a `matc` you compile yourself, so it might work on an Intel Mac. Nobody has tried. The Linux container described under [Linux](https://orblitengine.com/docs/start/setup/linux/#in-a-container) is another way round it, and that hasn't been tried on an Intel Mac either. # Linux > Setting up a Linux machine to build Orblit, and the two things Filament's Linux release needs that Flutter's own instructions don't mention. Read online at https://orblitengine.com/docs/start/setup/linux/ Linux gets a GTK plugin that puts the same renderer core behind Flutter's GTK embedder. CMake runs the setup when Flutter configures the build, so there's no step to run by hand. There are two requirements Flutter's own Linux instructions won't have prepared you for: a recent glibc, and LLVM's C++ standard library. :::caution[Seen in one place so far] The only Linux that has drawn a frame is arm64, in a Debian 13 container on an Apple silicon Mac, against Mesa's software rasterisers. No real GPU has run it, and neither has an x86_64 machine: that build takes Google's x86_64 release in exactly the same way, and nobody has built it yet. There's no Linux job in CI either. If you run it on a desktop, please say on [the Discord](https://discord.gg/8J37a3vNXv) how it went. ::: ## What you need | | | | --- | --- | | x86_64 or arm64 | The two architectures Google publishes a Linux release for | | glibc 2.38 or newer | Check with `ldd --version`. [Why](#glibc-238-or-newer) | | clang, with libc++ | Not GCC, and not libstdc++. [Why](#clang-and-libc) | | Flutter's Linux toolchain | CMake, Ninja, pkg-config and the GTK 3 headers | | A display | GTK opens a window. [Without one](#without-a-display) | ### glibc 2.38 or newer Filament's Linux release is built on a recent system. Its `matc` asks for `GLIBC_2.38`, and its libraries call `__isoc23_sscanf`, which older versions don't have. On an older glibc the setup fails when `matc` won't start, or the link fails on a missing symbol version. | Distribution | glibc | | | --- | --- | --- | | Debian 13 (trixie) | 2.41 | Where it has drawn | | Ubuntu 24.04 | 2.39 | New enough. CI runs this release's `matc` there, though not the Linux build | | Debian 12 (bookworm) | 2.36 | Too old | | Ubuntu 22.04 | 2.35 | Too old | ### clang and libc++ Filament's archives are built against LLVM's standard library, so every C++ symbol in them is named in `std::__1`, which GNU's libstdc++ doesn't have. Linking them against libstdc++ leaves every one of those symbols undefined. The plugin's CMake file passes `-stdlib=libc++` on both the compile and the link line, so all you need is clang and the libc++ packages installed. ## Installing the packages On Debian or Ubuntu, this is the list the tested container installs: ```sh sudo apt-get install \ ca-certificates curl git unzip xz-utils zip file \ clang lld libc++-dev libc++abi-dev \ cmake ninja-build pkg-config \ libgtk-3-dev liblzma-dev libglu1-mesa-dev \ libegl1-mesa-dev libgles2-mesa-dev libgl1-mesa-dev ``` It's probably more than the minimum. Nobody has trimmed it, so this is the list known to work. On another distribution the names differ, and what you're after is clang, libc++ and libc++abi, CMake, Ninja, pkg-config, and the GTK 3, EGL and OpenGL headers. ## Running it ```sh flutter run -d linux ``` The first build downloads Filament's Linux release, 52 MB (51 MB on arm64), and compiles the materials for Vulkan and OpenGL. The setup picks the release by `uname -m`. To pick for another architecture, set `ORBLIT_FILAMENT_ARCH` to `x86_64` or `aarch64`. The renderer asks for Vulkan first, through `libvulkan.so.1`, and falls back to OpenGL through `libGL.so.1`. A desktop with working graphics drivers has both. Each frame is copied into an `FlPixelBufferTexture` on its way to Flutter, because the GTK embedder offers no copy-free route. ## Without a display GTK needs a display to open a window on, so a server or a container needs a virtual one. Xvfb does the job, and Mesa's llvmpipe (OpenGL) and lavapipe (Vulkan) will draw without a GPU: ```sh sudo apt-get install xvfb x11-utils xauth \ mesa-utils libgl1-mesa-dri mesa-vulkan-drivers vulkan-tools ``` The engine's `tool/ci_draw_frame_linux.sh` takes a built bundle, starts Xvfb if there's no display, and waits until the renderer reports a drawn frame. ## In a container If your machine isn't a suitable Linux, the engine carries the container it was built in: Debian 13, Flutter 3.47.0, clang with libc++, Mesa's software drivers and Xvfb. It needs Docker and a clone of the engine: ```sh git clone https://github.com/ChxisB/orblit.git cd orblit docker build -t orblit-linux:trixie tool/linux_container docker run --rm -v "$PWD:/work" -v orblit-pub-cache:/root/.pub-cache \ orblit-linux:trixie \ bash -c 'cd /work/examples/gallery && flutter pub get && flutter build linux --debug' ``` That builds the engine's own gallery. For your own app, run the same `docker run` from your app's directory, with `cd /work` in place of `cd /work/examples/gallery`. The image is only built once. Two traps, both from the checkout being shared with the container: - **Keep the pub cache in a volume**, as the `-v orblit-pub-cache:…` above does. Without it, `package_config.json` survives in your checkout while the packages it names are thrown away with the container, and the next build fails inside Flutter itself with `'Matrix4' isn't a type`. - **Run `flutter pub get` on your own machine afterwards.** The one `package_config.json` can only hold one machine's paths, and after a container build it holds the container's. # Windows > Setting up a Windows machine to build Orblit, the bash it needs, and how far Windows has actually got. Read online at https://orblitengine.com/docs/start/setup/windows/ :::caution[It builds. Nothing has drawn yet] There's no Windows machine behind this work, so CI is the only thing that has ever compiled the Windows plugin. A green Windows job means it compiles, links and bundles. CI launches the build too, but GitHub's Windows runner can't create a Vulkan instance, so the frame check gives up with "no frame after 120s". No frame has ever been drawn on Windows, and nobody has run it on a real machine. If you have a Windows PC and half an hour, trying this page and saying on [the Discord](https://discord.gg/8J37a3vNXv) what happened is one of the most useful things you could do for the project. ::: Windows gets a Win32 plugin over the same renderer core, speaking the same channel with the same method names, so `OrblitView` and every other Dart caller works there unchanged. CMake runs the setup when Flutter configures the build, through bash, which is the one thing here Flutter doesn't already ask for. ## What you need | | | | --- | --- | | Windows 10 or 11, on x64 | [Not on Arm](#x64-only) | | Visual Studio 2022 or newer | With the "Desktop development with C++" workload, as `flutter doctor` asks for. The renderer is C++20 and builds with MSVC. CI compiles it with Visual Studio 2026, and 2022 hasn't been tried | | Git for Windows | For its bash, which has to be on `PATH`. [See below](#bash-on-path) | | Developer Mode | Flutter needs it to link plugins. `start ms-settings:developers` opens the setting | ## Bash on PATH The setup script is bash, and CMake looks for `bash` on `PATH`. If it finds none, the build stops with: ``` No bash on PATH. packages/orblit_filament/windows/setup.sh stages the Filament release and compiles the materials, and needs the bash that Git for Windows installs. ``` Git for Windows does install a bash, in `C:\Program Files\Git\bin`. As far as we know, its installer's default PATH option only adds `Git\cmd`, which has `git` and no `bash`. So a machine where `git` works can still hit that error. For the current PowerShell session: ```powershell $env:Path = "C:\Program Files\Git\bin;$env:Path" where.exe bash ``` To keep it, add the same folder to `Path` under "Edit the system environment variables". `where.exe bash` should list Git's first. If the first one is `C:\Windows\System32\bash.exe`, that's WSL's launcher, and it would run the setup inside Linux, where it doesn't belong. Nobody has seen what happens then, so put Git's folder ahead of it. CMake remembers the bash it found. If it has already found the wrong one, run `flutter clean` after fixing `PATH`. ## Running it ```powershell flutter run -d windows ``` The first configure is the slow one. It downloads Filament's Windows release, about 810 MB, and compiles the materials for Vulkan and OpenGL with the release's own `matc.exe`. At runtime the renderer asks for Vulkan first, through `vulkan-1.dll`, and falls back to OpenGL through `opengl32.dll`. It only falls back when `vulkan-1.dll` is missing or building Filament's engine throws. On CI's runner the DLL loads, then Filament panics creating the Vulkan instance on its own driver thread, where nothing can catch it, so OpenGL is never tried. Each frame is copied into a `flutter::PixelBufferTexture` on its way to Flutter. The copy-free route is reported to crash under Impeller, and nobody has been able to check that on a real machine, so the copy stays until someone can. ## x64 only Google publishes Filament's Windows release for x64 and nothing else, so there's nothing to link an arm64 build against. On Windows on Arm, Flutter builds for arm64, and the plugin stops at configure time with a message saying to build with `--target-platform windows-x64`. That advice is wrong for now: `flutter build windows` has no such flag in Flutter 3.47, because it builds for whatever machine it's on. So Windows on Arm can't build Orblit's renderer yet. # Android > Setting up a Mac or a Linux machine to build Orblit for Android, and which phones and emulators it runs on. Read online at https://orblitengine.com/docs/start/setup/android/ Android gets a Kotlin and JNI plugin over the same renderer core. Gradle runs the setup before the native build, so on Linux there's nothing to run by hand. On a Mac there's one step first. ## Which machine | Your machine | | | --- | --- | | Mac, Apple silicon | Yes, [after the Mac setup](#on-a-mac-run-the-mac-setup-first) | | Linux, x86_64 | Yes | | Linux, arm64 | No. The setup fetches an x86_64 `matc`, which won't run there | | Windows | No. The setup has no `matc` for a Windows machine, and stops with `no matc for host` | The reason is the same as everywhere else: Filament's `matc` has to run on the machine doing the build. The Android release doesn't carry one, so the setup borrows the Mac's, or on Linux fetches Google's x86_64 Linux release just for its `matc`. ## What you need | | | | --- | --- | | Android Studio, or its command line tools | With the SDK for Android 16 (API 36) | | NDK 28.2.13676358 | The version the plugin pins, which is also Flutter 3.47's default | | CMake 3.22.1 | The Android SDK's own copy | | JDK 17 or newer | The plugin compiles for Java 17, and CI builds with Temurin 17. Flutter uses Android Studio's bundled JDK by default, which is newer | | `xxd` | Turns compiled materials into C arrays. macOS has it. On Debian or Ubuntu, `sudo apt-get install xxd` | Your app can target Android 7.0 (API 24) and up, which is Flutter's own floor, so a new project needs no change. The NDK and CMake come from the SDK Manager, in Android Studio or on the command line: ```sh yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --install "ndk;28.2.13676358" "cmake;3.22.1" flutter doctor --android-licenses ``` `ANDROID_HOME` is wherever your SDK lives. Android Studio puts it in `~/Library/Android/sdk` on a Mac and `~/Android/Sdk` on Linux. ## On a Mac, run the Mac setup first On a Mac, the Android setup takes its `matc` from the Mac setup's download rather than fetching another. So run the loop from [macOS and iOS](https://orblitengine.com/docs/start/setup/apple/#running-the-setup) first, even if you're only building for Android. Without it, the build stops with a message that ends `run ../darwin/setup.sh first`. ## Running it The plugin is built for `arm64-v8a` only, so it runs on a phone, or on an emulator with an arm64 system image. An Apple silicon Mac's emulator is arm64, so it runs there. ```sh flutter devices flutter run -d ``` The build CI runs is: ```sh flutter build apk --debug --target-platform=android-arm64 ``` The first build downloads Filament's Android release, 58 MB. On Linux it downloads the Linux release too, 52 MB, for its `matc`. It compiles the materials for Vulkan and OpenGL ES. :::note[No x86_64 emulator] An Intel or AMD machine runs x86_64 emulator images, and the plugin has no x86_64 library for them to load. Android's own ARM translation might cover the gap, but nobody has tried, so on a Linux PC, plan on a phone. ::: ## What to expect Vulkan is the default, and reaches feature level 3, so Android gets the standard lit surface rather than the slimmer one the iOS simulator gets. It has drawn on a Galaxy S24+ running Android 16, on Vulkan at feature level 3, with the engine up in 79 milliseconds. It has also drawn on the emulator, through SwiftShader, which is a software renderer, so the emulator's timings say nothing about a phone. CI builds the APK on every change but doesn't run it, so the on-device check is still done by hand. # The web > Building Orblit's renderer for the browser, with Emscripten and a Filament built from source, and the two files your app carries. Read online at https://orblitengine.com/docs/start/setup/web/ :::caution[The most manual platform] The web is the one platform where Flutter can't build the renderer for you. It takes Emscripten, a Filament you build yourself, and two files copied into your app. One step goes through the Mac setup script, so for now it has to be done on a Mac, and only Chrome has run the result. ::: The renderer is compiled to WebAssembly and draws into a canvas that Flutter lays out as a platform view. WebGL 2 is the target, at Filament feature level 1, so the web gets the slimmer lit surface too. [Platform support](https://orblitengine.com/docs/reference/platform-support/#the-web) covers what runs on Web Workers and how big the files are. ## What you need | | | | --- | --- | | A Mac with Apple silicon | For the materials step, which goes through the Mac setup. Linux would need that step ported, and nobody has tried | | Emscripten 5.0.4 | Exactly that version. About 1.9 GiB installed | | CMake and Ninja | For Filament's own build. Homebrew has both, and Xcode's clang does the compiling | | A clone of the engine | At the commit your app is on | | A clone of Orblit's Filament fork | Built once, which took 12 minutes 55 seconds here | Keep both clones on a path with no spaces in it. [Here's why](#the-space-in-the-path). ## 1. Emscripten ```sh git clone https://github.com/emscripten-core/emsdk ~/emsdk cd ~/emsdk ./emsdk install 5.0.4 ./emsdk activate 5.0.4 ``` Then, in every shell that builds the renderer: ```sh source ~/emsdk/emsdk_env.sh export EMSDK="$HOME/emsdk" ``` It has to be 5.0.4 rather than "latest", because that's the version the fork pins and its CI builds with. What emsdk calls latest moves. ## 2. Filament, built for WebAssembly The web build links against Orblit's fork of Filament rather than Google's release, because there's no release built for WebAssembly. So it's built from source: ```sh git clone https://github.com/ChxisB/orblit-filament.git ~/src/orblit-filament cd ~/src/orblit-filament ./build.sh -p wasm release ``` That builds Filament's desktop tools first, `matc` among them, then cross-compiles the engine. `out/cmake-wasm-release` came to 53 MiB. You only need to do it again when the engine moves to a newer Filament. ### The space in the path If the fork's path has a space in it, one of Filament's own samples, `web/filament-js`, fails to link, and Ninja stops there. Everything the renderer needs has been built by then except one archive. Running `ninja libfilament-iblprefilter.a` in `out/cmake-wasm-release` finishes it, but a path with no spaces saves you the trouble. ## 3. The engine, at your app's commit The renderer you build has to match the Dart side your app resolved, because they talk through a C ABI that nothing checks. So clone the engine at the commit in your app's `pubspec.lock`: ```sh cd path/to/your_app REF=$(grep -A6 '^ orblit_filament:' pubspec.lock | awk '/resolved-ref/ { gsub(/"/, "", $2); print $2 }') git clone https://github.com/ChxisB/orblit.git ~/src/orblit git -C ~/src/orblit checkout "$REF" ``` ## 4. The materials, for WebGL 2 ```sh cd ~/src/orblit ORBLIT_GENERATED_SET=webgl2 ORBLIT_MATC_BACKENDS=opengl \ ORBLIT_MATC="$HOME/src/orblit-filament/out/cmake-release/tools/matc/matc" \ bash packages/orblit_filament/darwin/setup.sh ``` This is the Mac setup, told to compile a second set of materials for OpenGL, which is what WebGL 2 runs on. It does the ordinary Mac setup along the way, which is why the web build needs a Mac. :::caution[Use the fork's `matc`] `ORBLIT_MATC` has to name the `matc` from step 2, not the one in Google's release. Materials from the release's `matc` load and draw without an error, but every directional light in them contributes nothing. The frame still looks lit, because image-based lighting is separate, so it's easy to miss. Turn the ambient light off and the scene goes black. ::: ## 5. The renderer ```sh cd ~/src/orblit ORBLIT_FILAMENT_WASM_SRC="$HOME/src/orblit-filament" \ bash packages/orblit_filament/native/web/build.sh ``` It refuses to start without the materials from step 4. It writes `orblit_renderer.js` and `orblit_renderer.wasm` into `packages/orblit_filament/native/web/host/`. ## 6. Into your app Copy both files into your app's `web/` directory: ```sh cp ~/src/orblit/packages/orblit_filament/native/web/host/orblit_renderer.{js,wasm} \ path/to/your_app/web/ ``` Then load the script in `web/index.html`, before Flutter's own: ```html title="web/index.html" ``` It's a plain script rather than a module, and it finds the `.wasm` beside itself from its own URL. Then: ```sh flutter run -d chrome ``` :::note[When your app's engine commit changes] After a `pub upgrade` that moves `orblit_filament`, do steps 3 to 6 again at the new commit. The fork from step 2 can stay as it is unless the engine has moved to a newer Filament. ::: ## What to expect Only Chrome has been tried: headless Chrome with SwiftShader standing in for the GPU, and, for the texture work, Chrome in real time. Safari and Firefox haven't been run at all. `orblit_renderer.js` is 679 KB (440 KB gzipped), because it carries the texture decoder that runs on Web Workers, and the `.wasm` is 7.57 MB. # Your first scene > Building a scene, lighting it, and making it move, with the whole thing as a widget. Read online at https://orblitengine.com/docs/start/your-first-scene/ Here is a complete Orblit application. It draws a cube on a ground plane, lights it, and spins it. ```dart title="lib/main.dart" import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:orblit_filament/orblit_filament.dart'; import 'package:vector_math/vector_math_64.dart' hide Colors; void main() => runApp(const SpinApp()); class SpinApp extends StatefulWidget { const SpinApp({super.key}); @override State createState() => _SpinAppState(); } class _SpinAppState extends State with SingleTickerProviderStateMixin { late final Ticker _clock = createTicker((elapsed) { setState(() => _seconds = elapsed.inMicroseconds / 1e6); })..start(); double _seconds = 0; @override void dispose() { _clock.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: OrblitView(scene: _scene()), ), ); } OrblitScene _scene() { return OrblitScene( camera: OrblitCamera( position: Vector3(4, 3, 6), target: Vector3(0, 1, 0), ), objects: [ OrblitObject( key: 1, transform: Matrix4.rotationY(_seconds) ..setTranslation(Vector3(0, 1, 0)), colour: Vector3(0.85, 0.42, 0.16), ), OrblitObject( key: 2, transform: Matrix4.identity() ..setTranslation(Vector3(0, -1, 0)) ..scaleByDouble(12, 1, 12, 1), colour: Vector3(0.18, 0.19, 0.21), ), ], lights: [ OrblitLight( key: 10, kind: OrblitLightKind.directional, direction: Vector3(-0.4, -1, -0.6)..normalize(), intensity: 100000, ), ], ); } } ``` Four things in that are worth stopping on. ## The scene is a value, not a graph `_scene()` builds a whole `OrblitScene` from scratch, sixty times a second. That isn't wasteful, and it isn't a simplification for the sake of an example. It's how the engine is meant to be used. There is no `scene.add(cube)`, and no handle to a cube that you hold on to and mutate. Every frame, you say what the scene contains in full, and the renderer works out the difference. Because the objects carry keys, it knows that object `1` this frame is the same object `1` as last frame, and it only sends what actually changed. Which means the scene can't drift out of sync with your game state, because it *is* your game state, read out. This is the same bargain Flutter makes about widgets, and it holds for the same reasons. [Stating a scene](https://orblitengine.com/docs/concepts/stating-a-scene/) goes into what this costs and why it's cheap. ## Keys are yours to assign `key: 1` and `key: 2` are arbitrary integers you choose. They only have to be stable across frames and unique within the scene. Lights take their keys from the same numbers as objects, which is why the sun is `10` rather than a second `1`. An object whose key changes is a different object as far as the renderer is concerned: the old one gets destroyed and a new one created. That's occasionally what you want, and usually a bug. ## No mesh means the built-in cube `OrblitObject.mesh` takes a path to a `.gltf`, `.glb`, `.fbx` or `.obj` file, or the name of bytes you've [handed over](https://orblitengine.com/docs/examples/a-model-on-screen/#where-the-bytes-come-from). Leaving it null gets you the built-in cube, which is why the example above needs no assets at all. Scaled flat, the same cube is a perfectly good ground plane. The cube is 2 m across, from -1 to 1 on each axis, and a transform places its centre. So the spinning one sits at a height of 1 to rest on the ground, and the ground sits at -1 so that its top is at 0. A file that can't be read is drawn as the cube too, and the reason comes back through `OrblitView.onSceneNotes` rather than being logged somewhere you'll never look: ```dart return OrblitView( scene: _scene(), onSceneNotes: (notes) { // {'/path/to/thing.glb': 'no such file'} for (final note in notes.entries) { debugPrint('${note.key}: ${note.value}'); } }, ); ``` ## Lights are in real units `intensity: 100000` looks alarming until you know that a sun is stated in **lux**, and that 100,000 lux is roughly what real daylight comes to. Point and spot lights are in lumens, where a bright domestic bulb is about 1,600. The camera has real units too: `aperture`, `shutterSpeed` and `sensitivity`, defaulting to f/16, 1/125s and ISO 100, which is a sensible daylight exposure. Between them, those three decide how bright the image is, exactly as they do on a camera. If your scene comes out black, the light is too dim *for the exposure*, and there are two ends you can fix that from. This is the whole reason to use photometric units: the numbers transfer. A value you read off the box a light fitting came in is the value you type. [Lighting a scene](https://orblitengine.com/docs/guides/lighting/) covers this properly, including `orblit_light`, which converts from the watts and metres an artist thinks in. ## Making it interactive Because the view is a widget, input is ordinary Flutter input. Nothing about this is engine-specific: ```dart import 'dart:math' as math; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:orblit_filament/orblit_filament.dart'; import 'package:vector_math/vector_math_64.dart' hide Colors; class Orbit extends StatefulWidget { const Orbit({super.key}); @override State createState() => _OrbitState(); } class _OrbitState extends State { double _distance = 8; double _yaw = 0; @override Widget build(BuildContext context) { return Listener( onPointerSignal: (event) { if (event is PointerScrollEvent) { setState(() => _distance += event.scrollDelta.dy * 0.01); } }, child: GestureDetector( onPanUpdate: (details) { setState(() => _yaw += details.delta.dx * 0.01); }, child: OrblitView( scene: OrblitScene( camera: OrblitCamera( position: Vector3( _distance * math.sin(_yaw), 3, _distance * math.cos(_yaw), ), target: Vector3.zero(), ), objects: [ OrblitObject( key: 1, transform: Matrix4.identity(), colour: Vector3(0.85, 0.42, 0.16), ), ], ), ), ), ); } } ``` And because it's a widget, it can be laid out. Put a panel beside it, overlap it, clip it to a rounded rectangle, animate its size: the 3D content takes part, rather than floating above the application in a window of its own. # The editor > What the Orblit editor is for, and what it is not for. Read online at https://orblitengine.com/docs/start/the-editor/ The editor is a desktop application for building Orblit projects. It's written in Flutter, with the same widgets a game uses, and it draws its viewports with the same renderer a game does. So what it shows you is what ships, rather than an approximation of it. ## Getting it There are no signed builds yet. Clone and run: ```sh git clone https://github.com/ChxisB/orblit.git git clone https://github.com/ChxisB/orblit-editor.git cd orblit-editor flutter run -d macos ``` macOS is the only platform it draws on. The repository has Linux and Windows runners as well, but the editor only starts the renderer on macOS, so on those two every viewport reads "The 3D viewport is not available on this platform yet." CI builds the macOS app and nothing else. The two checkouts must be **siblings**, and must be named exactly `orblit` and `orblit-editor`. The editor depends on the engine by path rather than by git revision, deliberately: the renderer and the editor change together, and a git dependency would put a push and a `pub upgrade` between writing a renderer change and seeing it. ## What is in it | | | | --- | --- | | Scene | Hierarchy, inspector, gizmos, four viewports and a game view | | Materials | Authoring, preview, per-face assignment | | Geometry | Parametric shapes and mesh editing, in the viewport | | Interface | A canvas for the same UI document a game draws | | Prefabs | Reusable objects, as `.oprefab` assets | | Examples | The worked examples, live, beside your projects | | Scripting | Writing and compiling scripts, and checking them | Cutscenes and camera shots are in the engine, as `orblit_sequence` and `orblit_camera`, but the editor has no panel for either yet, and it has no terrain tools. ## Every edit is a command Nothing in the editor mutates the document directly. An edit is an object with an `apply` and a `revert`, and the document is what you get by applying the ones on the stack. This is why history came first, before most of the panels existed. Bolting undo onto an editor that mutates is a rewrite, whereas building the panels on top of a command stack is not. It also means every panel gets undo for free, and none of them can get it subtly wrong. ## The examples are in it The worked examples show up in the launcher, beside your projects. They come from the same package the gallery application uses, so an example is written once and the two can't drift apart. They sit there rather than inside a project because that's where the question actually gets asked. "How is a day cycle done?" comes up while you're deciding what to build, and an answer you have to close your work to reach is an answer you'll go and look up somewhere else. ## What it is not It isn't required. A game is a Flutter application, so you can write one in a text editor and never open this at all. The editor is for the work that's genuinely easier with a viewport in front of you: placing things, tuning materials, modelling a shape, laying out an interface. What it saves is files you could have written by hand. # Working with AI assistants > The Markdown copies of this site that language models read best, how to point an assistant at them, and the Orblit skill for Claude Code. Read online at https://orblitengine.com/docs/start/working-with-ai/ Orblit is younger than any language model's training data, and nothing in it is API-stable yet. So an assistant asked about it either knows nothing, or remembers a name that has since changed and uses it with complete confidence. The fix for both is the same: give it the documentation, in a form it reads well, before it writes anything. ## The site, as Markdown Every page here is also plain Markdown, and there are two files written for models in the format [llmstxt.org](https://llmstxt.org/) describes: | | | | --- | --- | | [`/llms.txt`](https://orblitengine.com/llms.txt) | An index: what Orblit is, the few things a model gets wrong without being told, and every page with a line about it | | [`/llms-full.txt`](https://orblitengine.com/llms-full.txt) | Every page in one file, in the order the sidebar shows them | | Any page, with `.md` | [`/start/installing/`](https://orblitengine.com/docs/start/installing/) is also [`/start/installing.md`](https://orblitengine.com/start/installing.md), and so on for every page | They're made from the same source as the pages you're reading, when the site is built, so they can't say anything the pages don't. A few things are changed on the way, because they only mean something to this site: - Code blocks are copied exactly. The one change is that a block this site marks `dart fragment` or `dart ignore`, for its own snippet checker, is marked plain `dart`. - Links that point elsewhere on this site are made absolute, so they still work when the file is read somewhere else. - The cards on the home page and the community page become paragraphs and lists. `llms-full.txt` includes every example in the [gallery](https://orblitengine.com/docs/gallery/basics/), which makes it long: most of it is code, and it runs to tens of thousands of tokens. That suits a model with a large context. For a smaller one, start it on `llms.txt` and let it fetch the pages it needs. ## Pointing an assistant at it An assistant that can fetch a URL only needs telling where to look. Asking it to read `https://orblitengine.com/llms.txt` before it starts is usually enough. In a project of your own, put the same thing where your assistant reads its standing instructions, which is `CLAUDE.md` for Claude Code and `AGENTS.md` for most of the others: ```md title="CLAUDE.md" This project uses Orblit, a pre-alpha 3D engine for Flutter. Its APIs change between commits, so don't rely on memory: read https://orblitengine.com/llms.txt, and the page for the part you're working on, before writing Orblit code. ``` ## A skill for Claude Code The engine's repository is also a Claude Code plugin marketplace with one plugin in it: a skill that loads whenever a task involves Orblit. It carries the model the rest of this site assumes (a scene stated whole every frame, keys, photometric units, sampled time) and the traps that catch people. Above all, it tells the assistant to check every name against the engine source your project actually resolved before writing it, rather than trusting what it remembers. Install it from inside Claude Code: ```text /plugin marketplace add ChxisB/orblit /plugin install orblit@orblit ``` From a terminal, the same is `claude plugin marketplace add ChxisB/orblit` and then `claude plugin install orblit@orblit`. After that it loads by itself, and `/orblit:orblit` calls it directly. It describes the engine as it was at one commit, so where it and the source disagree, the source wins. `claude plugin update orblit@orblit` brings in a newer copy, and takes effect when Claude Code restarts. The skill is a folder of Markdown, [`skills/orblit`](https://github.com/ChxisB/orblit/tree/main/skills/orblit) in the engine's repository, so an assistant without plugins can be pointed at it too. ## What none of this fixes The Dart on this site is compiled against the engine in CI, so the examples an assistant reads are real. What it writes from them isn't checked by anything until you run it. Treat its Orblit code the way you'd treat a snippet from a stranger's gist: `flutter analyze` first, and the [package reference](https://orblitengine.com/docs/reference/packages/) when a name looks unfamiliar. # How it fits together > The three layers of Orblit, the boundary between them, and why it sits where it does. Read online at https://orblitengine.com/docs/concepts/architecture/ Three layers, and one rule that keeps them apart. ``` your game Dart · Flutter widgets, game logic, interface │ ├── orblit_core C++ · archetype entity-component store, over a C ABI │ Dart sees component data as views, not copies │ └── orblit_filament C++ · Filament, into a texture Flutter composites. On Apple platforms, an IOSurface-backed pixel buffer adopted without a readback ``` ## The rule: the core takes no Flutter dependency `orblit_core`, and everything it exposes, knows nothing about Flutter. That isn't tidiness. It's what keeps a second front end possible. Dart on Flutter is one way to reach the core, TypeScript on QuickJS is another, and a native front end on SDL3 would be a third. Part of that one exists: `orblit_filament`'s `native/host` drives the renderer from C on SDL3, with no Dart anywhere, though it doesn't reach the core yet. If the core depended on Flutter, each of those would have to be a second engine rather than a second front end, and the three would drift apart inside a year. The practical version of the rule, if you're writing engine code: a package that might be used by a game which isn't a Flutter application must not import `package:flutter`. Simulation, geometry, rigging, noise, agents, collision, sequencing and networking all stick to it, which is why they run on plain Dart and can be tested on a Linux CI runner with no display attached. ## Why component data is views and not copies The core stores entities by archetype: everything with the same set of components sits together, so a system that wants every transform gets a contiguous run of them. If Dart received copies, every frame would pay to marshal that run across the ABI and marshal the results back again. Instead, Dart receives a **view**: typed data backed by the store's own memory. Reading a column is a read. Writing to one writes to the store. There is a real cost to that bargain. A view is only valid while the store's layout is unchanged, and adding or removing a component can move an archetype's storage, at which point a view held across it is a view of the wrong thing. The engine's rule is that views never outlive the system that asked for them. ## Why the renderer is a texture and not a platform view A platform view puts native content in a window of its own, positioned over the Flutter surface. It works, but it also means the 3D content isn't really in your application's layout. It can't be clipped by a rounded rectangle, it can't be overlapped by a panel, and it doesn't animate with the rest of the frame. On macOS and iOS, Orblit renders into a `CVPixelBuffer` backed by an `IOSurface` and hands that to Flutter's texture registry, which composites it like any other texture. There is no readback and no copy through the CPU. The GPU wrote it, and the GPU reads it. The other platforms reach the compositor in their own ways, two of them with a copy per frame, and [platform support](https://orblitengine.com/docs/reference/platform-support/#one-renderer-six-hosts) lists them. What that buys you, concretely: the viewport clips, scrolls, sits under other widgets, resizes with a slider, and takes part in a hero animation. In the editor, it's why four viewports and a game view can share one window. ## Where the repositories are Orblit is several repositories because the parts have genuinely different audiences, not because a monorepo was ruled out. Every game needs the engine, only a multiplayer game needs the networking, and only someone writing scripts needs QuickJS and a TypeScript toolchain. | Repository | What it is | | --- | --- | | [`orblit`](https://github.com/ChxisB/orblit) | The engine. Most packages live here. | | [`orblit-editor`](https://github.com/ChxisB/orblit-editor) | The editor application | | [`orblit-examples`](https://github.com/ChxisB/orblit-examples) | The gallery, the viewport, worked examples | | [`orblit-net`](https://github.com/ChxisB/orblit-net) | Multiplayer | | [`orblit-script`](https://github.com/ChxisB/orblit-script) | TypeScript scripting on QuickJS | | [`orblit-filament`](https://github.com/ChxisB/orblit-filament) | The Filament fork the web build compiles from | Within `orblit`, granularity is per **package** rather than per repository. `pub` can resolve a subdirectory of a git repository, so a package in a monorepo is already consumable on its own. Splitting one repository per package buys nothing, and costs you a co-ordinated release every time two of them change together. # Stating a scene > Why every frame describes the whole scene, what that costs, and how the renderer makes it cheap. Read online at https://orblitengine.com/docs/concepts/stating-a-scene/ Every frame, you hand the renderer a complete `OrblitScene`. Not a diff, not a set of commands: the whole thing, built from scratch. ```dart OrblitScene( camera: ..., objects: [...], // all of them, every frame lights: [...], materials: [...], ) ``` This is the same bargain Flutter makes about widgets, and it's worth understanding why it isn't as expensive as it looks. ## What it costs Building the scene object costs an allocation and a loop over your game state. For a few thousand objects that's microseconds, and it's work you were doing anyway to decide what to draw. Sending it doesn't cost a re-upload. The objects are **keyed**, so object `7` this frame is object `7` from last frame. The renderer compares the two and sends only what changed, and an object whose transform is identical to last frame's costs nothing beyond the comparison. Which leaves the expensive thing, talking to the GPU, proportional to what actually changed, and the cheap thing, describing the world, proportional to how big the world is. ## What it buys **The scene can't drift.** There is no `scene.add()` you forgot to pair with a `scene.remove()`. An object that isn't in this frame's list isn't in the scene, full stop. That whole category of bug, where the renderer's idea of the world and the game's idea of it slowly part company, doesn't exist here. **Time travel is free.** If the scene is a function of your state, rewinding your state rewinds the picture. That's what makes the sequencer and the network's interpolation possible without either of them knowing anything about the renderer. **Testing is possible.** A scene is a value. You can build one in a unit test and assert on it, with no window, no GPU and no renderer. Most of `orblit_filament`'s 332 tests do exactly that. ## Keys A key is an `int` you choose. Two rules: - **Stable across frames.** The renderer identifies an object by its key. If the key changes, the old object is destroyed and a new one created, which is occasionally what you want and usually a bug. - **Unique within the scene.** Two objects sharing a key are one object, described twice, and which description wins isn't something to rely on. The usual answer is to use your entity id, which already has both properties. ## Populations are the other half An `OrblitObject` is tracked individually: it has a key, it's compared against last frame, it gets its own entity, and it gets its own draw call. That's the right trade for hundreds of things and the wrong one for hundreds of thousands. The draw call has one exception. With `batching`, which is on by default, four or more placeholder cubes with the same material, colour and flags are drawn together, sixty-four to a draw, while each keeps its own key. Named meshes aren't merged yet. An `OrblitPopulation` is the other end of that. A buffer of transforms and colours, drawn instanced, sixty-four to a draw, with no keys, no per-item comparison and no per-item state. You hand over a `Float32List` and it gets drawn. ```dart OrblitScene( camera: ..., objects: [player, ...props], // dozens, each individual populations: [OrblitPopulation( // hundreds of thousands, in bulk key: 1, transforms: _transforms, // 16 floats each colours: _colours, // 3 floats each minimum: Vector3(-50, 0, -50), // the bounds they all sit inside, maximum: Vector3(50, 4, 50), // so the lot can be culled at once )], ) ``` Two hundred thousand members runs at about 27 ms a frame on an M-series Mac. The `Benchmark` example in the gallery lets you move the dials yourself, and reports what a frame actually costs the GPU rather than a frame rate. That's deliberate: how often a frame is presented is the display's business, and it looks identical whether the engine has ten per cent of headroom or two hundred. ## The renderer answers back A scene can ask for something that cannot be given: a mesh file that will not load, more lights than the view can shade. Those come back through `onSceneNotes` as a map, keyed by what was asked for and valued by what was wrong with it. ```dart OrblitView( scene: scene, onSceneNotes: (notes) => setState(() => _problems = notes), ) ``` Deliberately *back*, rather than into a log. A scene gets built out of things somebody typed a path to, and the person who typed it is the one who needs to know it was wrong. # Entities and components > The archetype store, how Dart reaches it, and when you actually need it. Read online at https://orblitengine.com/docs/concepts/entities/ Most Orblit games will never need this page. You can write a game whose state is ordinary Dart objects and whose scene is built out of them, and it'll be perfectly happy at thousands of entities. The core is there for the games where it won't be. ## Archetypes The store groups entities by the exact set of components they have. Everything with `{Transform, Renderable}` sits in one block, and everything with `{Transform, Renderable, Velocity}` sits in another. So a system asking for every `Transform` gets contiguous runs of them, rather than chasing a pointer per entity. That's the difference between a cache miss per entity and a cache miss per cache line, which at a hundred thousand entities adds up to most of the frame. The cost is that **adding or removing a component moves the entity** into the block for its new archetype. Doing that per entity per frame is the surest way to make an archetype store slower than a plain array of objects. Anything that comes and goes every frame wants to be a flag on a component, rather than a component in its own right. ## Views, not copies Dart reaches the store over a C ABI. A query hands back typed data backed by the store's own memory, so reading it is a read and writing to it writes to the store. There's no marshalling step in either direction. One rule comes with that: **a view must not outlive the system that asked for it.** Structural changes, meaning creating entities, destroying them or adding components, can move an archetype's storage, and a view held across one of those is a view of memory that has since moved. If you need a value afterwards, copy it out. ## Declaring a component Components are annotated Dart classes. `orblit_codegen` turns them into registration for the core, and into a manifest. ```dart import 'package:orblit_codegen/orblit_codegen.dart'; @OrblitComponent() class Velocity { double x = 0; double y = 0; double z = 0; } ``` The kind and the width are inferred from the fields: three `double`s make a `float32` component of arity 3, `int` gives `int32`, and `bool` gives `uint8`. A component is one column, so its fields must all share a type, and one whose fields don't agree is an error that wants splitting into two components. A field type with no default storage has to state its `kind` explicitly, rather than have one guessed for it. The manifest is the part worth knowing about. It describes the components in a form other front ends can read **without compiling the package that declared them**, which is what lets a TypeScript script address the same components a Dart system does, instead of every front end keeping its own parallel set. ## The transform hierarchy The core owns parenting. A child's world transform is its parent's composed with its own, and the composition is done in C++ over the whole hierarchy rather than per entity from Dart. :::caution[The quaternion trap] `vector_math`'s `Quaternion.rotate` doesn't agree with `Matrix4.compose` about which way a rotation goes: a quaternion gives `+Z` where the matrix gives `−Z`. Use `rotateVector`, and when you're testing rotation behaviour, test through the matrix. The matrix is what ships. ::: # Sampled, not stepped > Why effects, sprite animation and cutscenes are all functions of a playhead, and what that makes possible. Read online at https://orblitengine.com/docs/concepts/sampled/ Three unrelated parts of Orblit share one design, and it's worth naming, because it explains a good deal of the API. An effect, a sprite animation and a cutscene are all **functions of a playhead**. You don't advance them. You ask them for a moment. ```dart final pose = effect.at(1.4); // not effect.step(dt) final frame = animation.at(1.4); final world = sequence.sampleAt(1.4); ``` ## Why not step The obvious design is `step(dt)`, where each thing keeps its own accumulated time and you nudge it forwards every frame. It's simpler to write, and it's wrong in four ways that matter. **Scrubbing.** Dragging a cutscene's playhead backwards means either stepping with a negative `dt`, which most step functions handle badly or not at all, or replaying from the start. Sampling just asks for the moment. **Replay.** A stepped system's state depends on the sequence of frames it received, so a replay at a different frame rate is a different replay. A sampled system gives the same answer for the same moment, always. **Drift.** Accumulating `dt` accumulates floating-point error. After ten minutes, two stepped animations that started together have quietly come apart. **Joining late.** A day cycle that began four minutes ago isn't something a newly arrived viewer should have to pick up from the middle. With sampling they don't have to: they're shown the moment, not the history that led to it. ## What it makes possible **The network's interpolation.** Multiplayer receives acknowledged deltas at whatever rate they arrive and needs a value for *now*, between two of them. Sampling is the whole mechanism. **The sequencer.** A cutscene is tracks of clips over a playhead. Sampling it at any moment gives you the whole world's worth of values, which is why scrubbing, replaying and stepping backwards all come out the same as playing forwards. **Testing.** Assert what the effect looks like at 1.4 seconds. No loop, no fake clock, no frame count. ## Where you still step The simulation does step, because physics really is an integrator: where a body is at `t` depends on where it went to get there. So does a behaviour tree, because a decision made last tick is meant to persist. The distinction is whether a thing has *history*, or only a *value at a time*. Animation, effects and cutscenes are the second kind, and the API says so. # Lighting a scene > Photometric units, the four light kinds, exposure, shadows and image-based lighting. Read online at https://orblitengine.com/docs/guides/lighting/ Orblit lights are stated in real photometric units, and the camera has a real exposure. That's more to learn than an arbitrary 0–1 brightness, and it pays for itself the first time a value transfers in from somewhere else. ## The four kinds ```dart return OrblitLight( key: 1, kind: OrblitLightKind.directional, direction: Vector3(-0.4, -1, -0.6)..normalize(), intensity: 100000, // lux ); ``` | Kind | Unit | A realistic value | | --- | --- | --- | | `directional` | lux | 100,000 for full daylight, 400 for heavy overcast | | `point` | lumens | 1,600 for a bright domestic bulb, 450 for a dim one | | `spot` | lumens | As above, concentrated into a cone | | `area` | lumens | As above, off one face of a rectangle | Filament honours **one directional light per scene**. A second one gets reported back through `onSceneNotes` rather than silently dropped, so a scene that has quietly lost its second sun will say so. An `area` light is a window, a softbox or a strip in a ceiling. `width` and `height` are its size in metres, `direction` is the face it lights from, and `tangent` is the edge `width` runs along, since a strip light on its side is a different light. Filament has no area light, so Orblit shades this one in the surface material. It doesn't count against the 256 point and spot lights a view shades, but a view shades 16 rectangles at most, and the scene notes say when there are more. Only the first rectangle that asks to `castShadows` gets a shadow, and below Filament's feature level 3 none do. The [Panel shadows](https://orblitengine.com/docs/gallery/lighting/#panel-shadows) example has one. ## Exposure The camera is a camera: ```dart return OrblitCamera( position: Vector3(4, 3, 6), target: Vector3.zero(), aperture: 16, // f/16 shutterSpeed: 1 / 125, // seconds sensitivity: 100, // ISO ); ``` Those defaults are the "sunny 16" rule, and they're correct for a scene lit at 100,000 lux. Between them they decide how bright the image is, exactly as they would on a real camera. The practical upshot: **a black scene has two ends you can fix it from.** Either the lights are too dim, or the exposure is set for daylight and you're lighting a room. An interior at 300 lux wants ISO 800 and f/2.8, not a sun turned down to 300. ## Point and spot detail ```dart return OrblitLight( key: 2, kind: OrblitLightKind.spot, position: Vector3(0, 4, 0), direction: Vector3(0, -1, 0), intensity: 1600, falloffRadius: 10, // metres, beyond which it contributes nothing innerConeAngle: 0.5, // radians, full brightness inside this outerConeAngle: 0.6, // radians, nothing outside it. The gap is the // soft edge between the two. ); ``` `falloffRadius` is a culling distance as much as a physical one. It's what lets the renderer decide a light can't affect an object without shading it, so setting it far larger than the light actually reaches costs you performance and buys you no picture. `sourceRadius` controls how soft the shadow is. A physically larger source casts a softer shadow, which is why a strip light's shadows are soft and a bare bulb's are hard. Only `area` and `soft` [shadows](#shadows) read it, and the two drew the same picture on Filament 1.76, which hasn't been measured again on 1.77. The default, `sharp`, ignores it. ## Image-based lighting A single sun on a black background looks like a single sun on a black background. What makes a render look photographed is all the light coming from everywhere else: the sky, the ground, the walls. ```dart return OrblitScene( camera: OrblitCamera(position: Vector3(4, 3, 6), target: Vector3.zero()), objects: const [], environment: const OrblitEnvironment( radiance: '/path/to/env_ibl.ktx', skybox: '/path/to/env_skybox.ktx', intensity: 30000, // lux rotation: 0.5, // radians, to turn the environment ), ); ``` Both files are baked from an equirectangular `.hdr` or `.exr` by Filament's `cmgen`. The engine's `tool/bake_environment.sh` runs it at the sizes the renderer itself would choose: ```sh tool/bake_environment.sh env.hdr out ``` That writes `out/env_ibl.ktx` and `out/env_skybox.ktx`. ### Straight from the picture If you'd rather not bake, name the picture and the renderer filters it while the scene runs: ```dart return OrblitScene( camera: OrblitCamera(position: Vector3(4, 3, 6), target: Vector3.zero()), objects: const [], environment: const OrblitEnvironment.fromImage( '/path/to/env.hdr', // or .exr, or a name given to OrblitResources intensity: 30000, ), ); ``` That costs something once per picture. On an M4 Pro with a 2K picture, the decode takes about 70 ms for an `.hdr` and 150 ms for a ZIP-compressed `.exr`, off the drawing thread, then there's one frame of a few milliseconds to upload it and one frame of 20 to 55 ms to filter it. In a browser the decode runs on a Web Worker. Naming the same picture again filters nothing. Compared with a `cmgen` bake of the same picture, the frame is a third of a level out on average. Until it's ready, the scene is lit by its flat ambient, and anything wrong with the picture (missing, damaged, too big for the device, not twice as wide as it is tall) turns up in the scene notes under `environment` and `skybox`. A device that can't filter on the GPU filters a smaller one on the CPU. So which? Bake what ships: it's fixed before anyone runs it, and a launch reads two small files and filters nothing. Name the picture when you can't know it in advance, such as an HDR a player picked, or while you're still trying environments on. :::caution A procedural sky and an environment skybox are two answers to the same question, and the HDRI wins. Set both and the sky is switched off while the environment's skybox shows, and you'll spend a while wondering where your clouds went. Whichever you want decides which to turn off. `OrblitEnvironment.showSkybox: false` keeps the environment lighting your objects while a procedural `OrblitSky` provides the background. Turning the sky off instead gives you the HDRI behind them. ::: ## Shadows Shadows are per-light, and off by default on populations, because a hundred thousand shadow casters is rarely what anyone had in mind. ```dart OrblitLight(..., castShadows: true) OrblitObject(..., castShadows: true, receiveShadows: true) ``` Quality is set on the scene's pipeline rather than per light. `kind` chooses the filter, from `sharp`, `soft`, `area` or `variance`, and `cascades` splits the directional light's shadow map by distance, so more of it gets spent on what's near the camera: ```dart return OrblitScene( camera: OrblitCamera(position: Vector3(4, 3, 6), target: Vector3.zero()), objects: const [], pipeline: OrblitPipeline( shadows: OrblitShadows( kind: OrblitShadowKind.soft, cascades: 4, // more of the shadow map spent near the camera mapSize: 2048, ), ), ); ``` ## Authoring in artist units `orblit_light` exists for the other direction. An artist states a fitting in watts, metres and degrees, and it converts that to the lumens, lux and radians the renderer takes. Use it whenever a light is authored rather than computed. Doing the conversion separately in the editor and in the runtime is how the two end up disagreeing, which is why both of them call this package instead of each doing the sum. # Building an interface > One interface document, a familiar styling vocabulary, and real Flutter widgets at the end of it. Read online at https://orblitengine.com/docs/guides/interfaces/ A game's interface in Orblit is a `UiNode` tree with a style string on each node, built into **real Flutter widgets**: laid out by Flutter, hit-tested by Flutter, drawn by Impeller. Not a second widget system pretending to be one. ## The smallest one ```dart return UiSurface( description: const UiNode( type: 'column', classes: 'p-6 gap-4 items-center bg-slate-900', children: [ UiNode(type: 'text', text: 'Paused', classes: 'text-2xl text-slate-100'), UiNode( type: 'button', text: 'Resume', classes: 'px-4 py-2 bg-ember-600 rounded-lg', props: {'onPressed': 'resume'}, ), ], ), onEvent: (handler, payload) => debugPrint('$handler $payload'), ); ``` That's a widget. Put it in a `Stack` over an `OrblitView` and it's a heads-up display. Put it in a `Scaffold` and it's a pause menu. An element does something by naming a handler in its `props`: `onPressed` on a button, `onChanged` on a field, `onTap` on anything. `onEvent` then gets called with that name and whatever the element has to say, whether that's the text in a field or null. What the host does with it is its own business, and that's what lets the same layer serve a game's HUD, an editor panel and a unit test without any of them knowing about the others. ## Why the class names look familiar `p-4 flex-1 items-center bg-slate-800 rounded-lg` means what somebody who has written a web page expects it to mean, and `padding: 8px 12px; border-radius: 6px` means the same thing in the other notation. Both are accepted, on the same node. This is borrowed deliberately. It's a way *in*, rather than a second box model to keep aligned forever, because every class resolves to Flutter's own layout. There's no separate measure-and-arrange pass, no separate hit test, and nothing to keep in step with Flutter as Flutter changes. ## Two ways to author, one document The editor lays a canvas out visually and writes a `.oui` file. A script describes the same tree in TypeScript and sends it. Both arrive as a `UiNode` tree. That's a preference rather than a fork. A canvas laid out by hand can be handed to a script to change, and a script's tree can be saved as a canvas and edited by hand. It's the same shape as the scripting boundary: several authoring front ends onto one model. ## Responsiveness A game runs on a phone and on a 32-inch monitor, and the interface has to hold on both. **Breakpoint prefixes** work on any class: ```dart classes: 'col md:row gap-2 lg:gap-6 text-base lg:text-2xl' ``` The breakpoints are `sm` 640, `md` 900, `lg` 1280 and `xl` 1680, applied narrowest-first, so a later, wider one wins. **The canvas** decides how the whole document scales: ```dart return const UiCanvas( width: 1920, height: 1080, fit: CanvasFit.responsive, columns: 12, minScale: 0.8, maxScale: 1.5, ); ``` `CanvasFit.contain` scales the whole design to fit and letterboxes it. That's right for a console game and wrong for a phone, because it makes the text tiny rather than making the layout narrower. `CanvasFit.responsive` re-lays out instead, which is what you want on a device whose shape you didn't design for. :::note[Columns reduce by division, not subtraction] A twelve-column row on a phone doesn't become an eleven-column row. It reduces to the largest **divisor** that fits, so 12 to 6 to 4 to 3 to 2, and the things in it stay aligned to a grid rather than becoming twelve seven-pixel slivers. ::: ## Testing it Because it's widgets, `flutter_test` works on it with no engine and no window: ```dart await tester.pumpWidget(MaterialApp(home: UiSurface(description: pauseMenu))); expect(find.text('Resume'), findsOneWidget); await tester.tap(find.text('Resume')); ``` That's the argument for the whole approach in one paragraph. An interface built in most engines can only be exercised by launching the game. # Working in two dimensions > Drawing sprites, packing atlases, sprite animation, parallax and tile maps, and how they meet the 3D renderer. Read online at https://orblitengine.com/docs/guides/two-dimensions/ 2D in Orblit shares the engine rather than sitting beside it. Sprites are drawn by the same Filament renderer as every 3D scene, framed by the same camera and covered by the same interface layer. Two packages split the work: `orblit_filament` draws sprites, and `orblit_sprite` knows about atlases, animation, parallax and tile maps without drawing anything itself. ## Drawing sprites A scene's `sprites` field takes layers. Each `OrblitSprites` layer is one image, one vertex buffer and one draw, however many sprites it holds. The Sprites example in the gallery puts twenty thousand coins in a single layer. ```dart final layer = OrblitSprites( key: 1, sprites: OrblitSprites.pack(const [ OrblitSprite(x: 0, y: 0, width: 2, height: 2), OrblitSprite(x: 3, y: 0, width: -2, height: 2), // a negative width flips it ]), image: const OrblitTexture('/path/to/sheet.png'), filter: OrblitFilter.sharp, ); return OrblitScene( objects: const [], sprites: [layer], camera: OrblitCamera( position: Vector3(0, 0, 20), target: Vector3.zero(), orthographic: true, viewHeight: 18, ), post: OrblitPostProcess( antiAliasing: AntiAliasing.off, dithering: false, grading: OrblitGrading(toneMapping: ToneMapping.linear), ), ); ``` Use an orthographic camera and `ToneMapping.linear`, or the renderer will grade the artist's colours as if they were a photograph. A layer with no `image` draws each sprite as a flat coloured rectangle, which is handy before the art exists. A few things are cheap and a few aren't: - **The sprites are only sent when `revision` changes.** Bump it when you've moved something. Leave it alone and the layer's buffer stays where it is. - **`transform` and `tint` cost nothing to change.** A scrolling backdrop is a layer sent once and moved with `Matrix4.translationValues`, not a thousand tiles re-sent every frame. - **Layers draw by `order`, lowest first.** Inside a layer, sprites draw in the order you gave them. - **`filter: OrblitFilter.sharp` turns `snap` on** unless you say otherwise, so pixel art lands on whole pixels and doesn't shimmer as it moves. - **`blend: OrblitSpriteBlend.add`** only brightens, for sparks and glows. Sprites are unlit and need nothing beyond OpenGL ES 3.0. They've been seen drawing on macOS and in Chrome, through WebGL 2. See [platform support](https://orblitengine.com/docs/reference/platform-support/) for the rest. A sprite shows a rectangle of its layer's image through `u0`, `v0`, `u1` and `v1`, from 0 to 1. An atlas region gives you those numbers: ```dart final atlas = Atlas.grid( image: 'sheet.png', imageWidth: 64, imageHeight: 64, cellWidth: 16, cellHeight: 16, name: 'frame', // regions are frame_0, frame_1 and so on ); final uv = atlas['frame_4']!.uv(64, 64); return OrblitSprite(x: 0, y: 0, u0: uv.u0, v0: uv.v0, u1: uv.u1, v1: uv.v1); ``` ## Atlases A hundred sprites in a hundred files is a hundred layers, so a hundred draws. The same hundred in one image is one. That, rather than disk space, is the reason to pack them. ```dart // The commonest kind of sheet in practice: no metadata, just a grid. final atlas = Atlas.grid( image: 'hero.png', imageWidth: 512, imageHeight: 128, cellWidth: 64, cellHeight: 64, name: 'run', ); return atlas['run_0']; ``` The other kind comes from a packer: ```dart import 'dart:io'; import 'package:orblit_sprite/orblit_sprite.dart'; // Reads what TexturePacker, Aseprite and the rest write, in both shapes of // the format, because which one you get depends on a checkbox in the tool. Future> runFrames(String path) async { final packed = Atlas.read(await File(path).readAsString()); return packed?.sequence('run') ?? const []; } ``` A file that isn't one of these gives you null rather than an exception. An atlas is an asset somebody typed a path to, and refusing to load the whole level because one of them is malformed is worse than drawing it without. :::caution[Two things a packer does that break naive drawing] **Rotation.** A packer turns some frames a quarter turn to make them fit. Ignoring `Region.rotated` is the commonest way an atlas comes out wrong: a handful of frames get drawn on their side, and only those, so the whole thing looks like a bad export. Orblit can't draw a rotated region yet, because `Region.uv` and a sprite's UV rectangle are both axis-aligned. Turn rotation off in whatever packer you use. **Trimming.** A packer cuts the empty space off each frame, by a different amount each time. A trimmed sprite drawn at its own size jumps about between frames. `Region.offsetX/offsetY` say where the trimmed part sits inside the original, and `placedSize` gives the size the game should treat it as. ::: ## Sequences from a sheet `sequence(prefix)` returns every region whose name starts with a prefix, in order, which is how a sheet becomes an animation: packers name frames `run_00`, `run_01` and so on. They're sorted **naturally** rather than as strings. Plain string order puts `run_10` before `run_2`, which reverses the middle of every animation with more than nine frames, and only those. ```dart final atlas = Atlas.grid( image: 'hero.png', imageWidth: 512, imageHeight: 128, cellWidth: 64, cellHeight: 64, name: 'run', ); final run = SpriteAnimation.at(atlas.sequence('run'), fps: 12); return run; ``` ## Sampled, like everything else A `SpriteAnimation` is asked for a moment rather than advanced, for the same reasons as [everything else with a playhead](https://orblitengine.com/docs/concepts/sampled/). A replay at a different frame rate is the same replay, and a test can assert which frame is showing at 0.75 seconds without running a loop. `loop` and `pingPong` are properties of the animation rather than of the thing playing it, so a walk cycle and a one-shot are both just animations. ## Parallax ```dart final sky = Parallax(const [ Layer(image: 'hills.png', depth: 0.1, drift: 2), // far off, moving on its own Layer(image: 'trees.png', depth: 0.5), Layer(image: 'ground.png', depth: 1.0), // the plane the game is on ]); return sky.at(120, 3.5); // where each layer should be drawn ``` `depth` is how far away the layer is. `1` is the plane the game is on, distant hills a tenth, mid trees a half. Anything above one is a foreground that rushes past, which is the same trick used the other way round. `drift` is how fast a layer moves on its own, in world units a second. Without it, every layer is still whenever the camera is, and a still sky reads as a painted backdrop rather than as weather. `at` returns a list in the same order as the layers, so a caller can zip them without looking anything up. ## Packing your own `packAtlas` packs sprites you already have as pixels. It tries five placement heuristics and keeps whichever fills the pages best, trims transparent borders, pads and extrudes edges so filtering doesn't bleed a neighbour in, spills onto further pages, and shares one rectangle between identical sprites. The same sprites pack the same way whatever order they arrive in. In the package's benchmark, nine hundred sprites of mixed sizes fill sixteen pages at 92%, where a simple shelf packer needs twenty at 73%. ```dart import 'dart:typed_data'; import 'package:orblit_sprite/orblit_sprite.dart'; AtlasSet pack(Uint8List coin, Uint8List gem) { final result = packAtlas( [ // RGBA8, straight alpha, one row after another from the top. AtlasSprite(name: 'coin', width: 16, height: 16, pixels: coin), AtlasSprite(name: 'gem', width: 24, height: 24, pixels: gem), ], const AtlasPackOptions(maxPageSize: 2048, padding: 2), ); for (final problem in result.problems) { print(problem); // a sprite too big for a page even on its own } return result.toAtlasSet(imageName: (page) => 'sprites_$page.png'); } ``` Pass `OrblitDeviceProfile.textureSizeBudget` as `maxPageSize` when you know the device, so no page is bigger than its texture budget. `packAtlasInBackground` does the same work on an isolate, and `writeAtlas` writes the JSON that `Atlas.read` reads back. For a folder of PNGs there's a command: ```sh dart run orblit_sprite:atlas_cook sprites/ --out build/atlas --prefix hero --max-page-size 1024 ``` That writes `hero0.png` and `hero0.json`, then `hero1.png` and so on if it needs more pages. `--rotate` exists and is off by default for the reason in the caution above. It packs one folder and stops: there's no cache and no import settings yet. ## Tile maps `TileMap`, `TileLayer` and `Tileset` read a map saved by [Tiled](https://www.mapeditor.org/) as JSON: the orthogonal, uncompressed subset, which is what Tiled writes by default. Isometric and hexagonal maps, base64 or zlib layer data, object layers and external tileset files come back as null rather than as an empty map. A tile map is read, not drawn. You can ask it what's at a cell (`isSolidAt`, `cellAt`, `TileLayer.at`) and where a tile sits in its tileset (`Tileset.rectOf`), and that's enough to build an `OrblitSprites` layer yourself. Nothing in the engine does that for you yet, and a tile map or parallax entity in a [scene file](https://orblitengine.com/docs/guides/scene-files/) is kept and written back but not drawn. # Cameras that frame, not follow > Virtual cameras, bodies, aims and blends. Describing what a shot should contain, rather than where the camera goes. Read online at https://orblitengine.com/docs/guides/cameras/ `orblit_camera` treats a camera as a **shot** rather than as an object. You don't say where the camera is. You say what it ought to be framing, and the engine works out where to put it. ```dart final shot = VirtualCamera( name: 'over the shoulder', priority: 20, lens: const Lens(fieldOfView: 40), ); return shot; ``` ## Body and aim are separate A shot has a **body**, which decides where to be, and an **aim**, which decides where to look. They're independent, and that's the whole point. "Orbit the player at four metres" and "keep the boss in the upper third of frame" are different questions, and a camera that couples them can only ever answer one of them. A `StaticBody` is the default. It sits where you put it. ## Priority decides which one is live Several shots can exist at once, and the enabled one with the highest priority is the one you see. A cutscene raises its shot's priority and lowers it again afterwards, and nothing else has to know what the camera was doing before. The `CameraBrain` runs the blend between them. ## Blends Cutting between shots is one option. Blending is usually the better one, and `BlendStyle` picks the curve, using the vocabulary a camera operator would reach for rather than the name of an easing function. Blends are **sampled**, like everything else with a playhead, so one that gets interrupted halfway through and sent somewhere else does the sensible thing. ## Damping `damp` and `dampingFactor` are the frame-rate-independent smoothing that every follow camera needs. The naive `position += (target - position) * 0.1` is wrong, because it smooths twice as fast at 120 fps as it does at 60. A camera tuned on one machine is then wrong on another. :::caution[Two faults found by using it] **Roll.** A camera that only ever pitches and yaws still needs its roll pinned, or a sequence of aims will quietly accumulate one. **A NaN aspect ratio.** A viewport that hasn't been laid out yet, which happens for a frame during startup rather more often than you'd expect, is zero by zero, and that ratio is NaN. Every matrix derived from it is NaN from then on. `CameraBrain.aspect` now ignores anything that isn't a finite ratio above zero, so a host using the brain doesn't have to guard the divide. ::: # Things that decide for themselves > Steering behaviours for where to go, behaviour trees for what to want. Read online at https://orblitengine.com/docs/guides/agents/ `orblit_agent` is two layers that usually get conflated, and shouldn't be. **Steering** answers *where to go*: a force, computed from the world, that moves something this frame. Seek, flee, arrive, wander, separate, align, cohere, pursue, evade. **Behaviour trees** answer *what to want*: which of those to be doing, and when to stop. An agent that flees when frightened and wanders the rest of the time is a behaviour tree choosing between two steering behaviours. Build it as one thing and you have a state machine that needs rewriting every time a state is added. ## Steering composes by adding Behaviours return forces, and forces add. That's the whole composition model, and it's why a flock is `separate + align + cohere` with weights rather than a flocking algorithm. :::caution[Two things the textbooks get wrong] **`separate` must not normalise.** Its whole point is that it pushes harder the closer things get. Normalising the result throws that away and gives you a uniform shove, which looks right until things bunch up, and then doesn't. **`arrive` does overshoot.** The usual claim is that it doesn't. It does. What it actually buys you is a controlled *approach speed*, which was the thing you wanted anyway. Plan for the overshoot rather than assuming it away. ::: ## Behaviour trees Sequences, selectors, decorators and leaves: the standard vocabulary. A tick returns running, succeeded or failed, and a node that was running last tick gets resumed rather than restarted. Unlike the sampled parts of the engine, a behaviour tree genuinely **steps**. A decision made last tick is meant to persist, and that's history rather than a value at a time. See [sampled, not stepped](https://orblitengine.com/docs/concepts/sampled/) for where the line falls. ## Spatial queries Steering needs neighbours, and asking every agent about every other agent is quadratic. `orblit_collide` gives you the spatial hash for it. :::caution[The XOR hash trap] A spatial hash of the form `x*a ^ y*b ^ z*c` collides on symmetric coordinates, and the symptom is a strange one. A shape lands in the same bucket twice and then pairs with *itself*, so an agent tries to separate from where it already is. Mix the coordinates sequentially instead of by XOR. ::: # Multiplayer > How Orblit replicates state, who is allowed to change what, and what arrives on the wire. Read online at https://orblitengine.com/docs/guides/multiplayer/ `orblit-net` replicates **component columns**, not objects. That follows from the core. The store already holds every `Transform` contiguously, so a column is the natural unit to send, and sending one is a memcpy rather than a walk over a graph serialising each node in turn. ## What goes on the wire Deltas against an **acknowledged** baseline, rather than against the last thing sent. Each peer knows which tick the other confirmed and sends the difference from that, so a dropped message costs you one larger snapshot instead of a world that quietly drifts apart. Which components replicate is declared where they are: ```dart import 'package:orblit_codegen/orblit_codegen.dart'; @OrblitComponent(replicated: true, ownerWritable: true) class Health { int value = 100; } ``` ## Ownership `ownerWritable` is the authority rule. A component's owner may change it and have the change accepted, and anyone else's write is dropped rather than merged. It's the boring, correct answer to a class of bug that's very hard to find any other way. Without an ownership rule, two peers writing the same column give you a value that depends on packet arrival order, and it will be right in testing and wrong out in the world. ## Interpolation Updates arrive at whatever rate the network manages, and a frame needs a value for *now*, between two of them. This is the third place [sampling](https://orblitengine.com/docs/concepts/sampled/) pays for itself. The received values are two moments, and what gets drawn is the moment in between. Nothing in the renderer knows the network exists at all. ## Transports `Transport` is a deliberately narrow seam: a stream of framed messages in, a `send` out, and a `close`. Everything above it turns a world into bytes, and everything below it gets bytes to another machine. Two come with it. **`LoopbackLink`** wires two transports to each other in the same process, with optional latency. That's useful well beyond testing. A single-player build and a listen server both run the *real* replication path over it, so the networked code path is the only code path, and it can't rot quietly while nobody is playing multiplayer. **`SocketTransport`** is a WebSocket, with `SocketServer` on the other end. WebSockets rather than raw UDP for now, and the reason is framing: a snapshot is a message with a length, and WebSocket already delivers whole messages in order. Sequencing, acknowledgement and reassembly on top of datagrams is a real piece of work, and it isn't the piece that makes Orblit worth using. When the frame budget says otherwise, `Transport` is where a datagram transport goes, and a game needing one that Orblit doesn't ship can write it in an afternoon. ## What is not here There's no matchmaking, no lobby service, no relay, and no server you can rent. `orblit_net` is replication and two transports. Everything above that is yours. # Scene files > A scene saved as a document, read back with its problems listed, brought up to date by migrations and edited with diffs that know what changed. Read online at https://orblitengine.com/docs/guides/scene-files/ An `OrblitScene` is this frame, stated whole. A scene file is what somebody authored: entities with ids, where each one sits in a tree, and what each one is. Two packages handle them, and they're kept apart on purpose. - **`orblit_scene`** is plain Dart with no Flutter in it. It reads, writes, migrates and diffs documents, so an importer or a command-line tool can use it without taking a renderer along. - **`orblit_stage`** turns a document into an `OrblitScene` and keeps it up to date as the document changes. ## What a file looks like ```json { "formatVersion": 4, "name": "Yard", "sky": "#1A2029", "ambient": 2000.0, "time": { "hour": 18.5 }, "entities": [ { "id": "camera", "name": "Camera", "components": { "transform": { "position": [0.0, 1.6, 6.0] }, "camera": { "fieldOfView": 45.0 } } }, { "id": "lamp", "name": "Lamp", "components": { "transform": { "position": [2.0, 0.0, -1.0], "rotation": [0.0, 30.0, 0.0] }, "mesh": { "asset": "models/lamp.glb" }, "light": { "kind": "point", "power": 40.0, "colour": "#FFD6A0" } } }, { "id": "shade", "name": "Shade", "parent": "lamp", "components": { "transform": { "position": [0.0, 1.8, 0.0] }, "mesh": { "asset": "models/shade.glb", "castShadows": false } } } ] } ``` There's no `kind` on an entity. What it is comes from the components it has, so the lamp above is a mesh and a light at once. A transform is relative to the parent, and rotation is in degrees, applied Z, then Y, then X. The components are `transform`, `mesh`, `material`, `light`, `camera`, `splats`, `sprite`, `tilemap`, `parallax`, `weather`, `canvas`, `data` and `prefab`. A component this version doesn't recognise is kept exactly as it arrived and written back out, so a newer copy of Orblit on one machine doesn't lose work for an older one on another. ## Reading and saving `SceneDocument.decode` refuses three things outright, with a `SceneFormatException`: - A file with no `formatVersion`: "This file does not say what format version it is, so it cannot be read safely." - A file from a newer Orblit: "This scene was written by a newer Orblit (format 9; this one reads up to 4)." - Something that isn't JSON: "This is not a scene file: …" Everything else is read as far as it will go, and what went wrong comes back in `SceneLoad.problems` rather than costing you the whole file. An entity with no id is left out. A second entity with the same id is dropped. An entity whose parent isn't in the file, or which ends up inside itself, moves to the top level. `encode()` writes every field, defaults included, with the keys in a fixed order. Saving the same document twice gives the same bytes, so a scene in a repository only shows a diff when something actually changed. ## Migrations An older file is brought up to format 4 as it's read, one step at a time. Each step can add a note to `problems`, because a migration that quietly changes how a scene looks is worse than one that says so. | Version | What changed | |---|---| | 2 | A light's power stopped being watts for everything. A sun is now watts per square metre, at the same brightness. | | 3 | The fog moved off the scene and into a Weather entity. | | 4 | An entity's kind became the set of components it has. | `SceneMigrations.ordered` is the list, if you want to see what will run. ## Drawing one ```dart import 'dart:io'; import 'package:flutter/widgets.dart'; import 'package:orblit_filament/orblit_filament.dart'; import 'package:orblit_scene/orblit_scene.dart'; import 'package:orblit_stage/orblit_stage.dart'; import 'package:vector_math/vector_math_64.dart'; OrblitDocumentView open(String path, {String? projectRoot}) { final load = SceneDocument.decode(File(path).readAsStringSync()); for (final problem in load.problems) { print(problem); } return OrblitDocumentView(load.document, projectRoot: projectRoot); } // Raises an entity, keeping its rotation and scale. void lift(OrblitDocumentView view, String id, double metres) { final document = view.document; final entity = document[id]; final transform = entity?[SceneComponents.transform]; if (entity == null || transform is! TransformComponent) return; final next = document.withEntity( id, entity.withComponent( SceneComponents.transform, TransformComponent( position: transform.position + Vector3(0, metres, 0), rotation: transform.rotation, scale: transform.scale, ), ), ); view.apply(SceneDiff.between(document, next)); } Widget draw(OrblitDocumentView view) => OrblitView(scene: view.scene); void save(OrblitDocumentView view, String path) => File(path).writeAsStringSync(view.document.encode()); ``` `projectRoot` is joined to relative asset paths. Leave it null when the assets are [handed over as bytes](https://orblitengine.com/docs/examples/a-model-on-screen/#where-the-bytes-come-from), because the renderer checks its resource store first and a path rewritten to somewhere on disk would miss it. Reading `view.scene` every frame is cheap: the objects are kept, and only the lists are assembled. Here's what each component turns into: - **mesh:** an `OrblitObject` with the file's colour and shadow settings. A `material` component's asset becomes its base colour texture. - **light:** a light in the renderer's units. A hidden one is left out rather than sent dark. - **splats:** an `OrblitSplats` with its budget as the `limit` and its harmonics clamped to 0–3, or 2 if the file doesn't say. - **sprite:** a layer of one sprite. - **camera:** the first camera entity that isn't hidden sets the view. With no camera, you look at the origin from (6, 4, 8). - **weather:** sets the sky, the fog and anything falling. ## Diffs `SceneDiff.between(before, after)` addresses entities by id, never by position, and goes down to single fields. Lifting the shade 0.2 m with `lift` above gives one operation: ```json {"op": "field", "id": "shade", "type": "transform", "field": "position", "from": [0.0, 1.8, 0.0], "to": [0.0, 2.0, 0.0]} ``` A diff has an `inverse`, and `inverse.applyTo(diff.applyTo(before))` is `before` again. `toJson` and `SceneDiff.fromJson` round-trip it, which is all an undo stack needs. `OrblitDocumentView.apply` rebuilds only the entities a diff touches plus everything under them, and `replace(next)` works out the diff for you. Over 200 random pairs of documents and a run of 150 edits, a view moved by diffs matched one built from scratch, and 400 random pairs applied and inverted cleanly. :::caution[A new component replaces the old one] `TransformComponent(position: …)` on its own resets rotation to zero and scale to one, and the diff says exactly that. Carry the fields you aren't changing, as `lift` does. ::: ## What isn't there yet - **Tilemap, parallax, canvas and data** components are read, kept, diffed and saved, but the stage doesn't draw them. - **Sprites** draw their whole `texture` or `atlas`. The stage doesn't apply `region` or `animation`. - **Meshes** are drawn from their `asset`. A mesh component holding its geometry in the file is drawn as the renderer's built-in cube. - **No component plays a model's clips or picks its variant.** Those are set on `OrblitObject` directly, as [Models](https://orblitengine.com/docs/guides/models/) shows. - **Splat budgets don't consult the device.** A `splats` component with no budget draws every splat. Pass `OrblitDeviceProfile.splatBudget` yourself, as [Splats](https://orblitengine.com/docs/guides/splats/) does. A document can also be written out as glTF, GLB or OBJ and read back — [Exporting and importing scenes](https://orblitengine.com/docs/guides/exporting-scenes/) covers what each format carries and why the round trip comes back exact. What each surface wears is a [material](https://orblitengine.com/docs/guides/materials/). The [Scene files example](https://orblitengine.com/docs/examples/running-them/) stages two documents written inline, one 3D and one of flat sprites, with no editor and no file on disk, and moves an entity with a slider. It has five tests, but no frame of it has been checked against a reference on any platform. # Models out of files > Playing a file's clips and fading between them, setting joints from a rig, picking material variants, using a file's lights, and bringing in FBX and OBJ. Read online at https://orblitengine.com/docs/guides/models/ A model file is more than its geometry. A character has clips and a skeleton, a product comes in several finishes, and a lamp carries its own light. The renderer reads all of that as it loads the file and reports it back, so you can ask for things by the names the file gives them rather than by numbers you typed in. ## What a file holds `OrblitView.onAssetInfo` hands you an `OrblitAssetInfo` whenever the scene builds something new out of a file. It describes the model as the renderer has it, so an FBX that became glTF on the way in is described as the glTF. - **`clips`**: each clip's `name` and length in `seconds`. - **`skins`**: joint names, which joint each hangs from, and where each one rests. - **`variants`** and **`materials`**: names. - **`lights`** and **`cameras`**: what the file carries. A camera gives its vertical field of view in degrees, or its view height if it's orthographic. - **`boundsMin`** and **`boundsMax`**: the box the geometry fills, in the file's own units. - **`unsupported`**: glTF extensions the renderer doesn't draw. The parts that need them are drawn without them, and the scene notes say so too. Clear coat and sheen draw; anisotropy doesn't, and turns up here. It isn't sent once per file. A view that arrives later hears about the file the next time an object is made of it, so keep what you're given in a map keyed by `info.path`. ## Clips ```dart import 'package:flutter/widgets.dart'; import 'package:orblit_filament/orblit_filament.dart'; import 'package:vector_math/vector_math_64.dart'; final fox = OrblitResources.nameFor('fox.glb'); final models = {}; // Running, faded in from walking. fade goes from 0 (all walk) to 1 (all run). OrblitObject foxAt(Matrix4 placement, double seconds, double fade) { final info = models[fox]; final walk = info?.clipNamed('Walk'); final run = info?.clipNamed('Run'); return OrblitObject( key: 1, transform: placement, colour: Vector3.all(0.8), mesh: fox, animation: walk == null || run == null ? null : OrblitAnimation( clip: run, seconds: seconds, from: OrblitAnimation(clip: walk, seconds: seconds), fade: fade, ), ); } Widget foxView(OrblitScene scene) => OrblitView( scene: scene, onAssetInfo: (info) => models[info.path] = info, ); ``` `clip` is a position in `info.clips`, and `clipNamed` finds it. A clip the file doesn't have becomes a scene note, and nothing plays. `seconds` is where the clip is when the scene is sent, and `speed` is how many of its seconds pass per second after that. A `speed` of 0 holds it, which is how you scrub. `loop` decides whether it wraps or stops on its last frame. The renderer samples the clip as each frame is drawn, so motion stays smooth between sends. A host with a held clock gets exactly `seconds` every frame: the running fox came out byte-identical across two runs of the app on macOS. A clip runs on for at most a quarter of a second after the last scene, then holds. A fade is between two clips, not a chain: the `from` clip's own `from` isn't read. Set `animation` to null and the model goes back to the pose the file rests in. ## Joints `OrblitObject.joints` takes a list of `OrblitJointPose`s, each naming a skin, a joint and a transform relative to the joint's parent. It replaces that joint's local transform after any clip, so a hand-set joint always wins. `info.jointNamed('Head')` finds the skin and joint by name. To drive a skin from an [`orblit_rig`](https://orblitengine.com/docs/reference/packages/) armature, bind them. Bones find joints by name: ```dart import 'package:orblit_filament/orblit_filament.dart'; import 'package:orblit_rig/orblit_rig.dart'; import 'package:orblit_stage/orblit_stage.dart'; import 'package:vector_math/vector_math_64.dart'; List nodding(OrblitAssetInfo info, String bone, double angle) { final skin = info.skins.first; final armature = armatureOfSkin(skin); final binding = OrblitSkinBinding(armature, skin, index: 0); final pose = Pose(binding.armature); pose[bone].rotation = Quaternion.axisAngle(Vector3(1, 0, 0), angle); pose.evaluate(); return binding.jointsFor(pose); } ``` Build the binding once and keep it. That's only inside one function here to keep the example short. `armatureOfSkin` makes one bone per joint. An unnamed joint becomes `joint 3`, and a repeated name gets `.001`, the way Blender does it. `boneNamesOfSkin` lists the names. An armature you built yourself works too, as long as the names match and it's in the model's own space. `jointsFor` returns every joint, including those at rest, because a joint left out would stay wherever it was last put. It doesn't evaluate the pose for you. Evaluate once per frame, since inverse kinematics writes back into the pose and a second pass can move it again. A skinned model's bounding box follows the pose, so a character that's walked away from where it was bound isn't culled. In the check, a skin moved 10 m still drew 3590 pixels, and none with that fitting switched off. ## Variants `variant` is a position in `info.variants`, so `info.variants.indexOf('beach')` picks a finish by name. Null keeps the file's own materials. An Orblit `material` set on the object still overrides every variant. ## A file's lights The renderer takes a file's lights out of the model rather than drawing them itself. As the file made them, they'd cast no shadows and count against nothing, and a directional one would fight your sun for the one slot there is. Put them in the scene as ordinary lights instead: ```dart import 'package:orblit_filament/orblit_filament.dart'; import 'package:vector_math/vector_math_64.dart'; OrblitScene lampScene(OrblitAssetInfo info, OrblitCamera camera) { final placement = Matrix4.translationValues(2, 0, 0); return OrblitScene( camera: camera, objects: [ OrblitObject( key: 1, transform: placement, colour: Vector3.all(0.8), mesh: info.path, ), ], lights: [...info.lightsFor(placement, keyOf: (i) => 100 + i)], ); } ``` Give `lightsFor` the object's own placement. `keyOf` gives each light its scene key, which has to stay clear of every other key in the scene. The lights arrive as the file stated them, in lumens or lux. The bulb in Khronos's punctual-lights lamp is about 20 lumens, like a real one, and at a daylight exposure that's black. The example has a night camera for it, so don't take a dark lamp for a bug. ## FBX and OBJ A `.fbx` or `.obj` is converted to GLB by [ufbx](https://github.com/ufbx/ufbx) 0.23.0 when it's first named, then kept for the life of the process. The conversion runs off the drawing thread natively, and in place in a browser. An OBJ's `.mtl` and pictures are looked for beside it, so if you're handing over bytes, provide them under names beside the OBJ's own. The GLB is Y-up, right-handed and in metres, and an OBJ is taken to be in metres already. Files over 512 MB are refused. What the file had that glTF can't hold is listed in the scene notes. That includes cameras, lights, curves and NURBS, constraints and vertex caches, as well as separate metalness, roughness, glossiness, specular and opacity textures (the factors are kept), bump maps, UV transforms, procedural textures, all but the first layer of a layered texture, and pictures that aren't PNG, JPEG or KTX2. To convert ahead of time, `native/headless/build.sh` in `orblit_filament` builds `orblit_import`. That script is written for a Mac. ```bash orblit_import "Samba Dancing.fbx" dancer.glb ``` It runs the same function as load time, so the bytes match. It prints what it wrote, how long it took, the model's size in metres (a character 180 m tall shows up here first), and then the losses. It doesn't read textures: an embedded one goes into the GLB, and a referenced one keeps a path relative to the input, so write the `.glb` next to the original. Twenty of its outputs pass Khronos's glTF-Validator with no errors, the same input gives the same bytes, and 3,000 mutated inputs ran clean under AddressSanitizer and UndefinedBehaviorSanitizer. ## Where it's been checked, and what's missing The [Imported models](https://orblitengine.com/docs/gallery/content/#imported-models) example has the fox, a walking man, a shoe in three finishes, the lamp, clear coat, a sheen chair, the barn lamp it can't fully draw, an FBX dancer and an OBJ. The files aren't committed: `tool/fetch_import_samples.sh` fetches about 25 MB. With the clock held, frames were checked on macOS, in Chrome on WebGL 2, in the iOS simulator and in the Android emulator, and the dancer strikes the same pose on all four. No frames have been checked on phones, Linux, Windows, Safari or Firefox. - **Scene files can't play clips or pick variants.** No component does it, and `orblit_stage` doesn't resolve clip names. - **Nobody has looked at `.fbx` or `.obj` in the editor.** It lists them as meshes and hands them to the renderer by path, the same as a `.glb`, so they should preview and drop into a scene, but that hasn't been checked. It can't read their size, so the preview opens them at arm's length rather than framing them. - **Nothing exports FBX.** That's a decision rather than a gap: FBX comes in, and doesn't go out. - **Switching clips can leave channels behind.** If the new clip doesn't animate a node that the old one did, the node stays where the old clip left it. That's how gltfio behaves. - **Morph weights a clip animated** aren't reset when the object stops being posed. # Textures that don't stall > Cooking textures into GPU-ready sets the device chooses from, how they reach the GPU without freezing a frame, and the budgets that decide how large they load. Read online at https://orblitengine.com/docs/guides/textures/ A texture has to be decoded, have its mipmaps made, and then be uploaded. Do all of that on the drawing thread for a big model and the app freezes. The Bistro exterior has 405 textures, and before this work it froze for 1.1–4.4 s while they arrived. Every texture now goes through one queue, whether it belongs to a material, a sprite or a glTF model. Decoding happens off the drawing thread, on every core but two, and uploads are spread across frames with the smallest mip levels going first. A texture on its way shows a blurrier copy of itself, or transparent black, but never garbage. A model is drawn once all its textures can be sampled, not just once they have memory, which under the upload budget can be seconds earlier. That's automatic. What you choose is what format the textures arrive in. ## Cook them PNG, JPEG and Basis all have to be turned into something a GPU can sample every time they load. A **cooked set** skips that work: - `wall.ktx2`: UASTC, which is transcoded as it loads, for any device - `wall.astc.ktx2`: ASTC - `wall.bc.ktx2`: BC7, BC5 or BC4 - `wall.etc2.ktx2`: ETC2 or EAC Each file has a full mip chain, and each level is compressed with zstd. Name `wall.ktx2` and the renderer loads the first sibling holding a format this device really samples. It checks the format in the file's header, not its name, and falls back to `wall.ktx2` itself. ```bash ./tool/cook_textures.sh models/lamp/textures --gltf models/lamp/lamp.gltf ``` That writes `models/lamp/textures.cooked/`, or wherever `--into` says. It builds the cooker the first time. The other flags are `--targets` (any of `astc`, `bc`, `etc2` and `basis`, all four by default), `--threads`, `--max-size` and `--lossless`. A file can't tell you whether it's a colour or a normal map, so the cook needs to be told: - **With `--gltf`**, the scene decides. `normalTexture` is a normal map, renormalised at every level with all three channels kept. The base colour of a `MASK` material is a cut-out, with its coverage kept at every level so foliage doesn't thin out with distance. Base colour, emissive, sheen and specular colour are sRGB, and everything else is linear. - **Without it**, file names decide. `*Normal*` is a normal map, and `*BaseColor*`, `*Albedo*`, `*Diffuse*` and `*Emissive*` are sRGB. There are no cut-outs, since nothing in a name says where the alpha test is. `--lossless` is for sprites. It writes RGBA8 in `wall.ktx2` alone, with no mips and no siblings, and the pixels come out exactly as they went in. A cook can be interrupted and resumed. Each file goes in under another name and is renamed into place, and a file that's newer than its source and was cooked with the same flags is skipped. The same input cooks to the same bytes, whatever the thread count. The script never makes single-channel textures or two-channel normal maps, because it can't know they're safe: glTF often packs occlusion and metal-roughness into one image, and no Orblit material rebuilds a normal's third channel. `orblit_texture_cook` has `--single-channel` and `--two-channel-normals` for when you know better. A glTF that already names `.ktx2` files, like the Bistro's, can be pointed at the cooked folder as it is. The renderer only looks for siblings of a name ending in `.ktx2`, so a scene that names PNGs gets PNGs. ## What it's worth Loading the Bistro exterior on an M4 Pro, three runs each: | | Arrives in | Longest frame | |---|---|---| | Before, Basis | 2.0–5.1 s | 1.1–4.4 s | | Now, Basis | 2.1–2.5 s | 94–183 ms | | Now, cooked | 0.83–1.03 s | 78–92 ms | That last longest frame is the model's first draw, not texture work. In the gallery, the cooked Bistro arrived in 1.35–1.65 s, against 3.3–3.7 s for Basis before. Basis on a busy machine can now be slower than it was, at 3.8–7.8 s, because its work is spread out rather than taken in one frozen frame. Cook them. ## Budgets How large textures load and how much uploads per frame both start from the device's tier: | Tier | Largest side | Upload per frame | |---|---|---| | Low | 1024 | 4 MB | | Medium | 2048 | 16 MB | | High | 4096 | 32 MB | The largest side is also capped at what the device supports. Set either yourself on the pipeline: ```dart final pipeline = OrblitPipeline( textures: OrblitTextureLimits(maxSize: 1024, uploadKilobytes: 8192), ); return pipeline; ``` A texture larger than `maxSize` drops its largest mip levels and never uploads them. A picture without mips is halved as it decodes. The cooked Bistro takes 1195 MB at full size and 299 MB at 1024. Textures already loaded keep their size. Left null, `uploadKilobytes` isn't fixed. It starts at the tier's figure and follows what frames actually cost while textures arrive. A number you set is held exactly. A frame always uploads at least one level, however large, or a level bigger than the budget would never go. The tiers are starting points: no phone has measured them yet. ## In a browser A browser has no files to look beside, so ask the device which ones are worth fetching, and provide the first that exists: ```dart import 'dart:typed_data'; import 'package:orblit_filament/orblit_filament.dart'; Future cookedWall( OrblitDeviceProfile device, Future Function(String path) fetch, ) async { for (final candidate in device.textureCandidates('textures/wall.ktx2')) { final bytes = await fetch(candidate); if (bytes == null) continue; await OrblitResources.provide(OrblitResources.nameFor(candidate), bytes); break; } final name = OrblitResources.nameFor('textures/wall.ktx2'); return OrblitMaterial(key: 1, baseColourMap: OrblitTexture(name)); } ``` `textureCandidates` lists the siblings in the renderer's order, with the universal file last. [Splats](https://orblitengine.com/docs/guides/splats/#the-device) shows how to get the profile. Decoding in a browser runs on Web Workers, and no page task went over 50 ms while twelve 2048² textures loaded, against 118–206 ms before. Environments load the same way. `OrblitEnvironment.fromImage` takes an `.hdr` or `.exr` and filters it while the scene runs, which [Lighting](https://orblitengine.com/docs/guides/lighting/#straight-from-the-picture) covers. ## Limits - **Nothing here fetches.** A cooked set served from a server is [an asset over a network](https://orblitengine.com/docs/guides/network-assets/), which loads it in stages so a coarse mip is on screen while the rest arrives. - **Apple devices take no sRGB ASTC.** Filament's Metal backend doesn't sample it, so colour comes from the BC file on a Mac and the ETC2 file on iOS. - **ETC2 is ETC1 quality for now.** The cooker makes its ETC2 files from UASTC, which only reaches the ETC1 subset: 23.6 dB on the Bistro's cobblestone normal map. iOS uses it for colour, and so does any device that has neither ASTC nor BC. - **The Bistro's own normal maps store two channels.** Nothing rebuilds the third, so it draws with bent normals, cooked or not. - **A Basis file with no mips** can't show a smaller level while it waits. - **Seen on** macOS, Chrome with WebGL 2, the iOS simulator and the Android emulator. Not run on Linux, Windows, Android on OpenGL ES, phones, Safari or Firefox. # Gaussian splats > Drawing captured places as clouds of 3D Gaussians, from a file or from memory, sorted off the drawing thread and held to what the device can carry. Read online at https://orblitengine.com/docs/guides/splats/ A Gaussian splat capture stores a place as millions of small, soft, coloured ellipsoids rather than as surfaces. A trainer fits them to photographs until, seen from where the photographs were taken, they add up to the place. Each one is drawn as an ellipse the size it projects to, fading out towards its edges and blended over whatever's behind it. That means they have to be drawn back to front, and sorted again whenever the camera moves. `OrblitScene.splats` takes a list of `OrblitSplats`, one per cloud. ## A capture ```dart import 'dart:math' as math; import 'package:orblit_filament/orblit_filament.dart'; import 'package:vector_math/vector_math_64.dart'; OrblitSplats garden(OrblitDeviceProfile? device) => OrblitSplats( key: 1, path: OrblitResources.nameFor('garden.osplat'), // Structure-from-motion leaves a lot of captures upside down. transform: Matrix4.rotationX(math.pi), harmonics: math.min(2, device?.harmonicDegree ?? 2), limit: device?.splatBudget, coarseOrder: device?.coarseSplatOrder ?? false, ); OrblitScene place(OrblitCamera camera, OrblitDeviceProfile? device) => OrblitScene(camera: camera, objects: const [], splats: [garden(device)]); ``` `path` is a file, or a name you've [provided bytes for](https://orblitengine.com/docs/examples/a-model-on-screen/#where-the-bytes-come-from). Four formats read: | Format | What it is | View-dependent colour | |---|---|---| | `.ply` | What the reference trainer writes | Up to degree 3 | | `.splat` | Compact 32-byte records | None | | `.spz` | Niantic's compressed format, versions 2 and 3 | Up to degree 3 | | `.osplat` | The cloud exactly as the renderer holds it | Whatever it was cooked with | A name that doesn't end in `.ply`, `.spz` or `.osplat` is read as `.splat` records. `transform` takes the capture's coordinates to the world's. A capture comes out of structure-from-motion facing whichever way the first photograph did, and a half turn about x is the usual fix. A `.spz` is turned into the same frame as a `.ply` as it's read, so one transform does for either. Scaling the transform scales every splat's size along with its position, so a capture scaled up is the same picture, only larger. `opacity` multiplies every splat's own opacity, and `brightness` its colour. Splats are drawn after the solid scene. They test against its depth but never write any, so a wall in front of a cloud hides it, and a cloud never hides a wall. They aren't lit, because a capture's lighting is already in its colours, and they neither cast nor receive shadows. ## The device `OrblitDeviceProfile` puts each device in a tier, and three answers for splats hang off it: | Tier | `splatBudget` | `harmonicDegree` | `coarseSplatOrder` | |---|---|---|---| | Low | 250,000 | 0 | true | | Medium | 1,000,000 | 2 | false | | High | 3,000,000 | 3 | false | A device is low if its `featureLevel` is below 3, it can't hold a 4096 texture, it has two threads or fewer, or it has under 3 GB of memory. High needs 8192 textures, eight threads and 8 GB, and a device that won't say how much memory it has can't be high. Everything else is medium. These are starting points, and no phone has measured them yet. The view knows which device it's on: ```dart import 'package:flutter/widgets.dart'; import 'package:orblit_filament/orblit_filament.dart'; class Place extends StatefulWidget { const Place({super.key, required this.scene}); final OrblitScene Function(OrblitDeviceProfile? device) scene; @override State createState() => _PlaceState(); } class _PlaceState extends State { OrblitDeviceProfile? device; // Null until the renderer has started, which in a browser is a frame or two // after the view is laid out, so this asks again until there's an answer. Future learn(int viewport) async { for (var attempt = 0; attempt < 50 && mounted; attempt++) { final profile = await OrblitView.profileOf(viewport); if (profile != null) { if (mounted) setState(() => device = profile); return; } await Future.delayed(const Duration(milliseconds: 100)); } } @override Widget build(BuildContext context) => OrblitView(scene: widget.scene(device), onViewport: learn); } ``` `onViewport` hands you the view's number once the renderer has one, and `OrblitView.profileOf` asks what that viewport's device can do. The answer is measured once, when the renderer starts, so ask until you get it and then keep it. The gallery does exactly this, every 100 ms for up to five seconds. A capture sent before the answer arrives is read at degree 2 with no limit, and then read again once the answer does, because a new degree or limit reads the file again. On a small device, leave the capture out of the scene until you know what the device is. ## Made in Dart A cloud can come from memory instead, in the same 32-byte records a `.splat` file holds: ```dart import 'dart:math' as math; import 'dart:typed_data'; import 'package:orblit_filament/orblit_filament.dart'; // A ring of 10,000 flat, orange discs, 2 m across. Uint8List ring() { const count = 10000; final positions = Float32List(count * 3); final scales = Float32List(count * 3); final colours = Float32List(count * 4); for (var i = 0; i < count; i++) { final angle = i / count * 2 * math.pi; positions[i * 3] = math.cos(angle); positions[i * 3 + 2] = math.sin(angle); scales.setAll(i * 3, [0.03, 0.001, 0.03]); colours.setAll(i * 4, [0.9, 0.5, 0.2, 0.6]); } return OrblitSplats.pack( positions: positions, scales: scales, colours: colours, ); } OrblitSplats ringSplats(Uint8List records, int revision) => OrblitSplats(key: 2, data: records, revision: revision); ``` Scales are standard deviations in metres, not the logarithms a `.ply` stores. Colours are red, green and blue from 0 to 1, with alpha as the splat's peak opacity. `rotations` is optional, as quaternions in (w, x, y, z) order. Colour and rotation are kept to a byte a channel, which is what a `.splat` does. Build the records once and keep them. They only travel to the renderer when `revision` changes, so bump it when you've written new ones and leave it alone otherwise. A few hundred thousand splats is megabytes, and a cloud that's only being looked at shouldn't send any of it. There's no room in the records for view-dependent colour, so a cloud made this way is flat whatever `harmonics` says. ## View-dependent colour A trainer fits each splat's colour as spherical harmonics, so a surface can be one colour from here and another from over there. That's what a polished floor, a window or wet tarmac looks like. `harmonics` says how many degrees of that to read, from 0 to 3, and each one costs 16 bytes a splat. At a million splats that's 16 MB for degree 1, 32 MB for degree 2 and 48 MB for degree 3, on top of the 48 MB the splats themselves take. The default is 2, which is what most captures are trained to. A file trained to a lower degree than you ask for is read as far as it goes. One trained higher is read to your degree, and the scene notes say what was left out. ## Limits `limit` is the most splats to draw, up to 16,777,215. As the cloud is read, every splat is ranked by how opaque it is and how much of the screen it can cover, and the limit keeps the ones at the top. So what a smaller budget takes away is the faint, small splats a capture is thickest with, rather than a random share of everything, and the rest never reach the GPU. The scene notes say when a limit dropped any. A limit is applied as the cloud is read, so a new one reads a file again. A cloud from memory takes a new limit with its next `revision`. ## Sorting Whenever the camera moves, the cloud is sorted again, natively on a thread of its own, or on a Web Worker in a browser. A browser doesn't get threads because they'd need the page to be cross-origin isolated and a second build of Filament. A cloud of 16,384 splats or fewer is sorted on the thread that asks, which is quicker than handing it over. A page that won't start a worker has its clouds sorted on the page's own thread, with a line in the console saying so. A worker that hasn't answered in half a second is sorted for on the page too, since a frame spent sorting is better than a cloud left in the wrong order. Before sorting, splats behind the camera or more than a quarter of a screen past its edge are dropped, and only the ones kept are uploaded and drawn. `coarseOrder` sorts on 16 bits of depth instead of 32, which is half the passes. Splats closer together than a 65,536th of the depth the visible ones span can come out in either order. It's there for a device where a full sort can't keep up with a turning camera. `sorted: false` draws every splat in the order given, and only exists to measure what sorting is worth. ## Cooking a capture A `.ply` spends its load working out an exponential, a quaternion and a covariance for every splat. An `.osplat` holds the results, so opening one is a read and four copies. `native/headless/build.sh` in `orblit_filament` builds `orblit_splat_cook`, and that script is written for a Mac: ```bash orblit_splat_cook garden.ply garden.osplat --harmonics 2 --limit 1000000 ``` It reads anything the renderer reads, including an `.osplat` cooked at a higher degree. `--harmonics` defaults to 3, and `--limit` ranks splats the same way the renderer does, so a cooked file can be the small one a phone loads. Afterwards it reads both files again and prints how long each took. For one of Niantic's `.spz` samples, reading went from 340 ms to 17 ms, and the cooked file drew the same frame to the byte. A 300,000-splat `.ply` at degree 3 went from 67 ms to 6 ms. ## What it's worth - Niantic's own `.spz` samples, of 786,233 and 932,560 splats at degree 3, read and draw. - From a viewpoint inside the 786,233-splat capture, culling kept 519,095 of them. - On an M4 Pro, a real capture of 786,000 splats costs 6.6 ms of GPU a frame. A generated ring of a million splats costs about 45 ms at 1600×1200, both before culling was added and after. Every splat in that ring is a large, half-transparent disc, and all of it's on screen, so there's nothing to cull and every pixel is drawn many times over. - With the clock held, native frames of the ring were byte-identical before and after culling and the new sorter went in, at 300,000 splats and at a million. - The browser's sorting worker was seen answering in 18.6 ms. Headless Chrome's virtual clock usually doesn't get round to the worker before the screenshot, so the browser check mostly exercises the half-second fallback instead. ## In a scene file A `splats` component has an `asset`, a `budget` and `harmonics`. The stage passes the budget as `limit` and clamps harmonics to 0–3, or uses 2 if the file doesn't say. It doesn't ask the device, so a component with no budget draws every splat. See [Scene files](https://orblitengine.com/docs/guides/scene-files/). ## Where it's been seen The [Gaussian splats](https://orblitengine.com/docs/gallery/content/#gaussian-splats) example generates its cloud, so there's nothing to download: a ring of 100,000, 300,000 or a million flat, half-transparent discs, striped so its near and far sides are different colours. Turn the sort off and you can see exactly what goes wrong where the two sides overlap. A solid pillar stands in the ring to show the depth rule, and the cloud is held to the device's budget, degree and coarse sort. Splats have been seen on macOS and in Chrome. They haven't been checked on Android or iOS hardware, on Windows or Linux, or in any browser but Chrome. # Materials that inherit > Material files that state only what they change, a library that resolves the chain once, groups that override a whole set from outside, and looks that reclothe a scene without editing it. Read online at https://orblitengine.com/docs/guides/materials/ A material is a small file — `.omat` — that says what a surface is. It is JSON, it is meant to be read and merged by hand, and it states only what it changes. Everything else comes from the material it inherits from. ```json { "parent": "materials/painted.omat", "values": { "roughness": 0.9, "metallic": 0.0 }, "maps": { "baseColour": "textures/brick.ktx2", "normal": "textures/brick_normal.ktx2" } } ``` Two tables, not one, and the split matters: `values` are numbers the renderer sets, `maps` are paths the cook has to follow. Anything that wants to know which textures a project uses reads `maps` and stops. ## Values and maps The parameter names are a fixed table. A file naming something that isn't in it is kept — the rest of the file still loads — and the unknown name is reported, so a typo shows up instead of quietly doing nothing. | Group | Parameters | | --- | --- | | Surface | `shading`, `blend`, `culling`, `doubleSided` | | Physical | `baseColour`, `metallic`, `roughness`, `reflectance`, `clearCoat`, `clearCoatRoughness`, `anisotropy`, `sheenColour`, `sheenRoughness` | | Light it gives off | `emissive`, `emissiveIntensity`, `ambientOcclusion`, `normalScale` | | How maps are sampled | `tiling`, `offset`, `wrap`, `filter` | | Drawing | `maskThreshold`, `depthWrite`, `depthBias`, `screenMapped` | | Blending two surfaces | `blendMode`, `blendAmount`, `blendSharpness`, `blendTiling`, `blendOffset` | | Wind | `windBearing`, `windSpeed`, `windStrength` | The ones that take a name rather than a number take one of a fixed set: `shading` is `lit`, `unlit`, `video` or `shadowCatcher`; `blend` is `opaque`, `transparent`, `fade`, `masked` or `add`; `culling` is `back`, `front` or `none`; `wrap` is `repeat`, `clamp` or `mirror`; `filter` is `smooth` or `sharp`; `blendMode` is `none`, `linear`, `masked` or `maskedDepth`. Spelt out rather than numbered, because a material is something you read in a diff. The map slots are `baseColour`, `normal`, `metallicRoughness`, `occlusion`, `emissive`, `blendBaseColour` and `blendMask`. Each names a project path, and what's on the end of it should be a [cooked texture](https://orblitengine.com/docs/guides/textures/). Colours are linear, and `baseColour` has an alpha. `sheenColour` and `emissive` don't, because a sheen can't be partly present. ## Resolving a chain A `MaterialDocument` is what one file says. A `ResolvedMaterial` is what a surface finally wears, with its ancestors and its group spent. The library does that once and caches it, so four hundred crates wearing eleven materials walk eleven chains. ```dart import 'package:orblit_scene/orblit_scene.dart'; MaterialLibrary projectMaterials() { final library = MaterialLibrary(); library.put( 'materials/painted.omat', const MaterialDocument( values: {'shading': 'lit', 'roughness': 0.6, 'metallic': 0.0}, ), ); library.put( 'materials/brick.omat', const MaterialDocument( parent: 'materials/painted.omat', values: {'roughness': 0.9}, maps: {'baseColour': 'textures/brick.ktx2'}, ), ); return library; } void main() { final brick = projectMaterials().resolve('materials/brick.omat'); print(brick.number('roughness')); // 0.9, this file's own print(brick.number('metallic')); // 0.0, inherited print(brick.maps['baseColour']); // textures/brick.ktx2 print(brick.problems); // empty } ``` The order is eldest ancestor first, each descendant over the top, then the group. A parameter nothing in the chain set comes back null, and the renderer's own default stands in — so a material that says nothing looks the same whether it resolved through ten ancestors or none. `problems` holds what went wrong on the way: a parent that doesn't exist, a chain that loops. Reported rather than thrown, because a material that lost its parent still draws, in the colours it states itself. Reading one off disk is yours to do — the package has no filesystem in it, on purpose, so the same code runs in the editor, in a cook step and in a browser. ```dart import 'dart:convert'; import 'dart:io'; import 'package:orblit_scene/orblit_scene.dart'; MaterialLibrary read(Directory dir) { final library = MaterialLibrary(); for (final file in dir.listSync().whereType()) { if (!file.path.endsWith(materialExtension)) continue; final json = jsonDecode(file.readAsStringSync()) as Map; final load = MaterialDocument.fromJson(json); for (final problem in load.problems) { stderr.writeln('${file.path}: $problem'); } library.put(file.path, load.document); } return library; } ``` ## Groups win A group is an override laid over a whole set of materials from outside. A material names the group it belongs to; the group's values go on last. ```dart import 'package:orblit_scene/orblit_scene.dart'; void main() { final library = MaterialLibrary() ..put( 'materials/brick.omat', const MaterialDocument(group: 'weathered', values: {'roughness': 0.9}), ) ..putGroup('weathered', const MaterialDocument(values: {'roughness': 0.35})); print(library.resolve('materials/brick.omat').number('roughness')); // 0.35 } ``` The group wins, deliberately. A group that lost to every material which had bothered to state a value could override almost nothing, which is the one thing a group is for. `put` and `putGroup` clear the whole resolve cache, not just that entry — anything naming it as a parent resolved through it, and finding those costs more than resolving a project's handful of materials again. ## Looks A look reclothes a scene without editing it. Each entity says only which material it swaps to under each named look; anything with nothing to say keeps what it already wears. A winter look is authored by naming the dozen things that change, not by restating the four hundred that don't. ```dart import 'package:orblit_scene/orblit_scene.dart'; SceneEntity wall() => SceneEntity( id: 'wall', name: 'Wall', components: { SceneComponents.material: const MaterialComponent( asset: 'materials/brick.omat', looks: {'winter': 'materials/brick_snow.omat'}, ), }, ); ``` That's the same shape `KHR_materials_variants` uses, and it survives an export to glTF as a variant. Which look is showing is stated when the document is staged, not in the document. The same file shown in summer and in winter is the same file; the look is how a viewer is asking to see it, so it can change without anything being edited. ```dart import 'package:orblit_scene/orblit_scene.dart'; import 'package:orblit_stage/orblit_stage.dart'; OrblitDocumentView stage(SceneDocument document, MaterialLibrary materials) { final view = OrblitDocumentView(document, materials: materials); view.look = 'winter'; return view; } ``` `view.looks` is every look anything in the scene has something of its own for — the list to put in a menu. Materials are keyed for the renderer by the material path *and* the look that decided it, not by entity. A hundred crates wearing one material are one material and one batch; keying by entity would be a hundred compiled instances and a hundred draws that can't be merged. ## What isn't there yet - **There's no material editor.** `.omat` files are written by hand or by a tool you write. The library, the inheritance and the looks all work; the panel to drive them doesn't exist. - **Values are per material, not per instance.** Two entities wearing `brick.omat` at different roughnesses need two materials today. Per-instance overrides cross the C ABI and every platform shim, so they're a piece of work on their own. - **Nothing validates a map path against the project.** A material naming a texture that isn't there resolves fine and reports nothing; the miss shows up when the cook runs. # Exporting and importing scenes > Writing a scene as glTF, GLB or OBJ with its models grafted in, reading one back so that the round trip returns the same document, and what each format can and can't carry. Read online at https://orblitengine.com/docs/guides/exporting-scenes/ A [scene file](https://orblitengine.com/docs/guides/scene-files/) is Orblit's own format and it keeps everything. Exporting is for everything else: sending a scene to Blender, shipping one file instead of a folder, handing geometry to a tool that only speaks OBJ. ```dart import 'dart:typed_data'; import 'package:orblit_scene/orblit_scene.dart'; SceneWritten export(SceneDocument scene, MaterialLibrary materials) { final written = scene.writeAs( SceneFormat.glb, name: 'old_town', materials: materials, files: const {}, // the bytes of anything it points at ); for (final problem in written.problems) { print('not carried: $problem'); } // written.files, in the order to save them: name and bytes each. return written; } ``` `files` are handed over as bytes rather than as a way to read them, deliberately: this package has no filesystem in it, and the same export runs in the editor, in a cook step and in a browser. Only the caller knows which. `problems` is what the format couldn't hold, in the order it was met. Empty is the ordinary case and means nothing was lost. ## The three formats | | Keeps the tree | Files | For | | --- | --- | --- | --- | | `SceneFormat.gltf` | yes | `name.gltf` + `name.bin` | version control, reading in an editor | | `SceneFormat.glb` | yes | `name.glb` | shipping | | `SceneFormat.obj` | no | `name.obj` + `name.mtl` | tools that read nothing else | `files` comes back in the order to save it in: the first names the export, the rest are the sidecars it points at. `SceneFormat.keepsScene` is the same distinction as a value, for a menu that needs to warn before OBJ. OBJ is corners and faces in world space. Lights, cameras, the tree and every component that isn't geometry are gone, and each is listed in `problems` on the way out. It's there because everything opens it. **FBX is deliberately not a format you can write to.** Reading one is worth the trouble because people have them, and Orblit does, through ufbx. Writing one is a proprietary format with no public specification, and everything that opens an FBX opens a glTF. ## Models come along An entity whose mesh names an imported model — `MeshComponent(asset: …)` — is exported with that model's glTF copied in whole, its roots becoming children of the entity's node. Meshes, materials, textures, skins and animations all come with it. That's what makes the export openable somewhere else rather than a tree of empty nodes with paths in them. It needs the bytes, which is what `files` is for, keyed by the project path the scene names the model by. A model whose bytes weren't supplied exports as an empty node where the model goes, and says so in `problems`. **The bytes have to be a GLB.** Only a model carrying its own buffer can be copied into an export — a `.gltf` with a `.bin` beside it names something the exporter has no way to follow, and is refused with a problem rather than grafted half way. A model placed twice is copied once. glTF already lets any number of nodes draw one mesh, so the second placement copies the nodes and nothing else — no bytes, no accessors — and a scene with two hundred of the same tree carries one tree. The exception is a model with a skin or an animation: sharing those would have both copies bent and moved by the first one's bones, so they're copied again in full. ## Reading one back ```dart import 'dart:typed_data'; import 'package:orblit_scene/orblit_scene.dart'; SceneDocument import(Uint8List bytes) { final read = readSceneFrom(bytes, name: 'Old Town'); if (!read.wasWrittenHere) { // Someone else's glTF. Still a scene, with rather more caveats. } for (final problem in read.problems) { print(problem); } return read.document; } ``` Whether the bytes are a GLB or glTF JSON comes from the bytes, not from the name. A `.gltf` that is really a GLB is an ordinary thing to be handed, and a reader that trusts the extension fails on it with a JSON error nobody can act on. A `.gltf` points at its `.bin` by name. Pass those bytes in `files`, keyed by the URI the document names them by — and note that a scene called `old town.gltf` writes a buffer named `old%20town.bin` while the file on disk has the space in it. `readSceneFrom` looks under both. `SceneFormatException` is thrown only when there's nothing to read at all: bytes that are neither a GLB nor JSON. Everything short of that comes back in `problems`, because a scene with one unreadable node is still a scene worth opening. ## The round trip is exact The exporter writes a full record of itself. Every node carries an `extras.orblit` holding the entity's id, whether it's visible and each component as the component itself serialises it; the scene carries its settings the same way. So reading an Orblit export back is a *read*, not a re-derivation from matrices and materials, and the document that comes out encodes byte for byte to the one that went in. ```dart import 'package:orblit_scene/orblit_scene.dart'; bool roundTrips(SceneDocument scene) { final read = readSceneFrom(scene.writeAs(SceneFormat.glb).first.bytes); return read.document.encode() == scene.encode(); } ``` That's asserted in the test suite, not hoped for. Two consequences worth knowing: - **A component this build has never heard of survives.** It comes back as an `UnknownComponent` holding its JSON, so an older editor can open a scene a newer one saved, change something else and save it without quietly dropping what it didn't understand. - **Grafted models don't become entities.** The nodes copied in from an imported model carry no `extras.orblit`, and the importer skips them for exactly that reason. Without it every round trip would double the outliner. `wasWrittenHere` is how you tell the two cases apart. A glTF from anywhere else is read as best it can be — nodes become entities, transforms and lights and cameras come across — and each thing that had to be guessed at or dropped is listed in `problems`. ## What isn't there yet - **The Blender half isn't automated.** That an Orblit export opens correctly in Blender is checked by hand. Nothing in CI opens Blender. - **Export is API-only.** There's no menu item in the editor yet; `writeAs` is the whole interface. - **A second buffer isn't read.** Nothing Orblit writes has one. A foreign glTF with several is read as far as its first, and the accessors that reach past it say so in `problems`. - **Animation on an Orblit entity isn't exported.** A grafted model brings its own animation with it; an entity animated by the scene doesn't, because there's nowhere in the document for that yet. # Assets over a network > Fetching assets from a server with a policy that refuses before it downloads, a manifest that verifies what arrived, resumable downloads, and stages so there's always something to draw. Read online at https://orblitengine.com/docs/guides/network-assets/ A game that loads assets from a server is downloading somebody else's bytes and handing them to a decoder — a decoder written in C, for several of the formats. So the network side of `orblit_asset` is built the other way round from most download code: every setting is a limit rather than a permission, and the cheapest defence is not fetching the thing at all. ```dart import 'package:orblit_asset/orblit_asset.dart'; AssetFetcher fetcher() => AssetFetcher( origin: AssetOrigin.parse( 'https://cdn.example.com/game/', policy: const FetchPolicy(maxBytes: 64 << 20), ), transport: HttpTransport(), ); ``` An **origin** is where assets live and what's allowed from there. A **transport** is the thing that actually talks to a server. A **fetcher** is the queue, the retries, the verification and the cache in front of both. ## The policy refuses first `FetchPolicy` defaults to the cautious answer for everything. | | Default | What it stops | | --- | --- | --- | | `hosts` | none besides the origin's own | an asset path that redirects somewhere else | | `allowInsecure` | `false` | a texture replaced in flight on plain HTTP | | `maxBytes` | 256 MiB | a download that never ends | | `maxPixels` | 64 Mpx | a decode bomb | `hosts` is matched exactly and case-insensitively. `cdn.example.com` does not allow `evil.cdn.example.com`, because a suffix match is how an allow-list becomes an allow-anything. Add the second place a project keeps things with `policy.allowing(['assets.example.com'])`. `maxBytes` is checked twice: against the length the server declares, before the body is read, and again as the body arrives. A server that says one thing and sends another is exactly the case worth catching, and a server that declares no length at all is the case where only the second check exists. `maxPixels` is separate from `maxBytes` and not derivable from it. A 64,000 × 64,000 PNG of flat colour compresses to a few hundred kilobytes and decodes to sixteen gigabytes. A size limit stops a slow download; only a dimension limit stops that. Anything refused throws `FetchRefused`, before a connection is opened. ## A manifest is what makes bytes trustworthy An `AssetId` is what a project calls a thing — `models/robot.glb`. A `ContentHash` is what the thing actually is, byte for byte. An `AssetManifest` is the one place the two meet, and handing one to the fetcher is what turns "these bytes arrived" into "these are the right bytes". ```dart import 'package:orblit_asset/orblit_asset.dart'; AssetFetcher verified(String manifestJson) { final load = AssetManifest.read(manifestJson); for (final problem in load.problems) { print(problem); } return AssetFetcher( origin: AssetOrigin.parse('https://cdn.example.com/game/'), transport: HttpTransport(), manifest: load.manifest, ); } ``` Without a manifest nothing is verified, and the fetcher says so rather than pretending the bytes are vouched for. With one, a body whose hash doesn't match throws `FetchCorrupt` naming what was wanted and what turned up — and the bytes never reach a decoder. Keeping names and hashes apart is the whole design. A name is what people type and scenes store, so it has to stay put while the file behind it changes. A hash is what caches and downloads can trust, so it has to change whenever a single byte does. Anything that mixes the two either re-downloads what it already has or keeps what it should have thrown away. ## Fetching ```dart import 'dart:typed_data'; import 'package:orblit_asset/orblit_asset.dart'; Future load(AssetFetcher fetcher) { final job = fetcher.fetch( AssetId.parse('models/robot.glb'), urgency: FetchUrgency.onScreen, onProgress: (p) => print('${p.received} of ${p.total ?? '?'}'), ); // job.cancel() closes the connection and fails job.bytes with // FetchCancelled, which is what makes it safe to start one per visible // thing and drop them when the scene changes. return job.bytes; } ``` Urgency is the queue's ordering, not a priority hint that gets ignored: `onScreen` is something being looked at now, `soon` is the next room, and `eventually` is warming the cache and should never delay either of the others. Four downloads run at once by default. A failed attempt is retried three times with a jittered backoff, but only when retrying could work. A 5xx, a 408, a 425 or a 429 is a server having a moment. A 404 or a 403 is an answer: retrying it is a slower way to fail, and on a metered connection an expensive one. What's left over throws `FetchFailed`, carrying the URL, how many attempts were made and the cause. A download that stops partway resumes from where it stopped rather than starting again, and a copy already held is revalidated with its ETag — which costs a round trip and no body when it's still current. That's the difference between a cold start and a warm one on a connection where the bytes are the expensive part. `fetcher.read(id)` is `fetch(id).bytes` for when there's nothing to cancel or watch. ## As a source, and in stages `NetworkAssetSource` makes a fetcher into an ordinary `AssetSource`, which is what lets it be a layer under the ones already on the device. ```dart import 'package:orblit_asset/orblit_asset.dart'; AssetSource layered(AssetFetcher fetcher, AssetSource bundled) => LayeredAssetSource([bundled, NetworkAssetSource(fetcher)]); ``` A 404 becomes `AssetNotFound`, which is the only thing `LayeredAssetSource` moves to the next source on. A refused host, a hash that doesn't match, a connection that never came back — all passed on untouched, because "the server hasn't got it" and "the server couldn't be reached" must not lead to the same place. Quietly serving something older for the second hides an outage. When there needs to be something on screen before the real asset lands: ```dart import 'package:orblit_asset/orblit_asset.dart'; Stream wall(AssetFetcher fetcher) => fetcher.fetchInStages( AssetId.parse('textures/wall.ktx2'), standIn: AssetId.parse('textures/wall_small.ktx2'), roughSize: 256, ); ``` At most two stages, and often one. The stand-in is only handed over if it arrived, 100 ms have passed, and the real asset hasn't turned up in the meantime — which on a warm cache it will have, so a second launch shows no stand-in at all rather than flashing one for a frame. That flash is why the delay exists: substituting a blurry texture for a sharp one is worth it while the wait is long, and worse than nothing when the wait is three frames. A [mipped `.ktx2`](https://orblitengine.com/docs/guides/textures/) gets a third stage for free: the coarse levels, built out of the front of the file while the rest is still arriving. No extra request and no extra byte — those bytes were coming anyway — and because it's the same asset rather than a substitute, it supersedes the stand-in and is never followed by one. Cancelling the subscription cancels both fetches. ## Testing without a network `MapTransport` answers from a map and understands enough of the protocol to exercise the code that depends on it: ETags it answers 304 to, ranges it honours, and a list of faults to hand out before it starts succeeding — including a reply that sends most of a large file and then drops, which is the failure resuming exists for and the one a test that only fails early never sees. ```dart import 'dart:typed_data'; import 'package:orblit_asset/orblit_asset.dart'; AssetFetcher offline(Uint8List robot) => AssetFetcher( origin: AssetOrigin.parse('https://example.test/game/'), transport: MapTransport({ Uri.parse('https://example.test/game/models/robot.glb'): TransportPage( robot, etag: 'v1', faults: [const Fault.status(503), const Fault.thrown()], ), }), ); ``` `transport.sent` is every request in order, which is how you assert that the second launch sent an `if-none-match` and downloaded nothing. ## What isn't there yet - **Nothing serves this yet.** There's no Orblit-side host or CDN layout — you point an origin at a directory you publish however you already publish things, with a manifest beside it. - **The renderer doesn't fetch on its own.** Streaming a splat capture's subset over the network, in particular, is renderer work rather than network work and isn't wired up. - **A browser cache is used where one exists**, through `CacheStorage`, but there's no eviction policy across platforms — a `ContentStore` grows until something clears it. # Running the examples > The applications you can clone and run today, and what each one is for. Read online at https://orblitengine.com/docs/examples/running-them/ The pages in this section are complete programs you can paste into `lib/main.dart`. This one is about the examples that already exist as applications, because reading a technique next to a slider that changes it is worth a good deal more than reading it on its own. All of them render. The applications in `orblit-examples` are set up for macOS, and the engine carries a second copy of the gallery that builds for the other platforms too. See [platform support](https://orblitengine.com/docs/reference/platform-support/) for how far each platform has got. ## The gallery Every rendering technique, one at a time. Pick one from the list, change it with the controls, and read the lines that do it in the panel beside it. The code there is coloured by what each word is, and a button copies it or opens it wider. ```sh git clone https://github.com/ChxisB/orblit.git git clone https://github.com/ChxisB/orblit-examples.git git clone --recurse-submodules https://github.com/ChxisB/orblit-script.git bash orblit/packages/orblit_filament/darwin/setup.sh # the Mac setup, once cd orblit-examples ./tool/link_local.sh # point the examples at the sibling checkouts cd gallery && flutter run -d macos ``` `orblit-script` is there for the three TypeScript examples, and `--recurse-submodules` fetches the JavaScript engine they run on. Without that clone, `link_local.sh` skips the gallery and says so. The setup line is [the Mac setup](https://orblitengine.com/docs/start/setup/apple/#running-the-setup), pointed at your clone rather than at pub's cache. `ORBLIT_EXAMPLE=weather flutter run -d macos` opens on one of them by name, which is what you want for a screenshot or a demo that should start where it means to. There are 39 of them: the engine's 36, and three that run TypeScript. The list keeps them under nine headings, in the order they're worth meeting, and each heading has a page here with every example's code as its panel shows it: [Getting started](https://orblitengine.com/docs/gallery/basics/) (3), [Lighting & shadows](https://orblitengine.com/docs/gallery/lighting/) (6), [Materials & textures](https://orblitengine.com/docs/gallery/materials/) (4), [Sky & atmosphere](https://orblitengine.com/docs/gallery/atmosphere/) (4), [Effects](https://orblitengine.com/docs/gallery/effects/) (4), [Models & media](https://orblitengine.com/docs/gallery/content/) (5), [Scripting](https://orblitengine.com/docs/gallery/scripting/) (3), [Performance](https://orblitengine.com/docs/gallery/performance/) (6) and [Showcases](https://orblitengine.com/docs/gallery/showcases/) (4). Only the heading holding the example on screen starts open, and clicking a heading opens or folds it. A sample of what is in there: | | | | --- | --- | | A scene as a widget | A lit surface composited by Flutter, laid out like any other widget | | Lights | Sun, point and spot, in lux and lumens, with the shadows each casts | | Day and night | A sun and a moon crossing the sky, with the camera metered for both | | Weather | Haze, banks of cloud and falling weather, carried by one wind | | A thousand objects | A whole scene sent every frame, and only what moved paid for | | Meshes | A glTF file, loaded once and instanced, with failures reported back | | Imported models | Clips, material variants and lights out of glTF files, named by the files themselves | | Textures | A wall of textures named at once: pictures, Basis and cooked sets chosen by the device, lit by a picture filtered at run time | | Virtual cameras | Third person, first person and flat, blended between, framing drawn | | Gaussian splats | A cloud of 3D Gaussians drawn as ellipses, sorted back to front off the render thread and held to what the device can carry | | Sprites | Pixel art in layers: one draw a layer, and a backdrop that scrolls without resending a tile | | Scene files | A `.oscene` document parsed and drawn, in 2D and in 3D, with an edit applied as a diff rather than a rebuild | | Runner | A runner you can play: dodge, jump, slide and pick up coins | Some of them want files the repository doesn't carry. The Bistro and Textures examples use the Bistro scene, which the engine's `tool/fetch_bistro.sh` downloads, and `tool/fetch_import_samples.sh` fetches the models the Imported models example shows. The Video example plays a film you name, through `ORBLIT_VIDEO` or by pasting a path into its settings. The Runner is a game rather than a demonstration. Click it, then use the arrow keys or WASD: left and right change lane, up or Space jumps, down slides under a bar, and Esc pauses. Its settings are Autopilot, Can't crash, Top speed (20–40 m/s) and Start again. Every model and texture in it is built in code and handed over with `OrblitResources.provide`, so it needs no download. Most of them live in the engine's own `orblit_examples` package rather than in the examples repository, because the editor shows the same ones beside the project you are working on. An example written twice is an example that drifts. ### On other platforms The engine repository has its own gallery app, `examples/gallery`, with the 36 engine examples and targets for macOS, iOS, Android, Linux, Windows and the web. It depends on the packages by path, so there's nothing to link: ```sh cd orblit/examples/gallery flutter devices # a phone or simulator goes by its id flutter run -d # or -d linux, windows or chrome ``` Each platform wants its machine set up first, and [setting up each platform](https://orblitengine.com/docs/start/setup/apple/) covers what that takes. On a Mac, that includes running `packages/orblit_filament/darwin/setup.sh` once, for iOS as well as macOS. On a phone there's no environment to set `ORBLIT_EXAMPLE` from, so a button over the scene opens a list instead. In a browser the switches go in the query string, as `?ORBLIT_EXAMPLE=Sprites`. The web build also needs the renderer compiled to WebAssembly and copied into `web/` first, which [setting up the web](https://orblitengine.com/docs/start/setup/web/) walks through. This gallery shows the scene and nothing else: no settings, no code panel and no headings, and nothing an example draws over its scene, so the Runner stands at the start with nothing to press. Of the platforms it builds for, it has been seen to draw on macOS, the iOS simulator, an Android handset and Chrome. Linux has only been run on Mesa's software rasterisers, and nothing has drawn on Windows yet. ## The viewport The narrower point, made as plainly as possible: a Filament scene as an ordinary Flutter widget. It lays out, it clips, and it sits beside a panel that resizes it. ```sh cd orblit-examples/viewport && flutter run -d macos ``` If you only believe one claim on this site before trying the engine, make it this one, because everything else follows from it. ## The simulation The engine with no window at all: an entity-component world, a transform hierarchy, a system written in Dart over column views, and the result replicated to a second world. ```sh dart run orblit-examples/simulation/bin/simulation.dart ``` Plain Dart, so it runs anywhere, including a CI runner with no display. That's the part people underestimate: most of the engine is testable without a GPU, and most of its own tests do exactly that. ## Orblit Craft A whole small game rather than a demonstration: a block world you can walk around, dig and build in. ```sh git clone https://github.com/ChxisB/orblit-craft.git cd orblit-craft && flutter run -d macos ``` It generates 129 blocks square and 48 tall from a seed, meshes it as 25 columns 32 blocks across, so a dig rebuilds one column rather than the world, and hands those to the renderer. It exists to put weight on the parts the small examples do not: geometry built at runtime through `orblit_mesh` rather than loaded from a file, one material for a whole landscape, and cutout geometry drawn against blended. That weight is the point. The 16-bit index limit in `orblit_mesh` was found by this game and is why the package now writes 32-bit ones. ## If one of them does not build The commonest cause is the checkouts not being siblings, or not being named exactly `orblit`, `orblit-examples` and `orblit-script`. The overrides `link_local.sh` writes aren't committed, so nothing ships wired to a path on one machine. That's the right call, and it does mean the script has to be run. The next commonest, on a Mac, is `'generated/_material.h' file not found` after a `git pull` in `orblit`. New materials need the Mac setup run again, and nothing runs it for you. If it's something else, that's worth a message on [the Discord](https://discord.gg/8J37a3vNXv). An example that won't build is a bug of ours, not a mistake of yours. # A model on screen > Loading a model file, instancing it, and finding out when it did not load. Read online at https://orblitengine.com/docs/examples/a-model-on-screen/ Loading a model in Orblit is one field. `OrblitObject.mesh` takes a path to a `.gltf`, `.glb`, `.fbx` or `.obj`, and leaving it null gets you the built-in cube. On a desktop the path is a file on disk. In a browser, or from inside an Android app's archive, it's a name you've handed bytes over under, which is covered [below](#where-the-bytes-come-from). ```dart title="lib/main.dart" import 'package:flutter/material.dart'; import 'package:orblit_filament/orblit_filament.dart'; import 'package:vector_math/vector_math_64.dart' hide Colors; void main() => runApp(const ModelApp()); class ModelApp extends StatefulWidget { const ModelApp({super.key}); @override State createState() => _ModelAppState(); } class _ModelAppState extends State { /// An absolute path. A relative one is relative to wherever the application /// happened to be launched from, which is not somewhere you can rely on. static const _model = '/Users/you/models/crate.glb'; /// What the renderer said about a file it could not read, if anything. String? _note; @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Stack( children: [ Positioned.fill( child: OrblitView( scene: _scene(), // Load failures come back here rather than going to a log. // A game can name the asset it is missing; an editor can put // it in front of whoever has to fix it. onSceneNotes: (notes) { final first = notes.entries.isEmpty ? null : '${notes.entries.first.key}: ' '${notes.entries.first.value}'; if (first != _note) setState(() => _note = first); }, ), ), if (_note != null) Positioned( left: 16, bottom: 16, child: Text( _note!, style: const TextStyle(color: Colors.orange, fontSize: 12), ), ), ], ), ), ); } OrblitScene _scene() { return OrblitScene( camera: OrblitCamera( position: Vector3(0, 2.5, 7), target: Vector3(0, 0.5, 0), ), objects: [ // Three objects naming the same file. It is parsed once; the second // and third get an instance of it rather than another copy. for (var i = 0; i < 3; i++) OrblitObject( key: 100 + i, transform: Matrix4.identity() ..setTranslation(Vector3((i - 1) * 2.4, 0, 0)) ..rotateY(i * 0.4), // Used only if the file will not load and the cube stands in. colour: Vector3(0.85, 0.42, 0.16), mesh: _model, ), OrblitObject( key: 90, transform: Matrix4.identity() ..setTranslation(Vector3(0, -1.2, 0)) ..scaleByDouble(12, 0.1, 12, 1), colour: Vector3(0.18, 0.19, 0.21), castShadows: false, ), ], lights: [ OrblitLight( key: 110, kind: OrblitLightKind.directional, direction: Vector3(-0.5, -1, -0.4)..normalize(), intensity: 76000, ), ], sky: OrblitSky(ambient: 14000), ); } } ``` ## A file is parsed once, however many objects name it A scene arrives on every frame of a drag, and re-reading a glTF at sixty hertz isn't a slow path, it's an unusable one. So the path is the cache key. The first object naming a file causes it to be read, and every object after that gets an instance. Which means the way to draw a hundred of something is to write the same path a hundred times. There is no separate "load this, keep the handle" step, and therefore no handle to leak, forget or free at the wrong moment. ## A missing file draws the cube Not an exception, not a black screen, and not an empty space. A file that cannot be read is drawn as the placeholder cube, in whatever `colour` the object carried, and the reason comes back through `onSceneNotes`. That is deliberate. A throw would take down a frame for one bad asset in a scene of five hundred; silence would have you hunting for a thing that is not there. A cube where the model should be is legible from across the room. :::caution[`onSceneNotes` fires during the frame] Call `setState` from it only when something actually changed, as above. Unconditionally setting state each time it fires schedules a rebuild every frame, and the note is usually the same note. ::: ## Where the bytes come from A browser has no disk, and an Android app's assets are inside its archive, so a path means nothing to either. Hand the bytes over under a name instead, and use the name wherever a path would go: ```dart import 'package:flutter/services.dart'; import 'package:orblit_filament/orblit_filament.dart'; Future provideCrate() async { final data = await rootBundle.load('assets/crate.glb'); final name = OrblitResources.nameFor('crate.glb'); await OrblitResources.provide(name, data.buffer.asUint8List()); return name; // use as OrblitObject.mesh } ``` Every renderer in the app looks for a provided name before it looks at the disk, and that covers textures, environments, decal pictures, splat captures and the files a `.gltf` names beside itself as well as meshes. A name stands for bytes that don't change: providing it again won't reload anything already loaded, so give changed bytes a new name, with a content hash in it. A scene can arrive before its bytes do. It draws without them, and picks them up when they arrive. ## What loads and what doesn't glTF and `.glb` load as they are. A `.fbx` or `.obj` is converted to glTF when it's first named, using [ufbx](https://github.com/ufbx/ufbx), then kept for the life of the process. What couldn't be carried across is said in the scene notes. [Models](https://orblitengine.com/docs/guides/models/) has the details, including an offline converter and a list of what gets lost. There's no asset store, and nothing exports FBX. Materials come with the file. A model arrives with its own, rather than being tinted by whatever colour the object carried, which is why `colour` above is described as the fallback rather than the appearance. A file's clips, skins, material variants and lights are all reachable too, and [Models](https://orblitengine.com/docs/guides/models/) covers them. # A thousand things > Sending a whole scene every frame, and paying only for what moved. Read online at https://orblitengine.com/docs/examples/a-thousand-things/ The scene is stated whole, every frame. At two objects that is obviously fine. The question is what it costs at a thousand, and the answer is what this example is for. ```dart title="lib/main.dart" import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:orblit_filament/orblit_filament.dart'; import 'package:vector_math/vector_math_64.dart' hide Colors; void main() => runApp(const CrowdApp()); class CrowdApp extends StatefulWidget { const CrowdApp({super.key}); @override State createState() => _CrowdAppState(); } class _CrowdAppState extends State with SingleTickerProviderStateMixin { static const _count = 1000; late final Ticker _clock = createTicker((elapsed) { setState(() => _seconds = elapsed.inMicroseconds / 1e6); })..start(); double _seconds = 0; @override void dispose() { _clock.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold(body: OrblitView(scene: _scene())), ); } OrblitScene _scene() { return OrblitScene( camera: OrblitCamera( position: Vector3(0, 14, 34), target: Vector3(0, 0, 0), ), objects: [ for (var i = 0; i < _count; i++) _crate(i), OrblitObject( key: 1, transform: Matrix4.identity() ..setTranslation(Vector3(0, -2.6, 0)) ..scaleByDouble(30, 0.06, 30, 1), colour: Vector3(0.05, 0.06, 0.07), castShadows: false, ), ], lights: [ OrblitLight( key: 2, kind: OrblitLightKind.directional, direction: Vector3(-0.4, -1, -0.4)..normalize(), intensity: 78000, ), ], sky: OrblitSky(ambient: 12000), ); } OrblitObject _crate(int index) { // A spiral, so changing the count does not make everything jump. final angle = index * 2.399; final radius = math.sqrt(index + 1) * 0.62; final wobble = math.sin(_seconds + index * 0.7); return OrblitObject( // Its own number, for as long as it exists. Two objects sharing one key // is one of them quietly taking the other's place. key: 1000 + index, transform: Matrix4.identity() ..setTranslation( Vector3( math.cos(angle) * radius, -2.2 + wobble * 0.6, math.sin(angle) * radius, ), ) ..rotateY(angle + wobble * 0.3) ..scaleByDouble(0.34, 0.5, 0.34, 1), colour: Vector3(0.85, 0.42, 0.16), // A thousand shadow casters is a thousand things in the shadow map. // Worth being deliberate about at this count. castShadows: index % 7 == 0, ); } } ``` ## Nothing on the wire says what changed There is no `add`, no `move` and no `remove`. Every frame is a complete description, and the renderer works out the difference by key. Which means the reconciliation rules are short enough to state in full: - A key that was there last frame and is there now: the transform is compared before it is written, because writing one dirties the node and everything under it. - A key that is new: an instance is taken from a pool if there is one. - A key that is gone: the instance goes back to the pool rather than being destroyed. - Only a change of `mesh` rebuilds anything. Stop half of them moving and what the renderer does drops with them, without anybody telling it that half of them stopped. That is the property the whole arrangement is for. ## Keys are yours, and they are load-bearing `key: 1000 + index` is arbitrary. It only has to be **stable across frames** and **unique within the scene**. Both halves bite. A key derived from a list position changes when something earlier is removed, and every object after it silently becomes a different object: meshes rebuild, and anything that was interpolating starts again. Two objects sharing a key is worse, and quieter. One of them simply never appears, with no error, because from the renderer's side that's a single object being described twice. The ground plane above is `key: 1` and the sun is `key: 2`. Lights and objects are keyed separately, so those two don't collide, but the crates still start at 1000 to leave obvious room. It costs nothing and saves you an afternoon. ## When a thousand is not enough Individually keyed objects are the right tool up to a few thousand. Past that, when the things are the same mesh and you do not need to address them one at a time, `OrblitScene.populations` takes a buffer of transforms and draws them 64 to a call. Grass, crowds, debris, forests. The difference is addressability: an object has a key you can reason about, and a population is a block of matrices. Reach for the population when you stop caring which one is which. # A camera that follows > Two shots, a brain that picks between them, and a blend you get for free. Read online at https://orblitengine.com/docs/examples/a-camera-that-follows/ A camera that follows something is the point at which most codebases acquire their worst file. The usual shape is one camera object that four systems all write to, between the follow code, the cutscene, the aim-down-sights and the screen shake, and whichever ran last wins. `orblit_camera` takes that away by making the camera the only thing allowed to move the camera. You describe shots; a brain picks one. ```dart title="lib/main.dart" import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:orblit_camera/orblit_camera.dart'; import 'package:orblit_filament/orblit_filament.dart'; import 'package:vector_math/vector_math_64.dart' hide Colors; void main() => runApp(const FollowApp()); class FollowApp extends StatefulWidget { const FollowApp({super.key}); @override State createState() => _FollowAppState(); } class _FollowAppState extends State with SingleTickerProviderStateMixin { /// What the cameras follow. A real game implements [CameraTarget] over its /// own entity; this is the two-number version of the same thing. final FixedTarget _player = FixedTarget(Vector3.zero()); /// Behind and above, in the subject's own frame, so it stays behind when /// the player turns rather than staying north of them. late final VirtualCamera _chase = VirtualCamera( name: 'chase', priority: 20, follow: _player, lookAt: _player, body: FollowBody( offset: Vector3(0, 2.4, 7), // Per axis, because the axes want different answers: a camera may lag a // long way behind and must never float up and down. damping: Vector3(0.35, 0.18, 0.5), ), aim: const HardLookAt(), lens: const Lens(fieldOfView: 55), ); /// A fixed vantage point. Armed the whole time at a lower priority, so it /// takes over the moment the chase camera is switched off. late final VirtualCamera _tower = VirtualCamera( name: 'tower', priority: 10, lookAt: _player, body: StaticBody(Vector3(-16, 9, 16)), aim: const HardLookAt(), lens: const Lens(fieldOfView: 38), ); late final CameraBrain _brain = CameraBrain() ..add(_chase) ..add(_tower) ..snap(); late final Ticker _clock = createTicker(_tick)..start(); double _last = 0; void _tick(Duration elapsed) { final now = elapsed.inMicroseconds / 1e6; // Clamped, because a frame lost to a stutter should not teleport a camera // that damps towards its target. final delta = (now - _last).clamp(0.0, 0.1); _last = now; // Move the player along a path, facing the way it is going. final at = _pathAt(now); final ahead = _pathAt(now + 0.12); _player ..position = at ..rotation = lookRotation(ahead - at, null); _brain.update(delta); setState(() {}); } Vector3 _pathAt(double t) => Vector3(math.cos(t * 0.4) * 8, 0, math.sin(t * 0.6) * 8); @override void dispose() { _clock.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Stack( children: [ Positioned.fill( child: LayoutBuilder( builder: (context, constraints) { // The brain needs the aspect the shot is actually framed // at, and the widget is the only thing that knows it. _brain.aspect = constraints.maxWidth / constraints.maxHeight; return OrblitView(scene: _scene()); }, ), ), Positioned( left: 16, bottom: 16, // Cutting to another shot is raising a number. Nothing moves a // transform, and the blend between the two is the brain's job. child: FilledButton( onPressed: () => setState(() { _chase.enabled = !_chase.enabled; }), child: Text(_chase.enabled ? 'To the tower' : 'Back to chase'), ), ), ], ), ), ); } OrblitScene _scene() { final state = _brain.state; return OrblitScene( // The brain's answer, converted to what the renderer takes. camera: OrblitCamera( position: state.position, target: state.position + state.forward, fieldOfView: state.lens.fieldOfView, ), objects: [ OrblitObject( key: 1, transform: Matrix4.compose( _player.position + Vector3(0, 0.8, 0), _player.rotation, Vector3(0.5, 0.8, 0.9), ), colour: Vector3(0.85, 0.42, 0.16), ), // Something to move past, so the motion reads as motion. for (var i = 0; i < 14; i++) OrblitObject( key: 10 + i, transform: Matrix4.identity() ..setTranslation( Vector3( math.cos(i * 0.9) * (7 + (i % 4) * 3.5), 0.9, math.sin(i * 1.7) * (7 + (i % 5) * 2.5), ), ) ..scaleByDouble(0.7, 1.2, 0.7, 1), colour: Vector3(0.22, 0.24, 0.27), ), OrblitObject( key: 2, transform: Matrix4.identity() ..setTranslation(Vector3(0, -0.1, 0)) ..scaleByDouble(60, 0.1, 60, 1), colour: Vector3(0.08, 0.09, 0.1), castShadows: false, ), ], lights: [ OrblitLight( key: 3, kind: OrblitLightKind.directional, direction: Vector3(-0.4, -1, -0.5)..normalize(), intensity: 80000, ), ], sky: OrblitSky(ambient: 14000), ); } } ``` ## Priority, not a stack `_chase` is priority 20 and `_tower` is priority 10, and both are live the whole time. The highest-priority enabled camera is the one you see. A number rather than a stack means a camera can be armed long before it matters and take over the instant its situation arises. Think of a danger camera that raises itself when the player is spotted, without anything else in the game having to know it exists. There's no push, no pop, and no way to leave the stack unbalanced after an early return. Switching, therefore, is `_chase.enabled = false`. Nothing moves a transform, and the brain blends from wherever it was to wherever the tower is. Every camera keeps its own solution up to date whether or not it is live, so cutting to one never starts from a stale position. ## Body and aim are separate, and that is the point A shot decides **where to be** (the body) and **where to look** (the aim), and those are independent problems. "Orbit the player at four metres" and "keep the boss in the upper third of frame" are different sentences about different things. Building them as one object is how you end up with a camera that cannot do the second without undoing the first. | | | | --- | --- | | `StaticBody` | Does not move. A fixed vantage point. | | `FollowBody` | Holds an offset, damped per axis. Bind to the target's rotation for over-the-shoulder, or leave it in world space for a camera that stays north. | | `OrbitBody` | Yaw, pitch and distance around the target. | | `FramingBody` | Holds a set distance from the target along a fixed direction, moving only towards and away. | | `ScreenFollowBody` | Moves so the subject sits at a point on screen. What a flat or isometric game wants, since turning an orthographic view moves nothing through the frame. | Aims pair with any of them: `HardLookAt` points straight at the target, `StaticAim` holds a fixed rotation, and the composer aims hold the subject inside a dead zone and ease after it through a soft zone. ## The delta is clamped `(now - _last).clamp(0.0, 0.1)`, in the ticker above, is not defensive programming for its own sake. A dropped frame, a breakpoint, or the window being dragged between monitors produces a delta of a second or more. Feeding that to a camera that damps towards its target moves it most of the way there in a single step, which reads as a teleport. A tenth of a second is six frames at sixty hertz: long enough to absorb a hitch, short enough that the camera never jumps. [Cameras that frame, not follow](https://orblitengine.com/docs/guides/cameras/) covers blends, noise and the composer's dead and soft zones properly. # A menu over the game > A pause overlay on top of a 3D scene, built from real Flutter widgets, because the scene is one too. Read online at https://orblitengine.com/docs/examples/a-menu-over-the-game/ This is the example that is boring in Orblit and hard everywhere else. A pause menu over a 3D view means, in most engines, a second UI toolkit that exists only inside that engine, with its own layout rules and no way to test it without launching the game. Here the scene is a widget, so the menu is a `Stack`. ```dart title="lib/main.dart" import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:orblit_filament/orblit_filament.dart'; import 'package:orblit_ui/orblit_ui.dart'; import 'package:vector_math/vector_math_64.dart' hide Colors; void main() => runApp(const PauseApp()); class PauseApp extends StatefulWidget { const PauseApp({super.key}); @override State createState() => _PauseAppState(); } class _PauseAppState extends State with SingleTickerProviderStateMixin { late final Ticker _clock = createTicker((elapsed) { if (_paused) return; setState(() => _seconds = elapsed.inMicroseconds / 1e6); })..start(); double _seconds = 0; bool _paused = false; @override void dispose() { _clock.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold( body: Stack( fit: StackFit.expand, children: [ // The 3D view is the bottom of an ordinary stack. It is not a // platform view in a window of its own, which is why anything at // all can be drawn over it. OrblitView(scene: _scene()), if (_paused) // A translucent Flutter layer over real 3D content. This is the // thing a platform view cannot do. Positioned.fill( child: ColoredBox( color: const Color(0xAA000000), child: Center(child: _menu()), ), ), Positioned( right: 16, top: 16, child: IconButton( icon: Icon(_paused ? Icons.play_arrow : Icons.pause), color: Colors.white, onPressed: () => setState(() => _paused = !_paused), ), ), ], ), ), ); } /// The menu described as a document rather than as widgets. /// /// This is `orblit_ui`: a tree of nodes with a utility class list on each, /// built into real Flutter widgets. Worth it when the interface comes from /// somewhere else, such as a file the editor wrote, a script or a server, /// and overkill when it doesn't. A plain `Column` makes a perfectly good /// pause menu. Widget _menu() { return UiSurface( width: 320, description: const UiNode( type: 'column', classes: 'p-6 gap-4 items-center bg-slate-900 rounded-xl', children: [ UiNode( type: 'text', text: 'Paused', classes: 'text-2xl text-slate-100', ), UiNode( type: 'button', text: 'Resume', classes: 'px-4 py-2 bg-orange-600 rounded-lg text-slate-50', props: {'onPressed': 'resume'}, ), UiNode( type: 'button', text: 'Quit', classes: 'px-4 py-2 bg-slate-700 rounded-lg text-slate-50', props: {'onPressed': 'quit'}, ), ], ), // Handlers are named in the document and resolved here, so the // description carries no closures and can come from a file. onEvent: (handler, payload) { switch (handler) { case 'resume': setState(() => _paused = false); case 'quit': debugPrint('quit'); } }, ); } OrblitScene _scene() { return OrblitScene( camera: OrblitCamera( position: Vector3(4, 3, 6), target: Vector3(0, 1, 0), ), objects: [ OrblitObject( key: 1, transform: Matrix4.rotationY(_seconds) ..setTranslation(Vector3(0, 1, 0)), colour: Vector3(0.85, 0.42, 0.16), ), OrblitObject( key: 2, transform: Matrix4.identity() ..setTranslation(Vector3(0, -1, 0)) ..scaleByDouble(12, 1, 12, 1), colour: Vector3(0.18, 0.19, 0.21), ), ], lights: [ OrblitLight( key: 10, kind: OrblitLightKind.directional, direction: Vector3(-0.4, -1, -0.6)..normalize(), intensity: 100000, ), ], ); } } ``` ## Pausing is not the engine's business `if (_paused) return;` in the ticker, and that is the entire pause implementation. The scene stops being rebuilt, so it stops changing. There is no `engine.pause()`, because there is nothing accumulating that would need to be told. The scene is a function of `_seconds`, so stop advancing `_seconds` and the picture holds. The same property is why scrubbing a cutscene backwards works. See [sampled, not stepped](https://orblitengine.com/docs/concepts/sampled/). ## When to use `orblit_ui` and when to use a `Column` The menu above could have been four ordinary widgets, and for a menu written in Dart and never changed, it should be. `orblit_ui` earns its place when the description comes from **somewhere that isn't Dart**: a canvas the editor saved, a TypeScript script, a layout served over the wire. The tree carries no closures, because handlers are named strings that `onEvent` resolves, and that's exactly what lets it be data. What you get either way is real widgets at the end: laid out by Flutter, hit-tested by Flutter, drawn by Impeller. It has a widget test. It is not a second widget system pretending to be one. ## Why the overlay works at all On macOS, where this example runs, the renderer draws into an IOSurface-backed pixel buffer that Flutter's texture registry adopts directly, with no readback and no copy through the CPU. From the compositor's point of view, the 3D view is just a texture in the layer tree. So it takes part in layout. It can be clipped to a rounded rectangle, animated, put inside a `PageView`, have a panel overlap it, or be laid out beside something that resizes it. An engine that embeds through a platform view puts its content in a window on top of the application, which is why those engines generally cannot let anything overlap the viewport. [Building an interface](https://orblitengine.com/docs/guides/interfaces/) covers the styling vocabulary, theming and responsiveness. # Something that chases you > Steering behaviours for where to go, a behaviour tree for what to want, and the line between them. Read online at https://orblitengine.com/docs/examples/something-that-chases-you/ `orblit_agent` keeps two things apart that are usually conflated: **Steering** answers *where to go*: a force, computed from the world, applied this frame. **Behaviour trees** answer *what to want*, meaning which of those forces should be running at all. Build them as one thing and you get a state machine that needs rewriting every time a state is added. Here they are separate, and the tree chooses between behaviours. ```dart title="lib/main.dart" import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:orblit_agent/orblit_agent.dart'; import 'package:orblit_filament/orblit_filament.dart'; import 'package:vector_math/vector_math_64.dart' hide Colors; void main() => runApp(const ChaseApp()); class ChaseApp extends StatefulWidget { const ChaseApp({super.key}); @override State createState() => _ChaseAppState(); } class _ChaseAppState extends State with SingleTickerProviderStateMixin { static const _count = 12; static const _noticeRange = 9.0; /// The thing being chased, moving on its own path. final Steerable _player = Steerable(maxSpeed: 5, maxForce: 12); late final List _hunters = [ for (var i = 0; i < _count; i++) Steerable( position: Vector3(math.cos(i * 0.9) * 14, 0, math.sin(i * 1.3) * 14), maxSpeed: 3.4, maxForce: 7, ), ]; /// One tree, shared by every hunter. A hundred guards patrolling run one /// tree and a hundred of these. late final Node _tree = Selector([ // Chase, if there is anything to chase. Sequence([ Check('sees the player', (tick) => tick.blackboard['seen'] == true), Do('chase', (tick) { (tick.blackboard['steer'] as void Function(Steering))( Arrive(_player.position, slowingRadius: 2.5), ); return Status.running; }), ]), // Otherwise mill about. Do('wander', (tick) { (tick.blackboard['steer'] as void Function(Steering))( Wander(seed: tick.blackboard['seed']! as int, at: tick.seconds), ); return Status.running; }), ]); /// Each hunter's own place in the shared tree. late final List _brains = [ for (var i = 0; i < _count; i++) Brain(_tree), ]; late final Ticker _clock = createTicker(_tick)..start(); double _last = 0; void _tick(Duration elapsed) { final now = elapsed.inMicroseconds / 1e6; final delta = (now - _last).clamp(0.0, 0.1); _last = now; if (delta == 0) return; _player.integrate( Seek(Vector3(math.cos(now * 0.5) * 10, 0, math.sin(now * 0.7) * 10)) .force(_player), delta, ); for (var i = 0; i < _count; i++) { final hunter = _hunters[i]; Steering? wanted; _brains[i].tick( now, blackboard: { 'seed': i, 'seen': (hunter.position - _player.position).length < _noticeRange, 'steer': (Steering behaviour) => wanted = behaviour, }, ); // Whatever the tree asked for, plus the thing that is always true: // do not walk through your neighbours. Separation weighs most, because // a sum longer than the agent can push is dominated by whatever // contributed most to it. final force = Blend([ (behaviour: Separate(_hunters, radius: 1.8), weight: 1.6), if (wanted != null) (behaviour: wanted!, weight: 1.0), ]).force(hunter); hunter.integrate(force, delta); } setState(() {}); } @override void dispose() { _clock.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return MaterialApp( home: Scaffold(body: OrblitView(scene: _scene())), ); } OrblitScene _scene() { return OrblitScene( camera: OrblitCamera( position: Vector3(0, 22, 26), target: Vector3.zero(), ), objects: [ OrblitObject( key: 1, transform: Matrix4.identity() ..setTranslation(_player.position + Vector3(0, 0.6, 0)) ..scaleByDouble(0.5, 0.6, 0.5, 1), colour: Vector3(0.9, 0.75, 0.2), ), for (var i = 0; i < _count; i++) OrblitObject( key: 100 + i, transform: Matrix4.identity() ..setTranslation(_hunters[i].position + Vector3(0, 0.5, 0)) // A mover with no speed has no heading, so there is nothing to // point at and the last one would be a lie. ..rotateY(_facing(_hunters[i])) ..scaleByDouble(0.4, 0.5, 0.6, 1), colour: Vector3(0.85, 0.3, 0.2), ), OrblitObject( key: 2, transform: Matrix4.identity() ..setTranslation(Vector3(0, -0.1, 0)) ..scaleByDouble(50, 0.1, 50, 1), colour: Vector3(0.08, 0.09, 0.1), castShadows: false, ), ], lights: [ OrblitLight( key: 10, kind: OrblitLightKind.directional, direction: Vector3(-0.4, -1, -0.5)..normalize(), intensity: 80000, ), ], sky: OrblitSky(ambient: 14000), ); } double _facing(Steerable agent) { final heading = agent.heading; return heading == null ? 0 : math.atan2(heading.x, heading.z); } } ``` ## Forces add, and that is the whole composition model A steering behaviour answers one question and nothing else: which way, and how hard. It does not move anything, it does not decide whether it should be running, and it does not know the others exist. Which is why `Blend` is a list of weighted behaviours rather than an algorithm. A flock isn't a flocking implementation. It's `Separate + Align + Cohere` with three weights, and changing the weights gets you a different animal. What matters in that list is the weights, not the order, and it is easy to miss: everything is summed and the total is then clamped to `maxForce`. When the sum is longer than the agent can push, what survives is dominated by whatever contributed most. So the forces that must not be ignored, like separation and obstacle avoidance, go heavier. ## `maxSpeed` and `maxForce` are the feel The hunters above are `Steerable(maxSpeed: 3.4, maxForce: 7)`, and those two numbers do more for how they read than anything else on the page. The ratio between them is the entire character of a mover. High force against low speed is something nimble that turns on the spot. Low force against high speed is something with mass, that commits to a direction and arcs. Tuning an agent that "feels wrong" is almost always these two numbers rather than the behaviour attached to them. ## The tree is shared; the place in it is not `Node` trees are `const`-constructible and stateless. One tree serves every hunter, and `Brain` holds the per-agent memory of where in it that agent had got to. That matters because of `Status.running`. Without it every node would have to finish inside one frame, which rules out walking anywhere, waiting for anything, or playing an animation to its end. With it, a node can say "still working, ask me again", and then something has to remember which node that was. `Brain` is that something. `Selector` runs its children until one succeeds; `Sequence` runs them until one fails. The tree above is therefore "chase if you can see them, otherwise wander", which reads in that order because that is the order it runs in. :::caution[`Blend` is defined twice] `orblit_agent` and `orblit_camera` both export a `Blend`, and the two are unrelated: one sums forces, the other interpolates camera states. Importing both needs a `hide` or a prefix on one of them. `orblit_agent` also calls its time-limit decorator `Deadline` rather than `Timeout`, because every Dart test package defines `Timeout`. ::: [Things that decide for themselves](https://orblitengine.com/docs/guides/agents/) covers the rest of the behaviours and decorators. # Getting started > The three examples the gallery lists under Getting started, and the code its panel shows for each. Read online at https://orblitengine.com/docs/gallery/basics/ The three examples the [gallery](https://orblitengine.com/docs/examples/running-them/#the-gallery) lists under **Getting started**. Each block is what the gallery's code panel shows beside that example, copied from the example itself: the lines that do it rather than the whole file, so it isn't compiled here the way the guides' code is. The file it came from is linked under each one. ## A scene as a widget A lit surface composited by Flutter, laid out like any other widget. ```dart // A scene is a description, handed over whole every frame. OrblitView( scene: OrblitScene( objects: [ OrblitObject( key: 1, transform: Matrix4.identity()..rotateY(turn), colour: Vector3(0.72, 0.13, 0.08), // linear RGB ), ], lights: [ OrblitLight( key: 10, kind: OrblitLightKind.directional, intensity: 82000, // lux direction: Vector3(-0.4, -1, -0.55)..normalize(), ), ], sky: OrblitSky(colour: Vector3(0.01, 0.02, 0.03), ambient: 9000), camera: camera, ), ) // The key is what makes saying it again cheap. Same key, same object: the // renderer moves what moved instead of building the scene a second time. ``` From [`surface.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/surface.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='a scene as a widget' flutter run -d macos ``` ## Scene files A .oscene document parsed and drawn, in 2D and in 3D, with an edit applied as a diff rather than a rebuild. ```dart // The file, as the editor saves it. final load = SceneDocument.decode(text); for (final problem in load.problems) { print(problem); // what could not be read, and why } // Staged for the renderer: entities become objects, lights, sprite layers. final view = OrblitDocumentView(load.document, projectRoot: root); // Every frame. The lists are assembled; the objects in them are kept. OrblitScene scene(OrblitCamera camera, double seconds) => view.scene; // An edit. Diffed against what was there, so only the entity that moved is // rebuilt -- the other twenty are the objects the renderer already has. final next = document.withEntity(id, moved); view.apply(SceneDiff.between(document, next)); ``` From [`scene_files.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/scene_files.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='scene files' flutter run -d macos ``` ## Virtual cameras Third person, first person and flat, blended between, framing drawn. ```dart // One real camera, and as many shots as the scene has situations. Cutting to // a different angle is raising a number — nothing outside the library moves a // transform, which is what stops two systems fighting over the camera. final subject = FixedTarget(Vector3.zero()); final chase = VirtualCamera( name: 'Chase', priority: 20, follow: subject, lookAt: subject, // Behind and above in the subject's own frame, so it stays behind when the // subject turns rather than staying north of it. // Per axis, because the axes want different answers: a camera may // lag a long way behind and must not float up and down. body: FollowBody( offset: Vector3(0, 2.4, 7), damping: Vector3(0.35, 0.18, 0.5), ), aim: ComposerAim( screenY: 0.45, // Inside this, the camera holds still. A camera that corrects for every // twitch reads as a nervous operator rather than a steady one. deadZoneWidth: 0.08, deadZoneHeight: 0.10, // Between the two it eases after the subject; past the soft edge it is // dragged, because by then keeping them in frame matters more. softZoneWidth: 0.35, softZoneHeight: 0.30, damping: 0.4, ), ); final brain = CameraBrain( blends: BlendTable(defaultBlend: Blend(BlendStyle.easeInOut, 0.9)), )..add(chase)..add(watchtower)..add(orbit)..snap(); // Every frame: move the world, then ask what the camera should be doing. brain.update(delta); OrblitScene( camera: OrblitCamera( position: brain.state.position, target: brain.state.position + brain.state.forward, fieldOfView: brain.state.lens.fieldOfView, ), // ... ); // And to show the rules rather than guess at them: final guides = brain.live?.aim.guides; // dead and soft, in fractions ``` From [`cameras.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/cameras.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='virtual cameras' flutter run -d macos ``` # Lighting & shadows > The six examples the gallery lists under Lighting & shadows, and the code its panel shows for each. Read online at https://orblitengine.com/docs/gallery/lighting/ The six examples the [gallery](https://orblitengine.com/docs/examples/running-them/#the-gallery) lists under **Lighting & shadows**. Each block is what the gallery's code panel shows beside that example, copied from the example itself: the lines that do it rather than the whole file, so it isn't compiled here the way the guides' code is. The file it came from is linked under each one. ## Lights Sun, point and spot, in lux and lumens, with the shadows each casts. ```dart // Sun: lux, and a width in degrees that decides how soft its shadows are. OrblitLight( key: 200, kind: OrblitLightKind.directional, intensity: 82000, // lux direction: Vector3(-0.4, -1, -0.5)..normalize(), sunAngularRadius: 0.53, // degrees; the real sun's castShadows: true, ) // Point: lumens, a position, and a distance it stops mattering past. OrblitLight( key: 200, kind: OrblitLightKind.point, intensity: 12000, // lumens position: Vector3(3, 3.4, 3), falloffRadius: 24, // metres sourceRadius: 0.5, // how wide the bulb is ) // Spot: the same, aimed, with the cone it throws. OrblitLight( key: 200, kind: OrblitLightKind.spot, intensity: 12000, position: Vector3(3, 3.4, 3), direction: aim, innerConeAngle: 25 * pi / 180, // full brightness inside this outerConeAngle: 45 * pi / 180, // nothing outside it ) // Watts, metres and degrees belong in orblit_light, which converts them once. ``` From [`lights.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/lights.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='lights' flutter run -d macos ``` ## Panel shadows The soft shadow a rectangular light casts, and how its edge changes with the size of the panel. ```dart // A rectangle of light, hung above the floor and pointed at it. // // `castShadows` is what this example is about. A rectangle is not one of // Filament's own lights — it is shaded in the surface material against a // fitted table — so its shadow is a depth map of its own, drawn once from // where the panel stands and compared against by every surface it reaches. OrblitLight( key: 1, kind: OrblitLightKind.area, // Lumens off a surface rather than out of a point, so a wider panel // spreads the same light instead of adding more of it. intensity: 900000, position: Vector3(0, 5, 0), direction: Vector3(0, -1, 0), // Which way the width runs. A strip on its side is a different light. tangent: Vector3(1, 0, 0), width: 2, height: 2, falloffRadius: 30, castShadows: true, ) // One rectangle casts. A scene has one key light and the rest are fill, and // giving every panel a map would cost a scene render each to shadow lights // whose job is to not be noticed. A second one asking is reported and lit // without a shadow rather than dropped. ``` From [`panel_shadow.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/panel_shadow.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='panel shadows' flutter run -d macos ``` ## Shadows Filament's own shadows: sun, spot and point, with every setting — map size, cascades and splits, bias, distance, contact shadows, and the four kinds of edge. ```dart // Shadow settings belong to the pipeline, not to one light: every light // that casts reads them. Filament keeps them on each light internally, and // the renderer copies them onto every one whenever they change. OrblitScene( pipeline: OrblitPipeline( shadows: OrblitShadows( // Sharp (PCF), Soft (DPCF), Area (PCSS) or Variance (VSM). kind: OrblitShadowKind.area, mapSize: 2048, // A sun's map split into cascades running away from the camera. The // splits are fractions of the distance; null lets lambda place them. cascades: 3, splits: [0.08, 0.3], distance: 60, constantBias: 0.001, normalBias: 1.0, // Locked to the world, so an edge does not crawl as the camera turns. stable: true, // A short march through the depth buffer, for what is too small for // the map: a stick on the floor, a foot on the ground. contact: true, contactDistance: 0.3, // For Area: how wide, and how fast it widens with distance. softness: 1.0, softnessFalloff: 1.0, // Only Variance reads these. variance: OrblitVarianceShadows(blur: 4, lightBleedReduction: 0.3), ), ), lights: [ OrblitLight( key: 1, kind: OrblitLightKind.spot, // The light's real size: what an Area edge is made from. sourceRadius: 0.4, castShadows: true, ... ), ], ... ) // Not reachable: caching a shadow map between frames. Filament redraws // every map every frame and has no API to hold one still. ``` From [`shadows.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/shadows.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='shadows' flutter run -d macos ``` ## Reflection probes A chrome box reflecting the room it is in, from a cubemap the scene captured of itself. ```dart // The scene photographs itself from a point inside, and is lit by that // instead of by the environment. Captured once and kept: six renders of // the whole scene is not a per-frame cost, so bumping `version` is how a // host says the room has changed. OrblitScene( probes: [ OrblitProbe( key: 100, position: Vector3(0, -0.4, 0), // head height, not the floor radius: 14, // how far its influence reaches resolution: 256, // Everything but the reflective things. A probe captured from inside // a mirror photographs the mirror, and the mirror then reflects a // smaller copy of itself. layers: 1 << 0, ), ], // ... ) ``` From [`probes.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/probes.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='reflection probes' flutter run -d macos ``` ## Bounced light One bounce, taken from the picture already drawn: coloured walls tinting a white box between them. ```dart // One effect pass over the finished picture. It reads the colour and the // depth of the same target, so the graph names that target once. OrblitRenderGraph( targets: const [OrblitTarget(name: 'frame')], passes: [ const OrblitPass(name: 'world', into: 'frame'), OrblitPass( name: 'bounce', kind: OrblitPassKind.effect, effect: OrblitEffect.bounce, reads: const ['frame'], // How far it looks, how much comes back, how solid the depth // buffer's surfaces are, and how many directions each pixel fans // along. Nought means the renderer's own default. plane: [3.0, 4.0, 0, 0], ), ], ) ``` From [`bounce.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/bounce.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='bounced light' flutter run -d macos ``` ## Irradiance field Probes standing in the room, holding the light that reaches them, so indirect light survives the camera looking away. ```dart OrblitScene( field: OrblitField( enabled: true, origin: Vector3(-4, -2.6, -4), // where the corner probe stands spacing: Vector3(2, 2, 2), // metres between probes counts: Vector3(5, 4, 5), // how many along each axis from: 'frame', // the target the probes read intensity: 1.6, retention: 0.94, // how much survives each frame ), // The probes read the picture the scene drew, so it has to go into a // target first and then be put on the screen. graph: OrblitRenderGraph( targets: const [OrblitTarget(name: 'frame')], passes: const [ OrblitPass(name: 'world', into: 'frame'), OrblitPass( name: 'present', kind: OrblitPassKind.effect, effect: OrblitEffect.copy, reads: ['frame'], ), ], ), // ... ) ``` From [`field.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/field.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='irradiance field' flutter run -d macos ``` # Materials & textures > The four examples the gallery lists under Materials & textures, and the code its panel shows for each. Read online at https://orblitengine.com/docs/gallery/materials/ The four examples the [gallery](https://orblitengine.com/docs/examples/running-them/#the-gallery) lists under **Materials & textures**. Each block is what the gallery's code panel shows beside that example, copied from the example itself: the lines that do it rather than the whole file, so it isn't compiled here the way the guides' code is. The file it came from is linked under each one. ## Materials Metalness and roughness across a grid, five ways of blending, and textures written at startup so there is nothing to download. ```dart // A material describes what a surface is, not what it looks like. final brass = OrblitMaterial( key: 7, baseColour: Vector4(0.72, 0.45, 0.20, 1), metallic: 1.0, roughness: 0.25, ); // Maps multiply into the numbers beside them, so a texture and a slider are // the same control. Tiling is applied by the shader — there is no automatic // repeat behind your back. final floor = OrblitMaterial( key: 8, tiling: Vector2(3, 3), baseColourMap: OrblitTexture('/path/albedo.png'), normalMap: OrblitTexture('/path/normals.png', srgb: false), metallicRoughnessMap: OrblitTexture('/path/packed.png', srgb: false), ); // Blending is the one property that cannot change without recompiling the // shader, so it selects which compiled surface the object is drawn with. final glass = OrblitMaterial( key: 9, blend: OrblitBlend.transparent, baseColour: Vector4(0.5, 0.78, 0.9, 0.45), roughness: 0.15, ); // Objects name a material by its key; the renderer keeps one instance behind // however many are made of it. OrblitScene( objects: [OrblitObject(key: 1, material: 7, transform: ..., colour: ...)], materials: [brass, floor, glass], camera: camera, ); ``` From [`materials.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/materials.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='materials' flutter run -d macos ``` ## Textures A wall of textures named at once — pictures, Basis and cooked sets chosen by the device — lit by a picture filtered at run time. ```dart // On the web, fetch the best file the device samples and provide it. for (final candidate in device.textureCandidates('wall.ktx2')) { final bytes = await fetchBytes('textures/$candidate'); if (bytes == null) continue; await OrblitResources.provide( OrblitResources.nameFor('textures/$candidate'), bytes); break; } OrblitMaterial( key: 1, baseColourMap: OrblitTexture(OrblitResources.nameFor('textures/wall.ktx2')), ); OrblitScene( objects: [...], environment: OrblitEnvironment.fromImage( OrblitResources.nameFor('pictures/place.hdr')), camera: camera, ); ``` From [`textures.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/textures.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='textures' flutter run -d macos ``` ## Blending Two surfaces on one mesh: linear, masked, and masked by height. ```dart // One material, two surfaces. The mask decides between them — and what the // mask is taken to mean is the mode. OrblitMaterial( key: 1, baseColourMap: OrblitTexture(cobbles), blendBaseColourMap: OrblitTexture(grass), blendMaskMap: OrblitTexture(height, srgb: false), blendMode: OrblitBlendMode.maskedDepth, blendAmount: 0.5, // how much grass there is blendSharpness: 14, // how hard the handover is blendTiling: Vector2(5, 5), // grass at its own scale ) // linear — the amount, everywhere, ignoring the mask. // masked — the mask scaled by the amount: a proportional fade. // maskedDepth — the mask read as a height, so the low ground fills first. ``` From [`blend.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/blend.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='blending' flutter run -d macos ``` ## Decals Posters, scorches, a puddle and road paint projected onto a floor and a wall, lit and shadowed with the surface under them. ```dart // A poster: a box whose own y is the way it is thrown. A quarter turn about // x throws it along minus z, onto a wall facing the camera. The box's x and // z are the picture's width and height; its y is how deep it reaches. OrblitDecal( key: 101, position: Vector3(-1.6, 2.1, -2.9), rotation: Quaternion.axisAngle(Vector3(1, 0, 0), math.pi / 2), size: Vector3(1.6, 0.4, 2.2), texture: OrblitTexture('/path/poster.png'), ) // A puddle: mostly a colour and a roughness. Painted before the floor is // lit, so it reflects the sun where the dry floor round it does not. OrblitDecal( key: 103, position: Vector3(0.6, 0, 2.2), size: Vector3(2.6, 0.3, 1.5), texture: OrblitTexture('/path/puddle.png'), colour: Vector3(0.05, 0.06, 0.07), roughness: 0.04, ) // Paint that leaves anything on layer one alone. OrblitDecal( key: 105, position: Vector3(2.4, 0.6, 0.4), size: Vector3(2.6, 1.6, 2.6), texture: OrblitTexture('/path/splash.png'), layers: {0}, ) // Past thirty-two in one scene, the rest are reported rather than painted. ``` From [`decals.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/decals.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='decals' flutter run -d macos ``` # Sky & atmosphere > The four examples the gallery lists under Sky & atmosphere, and the code its panel shows for each. Read online at https://orblitengine.com/docs/gallery/atmosphere/ The four examples the [gallery](https://orblitengine.com/docs/examples/running-them/#the-gallery) lists under **Sky & atmosphere**. Each block is what the gallery's code panel shows beside that example, copied from the example itself: the lines that do it rather than the whole file, so it isn't compiled here the way the guides' code is. The file it came from is linked under each one. ## Day and night A sun and a moon crossing the sky, with the camera metered for both. ```dart // Where the body is, at this hour. final swing = sin((hour - 6) / 12 * pi); // up for half the day final isDay = swing > 0; final altitude = (isDay ? swing : -swing) * radians(65); final azimuth = hour / 24 * 2 * pi + (isDay ? 0 : pi); // the moon opposes // The sun falls to the moon's own strength at the horizon, so the swap is a // change of direction rather than a step in how much light there is. final lux = isDay ? 1.0 + 75000 * swing * swing : 1.0; final ambient = max(0.2, lux * 0.35); // a clear sky returns a third OrblitScene( lights: [ OrblitLight( key: 310, kind: OrblitLightKind.directional, intensity: lux, direction: -toBody..normalize(), sunAngularRadius: 0.53, // the real sun's, and the real moon's haloSize: isDay ? 12 : 3, // glare, or none: what tells them apart ), ], sky: OrblitSky(colour: skyAt(swing), ambient: ambient), // Seventeen stops between noon and moonlight. Meter it, or one of the two // is a solid colour. camera: camera.copyWith( aperture: aperture, shutterSpeed: shutter, sensitivity: iso, ), ) ``` From [`day_and_night.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/day_and_night.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='day and night' flutter run -d macos ``` ## God rays A low sun behind a colonnade, and the shafts of light the air between the pillars catches. ```dart // The scene's own directional light is the sun the shafts come from, so // they always point where the shadows do. Nothing else to wire up: with no // graph of its own, the scene gets the passes it needs. OrblitScene( lights: [sun], godRays: OrblitGodRays( strength: 0.8, // how bright; nought is off, and free decay: 0.97, // how far along its length a shaft fades density: 0.9, // how far towards the sun each pixel looks samples: 64, // smoothness against cost ), ... ) ``` From [`god_rays.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/god_rays.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='god rays' flutter run -d macos ``` ## Weather Haze, banks of cloud and falling weather, carried by one wind. ```dart // Haze for distance, banks for shape. One setting, because weather with no // haze behind it reads as cut-outs hanging in clear air. fog: OrblitFog( colour: Vector3(0.65, 0.69, 0.73), density: 0.055, // per metre height: -1.5, // where the layer lies heightFalloff: 0.4, // how fast it thins going up structure: 0.75, // above zero, banks of cloud are drawn as well wind: Vector2(1.8, -1.8), // metres a second, across the ground featureSize: 1 / 22, // one over how big a cloud is, in metres thickness: 6, // how deep the bank is ), // Rain and snow are one curtain at different settings: what separates them is // how far a drop travels while the shutter is open. precipitation: OrblitPrecipitation( amount: 0.65, fall: 9, // metres a second; snow is under one wind: wind, dropsPerMetre: 6, stretch: 30, // a streak. 1.2 is a flake. ), // The renderer moves both on its own clock, so a still scene keeps raining // without the host sending another frame. ``` From [`weather.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/weather.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='weather' flutter run -d macos ``` ## Environment volumes A sunny courtyard and a dim, dusty hall, and the look blending between them as the camera walks through the door. ```dart // The hall's own box, and what is different about being in it. Anything // left out — here the environment, the contrast, the fog's height — is left // exactly as the scene has it. OrblitEnvironmentVolume.box( key: 1, centre: Vector3(0, 2.5, -14), halfExtents: Vector3(4, 2.5, 10), // Full strength inside; fading to nothing four metres out of the door. blendDistance: 4, overrides: OrblitEnvironmentOverrides( fogDensity: 0.09, fogColour: linearOf(const Color(0xFF8A6A4C)), ambient: 2000, // lux, blended in log space skyColour: linearOf(const Color(0xFFC89A6A)), exposureCompensation: -0.5, // stops bloomStrength: 0.25, saturation: 0.85, temperature: 0.15, ), ) // A damp corner at the back, which wins where the two overlap. OrblitEnvironmentVolume.sphere( key: 2, centre: Vector3(0, 1.5, -20), radius: 3, blendDistance: 3, priority: 1, overrides: OrblitEnvironmentOverrides( fogDensity: 0.14, fogColour: linearOf(const Color(0xFF4A5C70)), ), ) // On the scene. They are resolved against the camera when it is sent, so // nothing else has to know they are there. OrblitScene(..., volumes: [hall, corner]) ``` From [`volumes.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/volumes.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='environment volumes' flutter run -d macos ``` # Effects > The four examples the gallery lists under Effects, and the code its panel shows for each. Read online at https://orblitengine.com/docs/gallery/effects/ The four examples the [gallery](https://orblitengine.com/docs/examples/running-them/#the-gallery) lists under **Effects**. Each block is what the gallery's code panel shows beside that example, copied from the example itself: the lines that do it rather than the whole file, so it isn't compiled here the way the guides' code is. The file it came from is linked under each one. ## Post-processing Bloom, depth of field, occlusion, grading and anti-aliasing, over a scene bright enough to show each of them. ```dart // Everything after the scene is drawn, on the scene rather than the camera: // a look belongs to the place, not to where somebody is standing in it. OrblitScene( objects: objects, camera: camera, post: OrblitPostProcess( bloom: OrblitBloom(enabled: true, strength: 0.25, lensFlare: true), occlusion: OrblitOcclusion(enabled: true), depthOfField: OrblitDepthOfField(enabled: true, focusDistance: 12), grading: OrblitGrading( enabled: true, toneMapping: ToneMapping.aces, exposure: 0.4, saturation: 1.1, ), ), ); ``` From [`post.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/post.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='post-processing' flutter run -d macos ``` ## Motion blur A spinning fan and a sliding block smeared by their own motion while the wall behind them stays sharp, at a shutter you choose. ```dart // The world into a target that keeps its depth, and the blur from there onto // the screen. The shutter follows the camera's own unless one is given. OrblitScene( camera: camera.copyWith(shutterSpeed: 1 / 30), graph: const OrblitMotionBlur().graph(), // ... ) // Or as one pass in a graph of your own, reading a target with depth. const OrblitMotionBlur(maxPixels: 32, objects: false) .pass(reads: 'frame', into: 'blurred') ``` From [`motion_blur.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/motion_blur.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='motion blur' flutter run -d macos ``` ## Distortion A shockwave across a chequered floor, heat rising off a vent, and a lens warping the lot. ```dart // Distortions are part of the scene, like its lights. The renderer sums // them in one pass over the finished frame — and draws no pass at all // when none of them is moving anything. OrblitScene( distortions: [ OrblitDistortion.expanding( centre: blast, age: seconds - wentOffAt, // the host's clock speed: 3.5, chromatic: 0.3, ), OrblitDistortion.haze( centre: vent + Vector3(0, 1.6, 0), halfSize: Vector3(0.9, 1.6, 0.9), seconds: seconds, ), OrblitDistortion.lens(strength: 0.1), // barrel; negative is pincushion ], ... ) ``` From [`distortion.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/distortion.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='distortion' flutter run -d macos ``` ## Outline A selection outline that follows the silhouette, and still finds an object hidden behind a wall. ```dart // The outline is on the scene, not on the objects: it is about how the // world is being looked at, and an editor changes it on every click // without touching a single object. OrblitScene( objects: objects, camera: camera, outline: OrblitOutline( // The active object, in the lighter orange. primary: behindTheWall.key, // Everything else selected, in the deeper one. keys: {besideIt.key}, width: 3, // What the wall hides is still outlined — fainter, and dashed, so it // reads as behind rather than in front. occluded: OrblitOccluded.dashed, ), ) // Colours are Flutter Colors — display colours — because the outline is // drawn after tone mapping and lands on screen as exactly what was asked // for. OrblitOutline.none, the default, draws nothing and costs nothing. ``` From [`outline.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/outline.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='outline' flutter run -d macos ``` # Models & media > The five examples the gallery lists under Models & media, and the code its panel shows for each. Read online at https://orblitengine.com/docs/gallery/content/ The five examples the [gallery](https://orblitengine.com/docs/examples/running-them/#the-gallery) lists under **Models & media**. Each block is what the gallery's code panel shows beside that example, copied from the example itself: the lines that do it rather than the whole file, so it isn't compiled here the way the guides' code is. The file it came from is linked under each one. ## Meshes A glTF file, loaded once and instanced, with failures reported back. ```dart // A path, on as many objects as want it. OrblitObject( key: 600, transform: placement, colour: Vector3(0.72, 0.13, 0.08), // used only if the file will not load mesh: '/Users/you/models/crate.glb', ) // Parsed once and kept, however many objects name it: a scene arrives on // every frame of a drag, and re-reading a glTF at that rate is unusable. // The second object using a file gets another instance of it rather than // another copy. // What could not be loaded comes back from the publish rather than going to // a log, so a host can name the asset it is missing. OrblitView( scene: scene, onSceneNotes: (notes) => setState(() => note = notes.values.first), ) ``` From [`meshes.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/meshes.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='meshes' flutter run -d macos ``` ## Imported models Clips, material variants and lights out of glTF files, named by the files themselves. ```dart // What a file holds comes back from the renderer as it is loaded. OrblitView( scene: scene, onAssetInfo: (info) => setState(() => models[info.path] = info), ) // A clip by the file's own name, fading in from the one before. final info = models['orblit:resource/fox.glb']!; OrblitObject( key: 1, transform: placement, colour: grey, mesh: 'orblit:resource/fox.glb', animation: OrblitAnimation( clip: info.clipNamed('Run')!, seconds: seconds, speed: 1, from: OrblitAnimation(clip: info.clipNamed('Walk')!, seconds: seconds), fade: 0.5, ), ) // A look the file comes in, by its name. OrblitObject(..., variant: info.variants.indexOf('beach')) // The file's lights, as ordinary lights that shadow like any other. lights: [...info.lightsFor(placement, keyOf: (i) => 100 + i)], ``` From [`imported.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/imported.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='imported models' flutter run -d macos ``` ## Gaussian splats A cloud of 3D Gaussians, drawn as ellipses, sorted back to front off the render thread and held to what the device can carry. ```dart // What this device can carry, asked of the view once its renderer is up. final device = await OrblitView.profileOf(viewport); // A capture from a file: the reference trainer's .ply, or a compact .splat. OrblitSplats( key: 1, path: 'garden.ply', // Structure-from-motion puts y down. Turned the right way up here. transform: Matrix4.rotationX(math.pi), // How much of the capture's view-dependent colour to read: 16 bytes a splat // for each degree, so a small device reads less of it. harmonics: device.harmonicDegree, // Keeps the most opaque and largest splats, and never sends the rest to the // GPU at all. limit: device.splatBudget, // Sixteen bits of depth where a full sort would lag a turning camera. coarseOrder: device.coarseSplatOrder, ) // Or a cloud made in Dart, packed into the same 32-byte layout. final data = OrblitSplats.pack( positions: positions, // three floats a splat, metres scales: scales, // three standard deviations a splat, metres colours: colours, // RGBA, nought to one; alpha is peak opacity rotations: rotations, // quaternions, (w, x, y, z) ); OrblitSplats(key: 1, data: data, revision: revision) // Sorted back to front whenever the camera moves — on a thread of the // renderer's own, or a Web Worker in a browser — leaving out what the camera // cannot see. Drawn after the solid scene, tested against its depth, never // writing any. ``` From [`splats.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/splats.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='gaussian splats' flutter run -d macos ``` ## Sprites Pixel art in layers: one draw a layer, drawn in order, and a backdrop that scrolls without resending a tile. ```dart // The sheet, as bytes: from an asset, a download or, here, painted in code. await OrblitResources.provide('orblit:resource/sheet.png', png); final atlas = Atlas.grid(image: 'sheet', imageWidth: 64, imageHeight: 64, cellWidth: 16, cellHeight: 16); // A layer is one image and any number of rectangles of it: one draw. final uv = atlas['frame_0']!.uv(64, 64); OrblitSprites( key: 2, image: const OrblitTexture('orblit:resource/sheet.png'), sprites: OrblitSprites.pack([ OrblitSprite(x: 0, y: 0, u0: uv.u0, v0: uv.v0, u1: uv.u1, v1: uv.v1), ]), revision: revision, // bump when the sprites change ) // A backdrop scrolls by its layer's transform, and sends no tiles to do it. OrblitSprites(key: 1, sprites: tiles, order: -10, transform: Matrix4.translationValues(scroll, 0, 0)) // Seen through an orthographic camera, graded straight through. OrblitCamera(position: Vector3(0, 0, 20), target: Vector3.zero(), orthographic: true, viewHeight: 18) ``` From [`sprites.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/sprites.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='sprites' flutter run -d macos ``` ## Video A film on four screens from one decoder, tinted, faded and played backwards — set ORBLIT_VIDEO or paste a path. ```dart // The film is on the scene, not on the material. One decoder, however many // screens are showing it. OrblitScene( videos: [ OrblitVideo(key: 1, path: '/path/to/a.mp4', playing: true, loop: true), ], materials: [ // A screen: unlit, because it makes its own light, and the base colour // tints the frame rather than replacing it. OrblitMaterial( key: 10, shading: OrblitShading.video, video: 1, baseColour: Vector4(1, 1, 1, 1), ), ], objects: [OrblitObject(key: 10, material: 10, transform: ..., colour: ...)], camera: camera, ); // Seeking is an event and the scene is a description, so a token reconciles // them: the renderer jumps when the token moves, not when the target does. OrblitVideo(key: 1, path: ..., seekTo: 12.0, seekToken: ++token); ``` From [`video.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/video.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='video' flutter run -d macos ``` # Scripting > The three examples the gallery lists under Scripting, and the code its panel shows for each. Read online at https://orblitengine.com/docs/gallery/scripting/ The three examples the [gallery](https://orblitengine.com/docs/examples/running-them/#the-gallery) lists under **Scripting**. Each block is what the gallery's code panel shows beside that example, copied from the example itself: the lines that do it rather than the whole file, so it isn't compiled here the way the guides' code is. The file it came from is linked under each one. ## An interface in TypeScript Two interfaces written in .tsx, running in the engine, drawn by Flutter. ```tsx // script/hud.tsx — the whole of the interface. Compiled by `npm run build` // and loaded into the engine's script host; nothing on the Dart side builds // an element. import { mount } from "orblit"; const state = { hull: 0.72, score: 1840, accent: "ember" }; /// A bar is two boxes: the track, and as much of it as is left. No progress /// widget, and no second component set. function Bar({ part }: { part: number }) { return ( ); } function Hud() { return ( Sector 12 ); } mount(() => ); ``` From [`interface.dart`](https://github.com/ChxisB/orblit-examples/blob/main/gallery/lib/src/examples/interface.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='an interface in typescript' flutter run -d macos ``` ## An interface in Dart The same description, built directly, with no script in the way. ```dart // The same description, written rather than sent. No engine, no script. UiNode get description => UiNode( type: 'column', classes: 'gap-2 px-4 py-3 rounded-lg bg-slate-900 border border-slate-700', children: [ UiNode( type: 'text', classes: 'text-xs uppercase text-slate-400', text: 'Power', ), // A bar is two boxes: the track, and as much of it as is left. UiNode( type: 'box', classes: 'w-full h-2 rounded-full bg-slate-700 clip', children: [ UiNode( type: 'box', classes: 'h-2 rounded-full bg-$accent-500', css: 'width: ${(health * 224).round()}px', ), ], ), ], ); // And then, exactly as in the TypeScript example: UiBuilder().build(description) ``` From [`interface_native.dart`](https://github.com/ChxisB/orblit-examples/blob/main/gallery/lib/src/examples/interface_native.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='an interface in dart' flutter run -d macos ``` ## Spawning from script Every object put there by TypeScript, and moved by it every frame. ```tsx // script/world.tsx — every object in the scene, and everything that moves. import { spawn, all, clear } from "orblit/scene"; export const settings = { rings: 4, spin: 0.35, bob: true }; export function build() { clear(); spawn({ id: "core", at: [0, 0.4, 0], size: [0.8, 2.4, 0.8], colour: "#F2F4F7" }); for (let ring = 0; ring < settings.rings; ring++) { const radius = 2.4 + ring * 1.9; const many = 6 + ring * 4; for (let i = 0; i < many; i++) { const angle = (i / many) * Math.PI * 2; spawn({ id: `r${ring}-${i}`, at: [Math.cos(angle) * radius, -0.6, Math.sin(angle) * radius], size: [0.5, 0.5 + ring * 0.25, 0.5], turn: (angle * 180) / Math.PI, colour: colours[(ring + i) % colours.length], }); } } } // The step. Everything that moves, moves here — the host only asks for a // frame and draws whatever it is told. export function step(seconds: number) { for (const thing of all()) { /* ... */ } } ``` From [`spawning.dart`](https://github.com/ChxisB/orblit-examples/blob/main/gallery/lib/src/examples/spawning.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='spawning from script' flutter run -d macos ``` # Performance > The six examples the gallery lists under Performance, and the code its panel shows for each. Read online at https://orblitengine.com/docs/gallery/performance/ The six examples the [gallery](https://orblitengine.com/docs/examples/running-them/#the-gallery) lists under **Performance**. Each block is what the gallery's code panel shows beside that example, copied from the example itself: the lines that do it rather than the whole file, so it isn't compiled here the way the guides' code is. The file it came from is linked under each one. ## A thousand objects A whole scene sent every frame, and only what moved paid for. ```dart // The whole scene, every frame. There is no add, move or remove call: a // description that says everything cannot go stale, and a key nobody sends // this time is an object that has left. OrblitScene( objects: [ for (var i = 0; i < total; i++) OrblitObject( key: 1000 + i, // its own, for as long as it exists transform: placementOf(i, seconds), colour: colourOf(i), castShadows: i % 7 == 0, ), ], lights: [sun], camera: camera, ) // On the other side: a transform is compared before it is written, because // writing one dirties the node and everything under it. A mesh instance whose // object is gone goes back to a pool rather than being destroyed. Only a // change of mesh rebuilds anything. ``` From [`many.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/many.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='a thousand objects' flutter run -d macos ``` ## Batching Thousands of identical crates, merged into instanced draws without the scene saying so. ```dart // Nothing about the objects changes. Each crate is still its own object // with its own key; the scene just says that batching is allowed. OrblitScene( batching: true, objects: [ for (var i = 0; i < 3000; i++) OrblitObject( key: 1000 + i, transform: placementOf(i), colour: crateColour, // the same for all, or they cannot share castShadows: i % 5 == 0, // casters batch with casters ), ], camera: camera, ) // On the other side, objects with the same mesh, material, flags and — on // the default surface — colour are counted as they arrive. Groups of four // or more share one material instance, and Filament merges their draws into // instanced ones. A transform written to one crate moves that one only. ``` From [`batching.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/batching.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='batching' flutter run -d macos ``` ## A hundred thousand One buffer of transforms, sent once, drawn in a handful of calls. ```dart // One mesh, one buffer of transforms, one submission. // // The buffer is written once and kept. `revision` is what tells the renderer // whether it has to be sent again — leave it alone and a hundred thousand // members cost nothing per frame at all. final transforms = Float32List(count * 16); final colours = Float32List(count * 3); for (var i = 0; i < count; i++) { // Column-major, straight into the buffer. Nothing is allocated per member. transforms[i * 16 + 0] = width; transforms[i * 16 + 5] = height; transforms[i * 16 + 10] = width; transforms[i * 16 + 12] = x; transforms[i * 16 + 13] = y; transforms[i * 16 + 14] = z; transforms[i * 16 + 15] = 1; } OrblitScene( populations: [ OrblitPopulation( key: 1, transforms: transforms, colours: colours, // Every member is culled by this one box, so it must cover all of them. minimum: Vector3(-spread, -2, -spread), maximum: Vector3(spread, 14, spread), // Bump this when you write into the buffers. Do not, and nothing is // sent. revision: revision, ), ], camera: camera, ); ``` From [`crowd.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/crowd.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='a hundred thousand' flutter run -d macos ``` ## Overdraw Slabs that pass through each other, so that every pixel is covered as many times as they overlap. ```dart // Slabs all crossing at the middle, so no order of objects is the right // order for every pixel and each one is covered many times over. OrblitScene( materials: [OrblitMaterial(key: 3, clearCoat: 1, anisotropy: 0.7)], objects: [ for (var i = 0; i < 48; i++) OrblitObject( key: 100 + i, material: 3, transform: Matrix4.identity() ..rotateY(i * math.pi / 48) ..scaleByDouble(5, 3, 0.05, 1), ), ], camera: camera, ) // What it is for is measuring. Turn the slabs up and watch the frame's GPU // time: on this machine it barely moves, because the hardware already // decides which surface wins a tile before shading any of it. ``` From [`overdraw.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/overdraw.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='overdraw' flutter run -d macos ``` ## Pipeline Shadows, cascades, multisampling and render scale — the four named settings, and every dial behind them. ```dart // One pipeline. The four named settings are settings of its dials, not // different pipelines — nothing appears or disappears between them. final pipeline = OrblitPipeline.at(OrblitDetail.high); // Or every dial by hand. final mine = OrblitPipeline( shadows: OrblitShadows( kind: OrblitShadowKind.soft, mapSize: 2048, // The single most effective shadow setting there is: one map over a // hundred metres puts a centimetre in each pixel; four cascades over the // same hundred metres puts a millimetre in the first. cascades: 3, distance: 80, contact: true, ), // Runs while the frame is drawn, unlike the anti-aliasing in post, which // runs on the finished image. Sharper, and costs bandwidth on everything. samples: 4, // A frame that arrives on time slightly soft beats one that arrives late // sharp. resolution: OrblitResolution(adaptive: true, minScale: 0.6), ); OrblitScene(objects: objects, camera: camera, pipeline: pipeline); ``` From [`pipeline.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/pipeline.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='pipeline' flutter run -d macos ``` ## Benchmark Turn the load up and watch what a frame costs. Nothing here is staged. ```dart // The three loads, and why they are separate dials. // // A member of a population is a row in a buffer. Sixty-four share a draw, and // standing still costs nothing at all — the buffer is only sent when its // revision moves. OrblitPopulation(key: 1, transforms: transforms, colours: colours, revision: revision, minimum: ..., maximum: ...) // An object is tracked one at a time: its own key, its own entity, its own // draw, compared against last frame every frame. OrblitObject(key: 2000 + i, transform: ..., colour: ...) // And the sky is neither. It is a volume marched per pixel, so its cost has // nothing to do with how much of anything is in the scene. OrblitSky(quality: SkyQuality.fair, clouds: OrblitClouds.cumulus(cover: 0.42)) // What a frame cost, from Filament's own frame history — the median of the // last handful, because a mean is dragged about by the one frame in thirty // that hits a hitch. final ms = await OrblitView.gpuMilliseconds(viewport); ``` From [`benchmark.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/benchmark.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='benchmark' flutter run -d macos ``` # Showcases > The four examples the gallery lists under Showcases, and the code its panel shows for each. Read online at https://orblitengine.com/docs/gallery/showcases/ The four examples the [gallery](https://orblitengine.com/docs/examples/running-them/#the-gallery) lists under **Showcases**. Each block is what the gallery's code panel shows beside that example, copied from the example itself: the lines that do it rather than the whole file, so it isn't compiled here the way the guides' code is. The file it came from is linked under each one. ## Blocks A landscape of sixty thousand cubes, generated and sent once. ```dart // The world is a grid of bytes, not a list of what to draw. The moment // somebody can dig, "what is at this point" is asked constantly — by the // body falling, by every step, by every ray under the crosshair — and a // grid answers it in one lookup instead of sixty thousand. Uint8List blocks; // side * side * tall, nought is air // Only what can be seen is drawn. A block with six solid neighbours is // invisible from everywhere, and in a world of hills that is most of them. if (!buriedOnAllSides) { transforms.addAll(placed(x, y, z)); colours.addAll(colourOf(kind)); } // Moved one axis at a time, which is the whole reason it works: move in // one step and test afterwards and you are inside a wall with no way to // know which way to come back out. One at a time, a corner stops you // sideways and lets you keep walking forwards — which is what sliding // along a wall is. for (final step in [(dx, 0.0), (0.0, dz)]) { ... } // And digging is a ray walked a fraction of a block at a time. A proper // grid traversal is faster; this is called once per click, on a ray six // blocks long, and being obviously correct is worth more here. ``` From [`voxels.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/voxels.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='blocks' flutter run -d macos ``` ## Runner A runner you can play: dodge, jump, slide and pick up coins. ```dart // Every model is made in code, written out as a glb, and handed to the // renderer by name — the same path a downloaded file takes. final art = RunnerArt.build(); for (final file in art.files.entries) { await OrblitResources.provide(file.key, file.value); } // The world is laid in chunks as the runner reaches them. A chunk that // falls behind hands its keys to the one being laid ahead, so the // renderer reuses what it made rather than making more. final key = 1000 + type * 100 + slot * cap + index; // Grass is one population per chunk, sent when the chunk is laid and never // again: the revision only changes when the blades do. OrblitPopulation( key: 1 + slot, transforms: blades, colours: shades, minimum: low, maximum: high, revision: revisions[slot], range: 60, ); // A hurdle is cleared by being above it when you reach it, a bar by being // below it, a container by not being in its lane. final blocked = switch (hazard.kind) { Kind.hurdle => y < 0.92, Kind.bar => y + (sliding ? 0.8 : 1.3) > 1.05, Kind.container => true, }; ``` From [`runner.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/runner.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='runner' flutter run -d macos ``` ## Bistro exterior Somebody else's street, lit by this engine. A hundred lights at night. ```dart // The fixtures come out of the scene's own emissive geometry, so the lights // stand where the artist put the lamps. for (final fixture in fixtures) OrblitLight( kind: OrblitLightKind.point, position: fixture.at, intensity: 2400, // lumens — a street lamp falloffRadius: 14, // metres castShadows: false, // a hundred shadow casters is not a thing ), // And the sky is still a light, even at night. OrblitSky(zenith: Color(0xFF0B1224), horizon: Color(0xFF243046), ambient: 120) ``` From [`bistro.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/bistro.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='bistro exterior' flutter run -d macos ``` ## Bistro interior The room, and the one place the absence of bounced light shows. ```dart // Indoors, the ambient is doing the job bounced light would do. Drag it to // nothing and the shadows go black, which is exactly what a renderer without // global illumination looks like when nothing stands in for it. OrblitSky( zenith: linearOf(const Color(0xFF2A1D18)), ambient: 400, // lux drawn: false, // lighting only; there is no sky to see from in here ) // And occlusion, the cheapest approximation of contact darkening. OrblitPostProcess(occlusion: OrblitOcclusion(enabled: true)) ``` From [`bistro.dart`](https://github.com/ChxisB/orblit/blob/main/packages/orblit_examples/lib/src/examples/bistro.dart). To open the gallery on it: ```sh ORBLIT_EXAMPLE='bistro interior' flutter run -d macos ``` # Packages > Every Orblit package, what it is, which repository it is in, and whether it needs Flutter. Read online at https://orblitengine.com/docs/reference/packages/ Granularity is per **package**, not per repository. `pub` resolves a subdirectory of a git repository, so a package in a monorepo is already consumable on its own. Splitting one repository per package buys nothing and costs a co-ordinated release every time two of them change together. ## In `ChxisB/orblit` | Package | What it is | Needs | | --- | --- | --- | | `orblit_core` | Archetype entity-component store in C++, over a C ABI, with a transform hierarchy. Component data reaches Dart as views, not copies. | Dart | | `orblit_codegen` | Turns annotated component classes into registration and a manifest other front ends read without compiling this package. | Dart | | `orblit_filament` | Filament rendering, composited by Flutter: meshes and models, sprites, splats, textures and environments. On Apple platforms it renders into IOSurface-backed pixel buffers the texture registry adopts without a readback. | Flutter, and a platform it [draws on](https://orblitengine.com/docs/reference/platform-support/) | | `orblit_ui` | The interface a game draws: a tree of elements with a utility class list or CSS on each, built into real Flutter widgets. | Flutter | | `orblit_light` | Lights described the way an artist states them, in watts, metres and degrees, converted to the photometric units a renderer works in. | Dart | | `orblit_mesh` | Building and editing geometry: parametric shapes, and the operations that turn one into something that was not a shape any more. | Dart | | `orblit_rig` | Armatures, poses and bone constraints. The skeleton layer a control rig is generated on top of. | Dart | | `orblit_camera` | Cameras as shots rather than as objects. A camera describes what it wants to frame; the engine works out where to be and blends between them. | Dart | | `orblit_agent` | Steering behaviours that say where to go, and behaviour trees that say what to want. | Dart | | `orblit_collide` | Shapes, raycasts and overlap tests, in three dimensions and in two. | Dart | | `orblit_effect` | Change stated as a function of time, so move, turn, grow and fade, sequenced and composed. | Dart | | `orblit_sequence` | Cutscenes as a function of time. Tracks of clips over a playhead; sampling gives the whole world's worth of values. | Dart | | `orblit_sprite` | Two dimensions: atlases and an atlas packer, sprite animation, parallax layers and tile maps. It draws nothing itself; `orblit_filament` draws sprites. | Dart | | `orblit_scene` | A scene as a document: entities with stable ids, components, migrations from every older format, and diffs that apply and invert. | Dart | | `orblit_stage` | A scene document staged for the renderer, turning entities into objects, lights, splat clouds and sprite layers, with a diff moving only what it touched. | Flutter | | `orblit_asset` | What an asset is called and where its bytes are: project-relative ids, content hashes, sources, and a store that files bytes by their hash. Its directory-backed classes throw in a browser. | Dart | | `orblit_input` | Gamepads as state a frame can ask about, plus the events between two frames. Linux only so far, through evdev; elsewhere it reports no pads. | Dart | | `orblit_noise` | Value and gradient noise, tiling and deterministic: the same field every run, on every machine. | Dart | | `orblit_weather` | What the air is doing, and what is above it. Conditions, transitions, cloud, the day's cycle, and the exposure any of it needs. | Dart | | `orblit_native` | Compiling and loading C++ scripts. A script is handed a table of what it may call, and answers start, step and stop. | Dart | | `orblit_examples` | One technique at a time: a scene that shows it, the controls that change it, and the lines that do it. | Flutter | "Needs Dart" means it runs anywhere Dart does, including a headless CI runner, which is why most of the engine is tested on Linux. ## Other repositories | Repository | What it is | | --- | --- | | [`orblit-editor`](https://github.com/ChxisB/orblit-editor) | The editor application. Depends on the engine by path, so the two must be sibling checkouts. | | [`orblit-examples`](https://github.com/ChxisB/orblit-examples) | The gallery and viewport applications, and a headless simulation example. | | [`orblit-net`](https://github.com/ChxisB/orblit-net) | Multiplayer: replicated component columns, ownership, acknowledged deltas, interpolation. | | [`orblit-script`](https://github.com/ChxisB/orblit-script) | TypeScript scripting on QuickJS, as a peer of Dart over the same core. | | [`orblit-filament`](https://github.com/ChxisB/orblit-filament) | A fork of Filament. The web build compiles from it; the native platforms download Google's v1.77.0 release instead. | ## Adding one ```yaml title="pubspec.yaml" dependencies: orblit_sprite: git: url: https://github.com/ChxisB/orblit.git path: packages/orblit_sprite ``` The `path` is what selects a package within the repository. :::caution[Two name clashes worth knowing about] `orblit_collide` exports `Sphere` and `Ray`, and `vector_math` defines both. Using the two together needs a `hide` on one of them: ```dart import 'package:vector_math/vector_math_64.dart' hide Ray, Sphere; ``` `orblit_agent` calls its time-limit decorator `Deadline` rather than `Timeout`, because every Dart test package defines `Timeout` and the clash was constant. ::: # Platform support > What runs where today, and how much of it has actually been seen to draw. Read online at https://orblitengine.com/docs/reference/platform-support/ Being precise about this is more use to you than a roadmap, so what follows is the state of things rather than the plan. The rendering column is deliberately fussy about the difference between code that compiles and a frame somebody has actually looked at. | Platform | Simulation, geometry, agents, 2D, networking | Rendering | | --- | --- | --- | | macOS | Yes | Yes, and CI draws a frame on every change | | iOS | Yes | Yes, [on the simulator](#ios) | | Android | Yes | Yes, [including on a handset](#android) | | Linux | Yes | Yes, [though not yet on a real GPU](#linux) | | Windows | Yes | [It builds. Nothing has drawn yet](#windows) | | Web | Yes | Yes, [in Chrome, with a build step of its own](#the-web) | What each platform needs from the machine that builds it, and which machine can build which platform, is under [Installing](https://orblitengine.com/docs/start/installing/#which-machine-builds-what). Everything in the first column is plain Dart, so it runs anywhere Dart does, including a CI runner with no display. That isn't a consolation prize. It means the simulation, the rig, the agents, the collision and the sequencer are all unit-testable without a GPU, and most of the engine's tests do exactly that. ## One renderer, six hosts The renderer itself is portable C++, `orblit::Renderer`, with nothing Apple in it. It sits behind a C ABI, `orblit_renderer.h`, that any host can call: a Flutter plugin on each platform, or a program with no Flutter at all, which `native/headless` proves by drawing offscreen to a PNG. Filament supplies the backends and the renderer picks one per platform, which is Metal on Apple, Vulkan first with OpenGL behind it everywhere else, and WebGL on the web. What differs between platforms is not the renderer. It is how a finished frame reaches Flutter's compositor, and that varies more than you would hope. | Platform | How a frame reaches Flutter | Copy per frame | | --- | --- | --- | | macOS, iOS | A `CVPixelBuffer` backed by an `IOSurface` | No | | Android | A native window from Flutter's `SurfaceProducer` | No | | Linux | An `FlPixelBufferTexture` | Yes | | Windows | A `flutter::PixelBufferTexture` | Yes | | Web | A canvas, laid out as a platform view | No | The two copies are deliberate rather than careless, and the headers that carry them say so at length. On Linux the copy-free route wants the name of a GL texture on Flutter's own context, which the GTK embedder offers no public way to obtain. On Windows the route that would work across devices is reported to crash under Impeller, and that was never reproduced on a running machine, so it sits in `windows/orblit_viewport.h` as unrefuted rather than confirmed. ## The asset pipeline, platform by platform Sprites, splats, scene files, models and textures are newer than the rest of the renderer, and they've been run in fewer places. "Seen" below means a frame somebody looked at, or a check that measured one, on that platform. | Feature | Seen on | Not run on | | --- | --- | --- | | Bytes by name (`OrblitResources`) | macOS, Chrome, the iOS simulator, the Android emulator | Phones, Linux, Windows, Safari, Firefox | | Sprites | macOS, Chrome | iOS, Android, Linux, Windows, Safari, Firefox | | Gaussian splats | macOS, Chrome | Phones, Linux, Windows, Safari, Firefox | | Scene files | Plain Dart, tested on CI. The drawing goes through the same objects, lights and layers as everything else | No platform-by-platform frame check yet | | Models: clips, skins, variants, FBX and OBJ | macOS, Chrome, the iOS simulator, the Android emulator | Phones, Linux, Windows, Safari, Firefox | | Textures: cooked sets, the texture queue, `fromImage` | macOS, Chrome, the iOS simulator, the Android emulator | Phones, Linux, Windows, Android on OpenGL ES, Safari, Firefox | The Android emulator draws with Vulkan through SwiftShader, a software renderer, so its timings say nothing about a phone. The device tiers' budgets for splats and textures are starting points for the same reason: no phone or tablet has measured them. Two limits are specific to a platform rather than untested on it: - **Apple devices can't take sRGB ASTC.** Filament's Metal backend doesn't sample it, so a cooked colour texture comes from the BC file in its set on a Mac, and from the ETC2 file on iOS. - **So colour on iOS is ETC1 quality for now.** The cooker makes its ETC2 files from UASTC, and that only reaches the ETC1 subset of ETC2: 23.6 dB on the Bistro's cobblestone normal map. ## macOS The reference platform, and the one everything else is compared against. CI builds the viewport example from `orblit-examples` and runs it until the renderer reports a drawn frame, on every change. Building proves the renderer compiles and links, which is not the thing that breaks. What breaks is a Filament precondition that aborts on the first frame that hits it, in a build that compiled perfectly, so the frame is the gate rather than the build. ## iOS iOS shares one implementation with macOS, meaning the same sources, the same surface and the same Metal backend, and it does draw. The gallery's examples render on the simulator. CI builds the gallery for the simulator on every change and launches it, and the engine starts, but on GitHub's hosted runners no frame has ever arrived, so that step reports rather than fails. The same app draws on a developer's simulator, so the frame check for iOS is still done by hand. It draws with a slimmer surface than macOS does. The standard lit surface binds twelve samplers, which Filament only allows at feature level 3, and Filament's Metal backend grants that level to `MTLGPUFamilyApple6` and newer (A13, so an iPhone 11 or later) and to any Mac in `MTLGPUFamilyMac2`, which takes in every Apple silicon Mac. The simulator's virtual GPU reports `MTLGPUFamilyApple2`, which is level 2. Below level 3, the renderer picks a lit surface with nine samplers instead. That keeps every map, ground blending and decals, and gives up two things: rectangular lights' shadows, and the irradiance field. It says so in the scene's notes if a scene asks for either. A current iPhone or iPad would report level 3 and get the standard surface. That hasn't been run, because it needs a signing identity, which is why the simulator is what CI checks. ## Android A Kotlin and JNI plugin over the same C ABI, presenting into a texture from Flutter's `SurfaceProducer`. Vulkan is the default and reaches feature level 3, so Android gets the standard surface rather than the slimmer one. It has been seen to draw on a handset rather than only on an emulator: a Galaxy S24+ running Android 16, on Vulkan, at feature level 3, with the engine up in 79 milliseconds. CI builds the APK on every change but does not run it, because the runner has neither a device nor an emulator, so the on-device check is still done by hand. It builds from a Mac or an x86_64 Linux machine, and [setting up Android](https://orblitengine.com/docs/start/setup/android/) says why not from the others. ## Linux A GTK plugin under `linux/`, putting the same portable core behind Flutter's GTK embedder. Vulkan where a driver answers, and OpenGL behind it. It draws, and the honest caveat is where. It has been exercised in a container against Mesa's software rasterisers, llvmpipe answering OpenGL and lavapipe answering Vulkan, which is enough to prove the plugin, the bindings and the buffer sizes, and nothing at all about a real driver. That container was arm64, on an Apple silicon Mac, so the x86_64 build hasn't been run either. No CI job builds or runs the Linux plugin yet, though the script one would call, `tool/ci_draw_frame_linux.sh`, is already written, which makes it the cheapest gap on this page to close. [Setting up Linux](https://orblitengine.com/docs/start/setup/linux/) covers the glibc and libc++ it needs. ## Windows A Win32 plugin, the same core again, speaking the same channel with the same method names and wire shapes, so `OrblitView` and every other Dart caller works there unchanged. Nothing has drawn a frame on it. There is no Windows machine behind this work, so CI is the only compiler, and a green Windows job means it compiles, links and bundles. CI then launches the gallery, but GitHub's Windows runners have no GPU: Filament can't create a Vulkan instance there, and the check gives up with no frame after 120 seconds, so that step reports rather than fails. Treat Windows as built and unproven, and if you have a Windows machine and half an hour, that is among the most useful things anyone could hand this project right now, and [setting up Windows](https://orblitengine.com/docs/start/setup/windows/) is where to start. ## The web Browsers are in scope, and WebGL 2 is the target for now. The renderer is compiled to WebAssembly and draws into a canvas that Flutter lays out as a platform view, at feature level 1, so the web gets the slimmer surface too. A browser page has one thread to draw on, and the renderer doesn't use WebAssembly threads, because they would need the page to be cross-origin isolated and a second Filament build. So the work that would stall a frame goes to Web Workers instead: - **Splat sorting.** A cloud is sorted on a worker, and one small enough is sorted on the page because handing it over would cost more. A worker that hasn't answered in half a second is sorted for on the page instead. - **Decoding.** Pictures, Basis and GPU-ready KTX2 textures and environment pictures are decoded on a pool of workers: half the machine's threads, at most four. With twelve 2048² textures or a 2K `.hdr` loading, no page task ran over 50 ms in Chrome, where it had been 118 to 206 ms. That decoder is carried inside `orblit_renderer.js`, which makes it 679 KB (440 KB gzipped), and the `.wasm` is 7.57 MB. A browser has no disk, so everything a scene names reaches the renderer as bytes through `OrblitResources`. A cooked texture set can't be looked for beside a file either, so `OrblitDeviceProfile.textureCandidates` says which names are worth fetching, best first. Only Chrome has been tried: headless Chrome with SwiftShader standing in for the GPU, and for the texture work, Chrome in real time. Safari and Firefox haven't been run at all. Unlike the others, there is no native build step Flutter knows how to run on your behalf. Your app's own `web/` directory has to carry `orblit_renderer.js` and `orblit_renderer.wasm`, built by `native/web/build.sh` against a Filament built for WebAssembly, with the WebGL 2 materials compiled first. It's the most manual setup of any platform, and [setting up the web](https://orblitengine.com/docs/start/setup/web/) walks through it. ## Portrait, and small panels A stated field of view is measured across whichever axis is shorter, so a scene framed on a desktop still frames sensibly on a phone held upright. Before that it was always measured vertically, and a portrait window showed about a third of the width a desktop window did, 24 degrees across against 64, which read as the camera having been shoved into the scene. `OrblitDisplay` states a panel's size and shape, and `OrblitDisplay.handheld` is a Steam Deck's 1280 by 800, which is 16:10 rather than the 16:9 most code assumes. `OrblitPipeline.forDisplay` moves the three dials that depend on how many pixels there are rather than on how fast the machine is: the shadow map, the floor under adaptive resolution, and multisampling. The floor does nothing yet, because the renderer doesn't act on adaptive resolution: it holds every frame at `maxScale`. ## Consoles Reachable in principle through an SDL3 embedder, which is why [the core takes no Flutter dependency](https://orblitengine.com/docs/concepts/architecture/). That rule is what keeps a native front end a *front end*, rather than a second engine. That front end has been written, though not for a console. `native/host` in `orblit_filament` is a C program on SDL3 that drives the renderer with no Dart anywhere, and it draws on macOS through Metal and in an arm64 Linux container through lavapipe. It has never run on a console. `CONSOLES.md` beside it sets out what a Switch port and an Xbox port would each take, and names the largest piece as one that needs no SDK: describing a scene without Dart. In practice it's gated on two things that aren't code: console SDK licensing, and a gap in Filament's backend support. Neither is close. ## Version pins | | | | --- | --- | | Flutter | 3.47.0 or newer, stable | | Dart SDK | 3.10.0 or newer | | Filament | v1.77.0, downloaded on first build | Filament is pinned rather than tracked. The material format, the compiled shader bundle and the engine's own API all move between releases, and taking whatever happens to be newest turns an unrelated build red. # The renderer > OrblitScene and everything you can put in one. Objects, populations, splats, sprites, lights, sky, weather, post-processing and the render graph. Read online at https://orblitengine.com/docs/reference/renderer/ Everything here comes from `package:orblit_filament/orblit_filament.dart`. A scene is stated whole, every frame. See [stating a scene](https://orblitengine.com/docs/concepts/stating-a-scene/) for why. ## `OrblitScene` | Field | What it is | | --- | --- | | `camera` | Where the view is, and its exposure. Required. | | `objects` | Individually tracked things, each keyed and compared against last frame. Required. | | `populations` | Bulk instanced things: a buffer of transforms, drawn 64 to a call | | `splats` | Gaussian splat clouds, from a capture file or from memory. See [splats](https://orblitengine.com/docs/guides/splats/) | | `sprites` | Layers of flat pictures, drawn after the solid scene. See [2D](https://orblitengine.com/docs/guides/two-dimensions/) | | `lights` | Directional, point, spot and area (a rectangle), in photometric units | | `materials` | Shared material definitions, referred to by key from an object | | `videos` | Video playing onto a surface | | `sky` | The procedural sky | | `fog` | Distance fog | | `precipitation` | Rain and snow | | `environment` | Image-based lighting and an HDRI skybox, baked or [filtered from an `.hdr` or `.exr` while it runs](https://orblitengine.com/docs/guides/lighting/#straight-from-the-picture) | | `pipeline` | Shadows, resolution, lighting, sampling, culling, refraction and the texture budget | | `post` | Bloom, depth of field, grading, occlusion, screen-space reflections, vignette, tone mapping, anti-aliasing | | `graph` | The passes, their targets, and the order they run in | | `probes` | Reflections captured at points in the scene. The one the camera is inside lights the scene in place of `environment` | | `field` | Light kept in the world rather than on the screen, as a lattice of probes. Off by default, and it lights nothing on the [slim surface](https://orblitengine.com/docs/reference/platform-support/#ios) | | `volumes` | Regions that look different from the rest, such as a dim hall or a foggy cave, resolved against the camera before the scene is sent | | `decals` | Pictures projected onto surfaces. The first 32 are painted, and the rest are reported | | `outline` | Which objects have a line drawn round them. None by default | | `godRays` | Shafts of light from the directional light. Off by default | | `distortions` | Air that bends the light: shockwaves, heat haze, a lens. None by default | | `batching` | On by default. Four or more placeholder cubes with the same material, colour and flags are drawn together, sixty-four to a draw. Named meshes aren't merged yet | | `depthPrepass` | Off by default. Draws opaque placeholder cubes into depth before shading them | ## `OrblitObject` | Field | Default | | | --- | --- | --- | | `key` | required | Stable across frames, unique within the scene | | `transform` | required | A `Matrix4` | | `colour` | required | A `Vector3`, used when no material is named | | `mesh` | null | A path or [resource name](https://orblitengine.com/docs/examples/a-model-on-screen/#where-the-bytes-come-from) for a `.gltf`, `.glb`, `.fbx` or `.obj`. Null is the built-in cube. | | `material` | null | The key of an entry in `OrblitScene.materials` | | `castShadows` | true | | | `receiveShadows` | true | | | `visible` | true | Hidden is not deleted. The key and the loaded mesh are kept | | `layer` | 0 | Which render layer it belongs to | | `animation` | null | One of the file's own clips, and where it is. See [models](https://orblitengine.com/docs/guides/models/) | | `variant` | null | Which of the file's material variants it wears, by position | | `joints` | null | Skin joints set by hand, after any clip | | `morphWeights` | null | How far each of the file's morph targets is dialled in, 0 to 1 | A mesh path that cannot be read is drawn as the cube, and the reason comes back through `OrblitView.onSceneNotes`. What a file holds once it has loaded, meaning its clips, skins, variants, lights and cameras, comes back through `OrblitView.onAssetInfo`. ## `OrblitCamera` | Field | Default | | | --- | --- | --- | | `position`, `target` | required | `Vector3` | | `fieldOfView` | 50 | Degrees. Ignored when orthographic. | | `orthographic` | false | | | `viewHeight` | 10 | World units, when orthographic | | `aperture` | 16 | f-stop | | `shutterSpeed` | 1/125 | Seconds | | `sensitivity` | 100 | ISO | The last three are a real exposure. Defaults are "sunny 16", correct for a scene lit at 100,000 lux. ## `OrblitLight` | Field | Default | | | --- | --- | --- | | `key`, `kind`, `intensity` | required | Lux for `directional`, lumens otherwise | | `colour` | white | `Vector3` | | `position` | the origin | Point, spot and area | | `direction` | straight down | Directional and spot, and the face an area light emits from | | `falloffRadius` | 10 | Metres. Also what lets a light be culled. | | `innerConeAngle` | 0.5 | Radians. Spot. Full brightness inside. | | `outerConeAngle` | 0.6 | Radians. Spot. Zero outside; the gap is the soft edge. | | `sunAngularRadius` | 0.263 | Degrees. The sun's apparent size, so how soft its shadows are. | | `sourceRadius` | 0.1 | Metres. A larger source casts softer shadows. | | `haloSize`, `haloFalloff` | 10, 80 | Directional. The glow round the sun's disk in the sky, and how quickly it fades. | | `castShadows` | true | | | `width`, `height` | 1, 1 | Metres. Area. The rectangle's size, which spreads its light rather than adding to it. | | `tangent` | (1, 0, 0) | Area. The edge `width` is measured along. | Filament honours one `directional` light per scene. A second is reported back rather than quietly ignored. ## `OrblitPopulation` | Field | | | --- | --- | | `key` | Required | | `transforms` | `Float32List`, 16 floats each | | `colours` | `Float32List`, 3 floats each | | `minimum`, `maximum` | The bounds the whole population sits inside, so the lot can be culled at once | | `mesh` | Optional; the built-in cube otherwise | | `range` | Metres past which a member isn't drawn. Default 0, which draws all of them | | `fade` | How a member goes once it's past `range`. Default `OrblitFade.sink` | | `revision` | Bump it when the buffers change, so unchanged frames cost nothing | | `castShadows` | Default **false**, since a hundred thousand shadow casters is rarely what anyone meant | | `receiveShadows` | Default true | | `layer` | Default 0. The whole population is on one layer | ## Measuring ```dart final cost = await OrblitView.gpuMilliseconds(textureId); ``` Milliseconds, not a frame rate. How often a frame is presented is the display's business, and a renderer with twice the headroom it needs looks identical there. The value is the **median** of the last handful rather than the mean, because a mean is dragged about by the one frame in thirty that hits a hitch. `textureId` comes from `OrblitView.onViewport`, which is called once with the view's own number. Per-pass costs are available too, through `OrblitView.capture(textureId, scene.passNames)`. That's the point of declaring passes rather than hard-coding them: which passes ran, in what order, and what each cost are the three questions asked of a renderer that is too slow, and a fixed pipeline cannot answer any of them without being instrumented by hand every time somebody asks. # Join in > The Discord, the repositories, and what is actually useful to do with a pre-alpha engine. Read online at https://orblitengine.com/docs/community/ Orblit is pre-alpha and community-led, which in practice means the most useful thing anyone can do right now is try to use it and tell us what happened. - [Discord](https://discord.gg/8J37a3vNXv): Where the design arguments happen, where 'is this a bug or am I holding it wrong' gets answered, and where releases get announced. - [GitHub](https://github.com/ChxisB/orblit): The engine's repository, under FSL-1.1-MIT. Issues and pull requests both work. ## The repositories | | | | | --- | --- | --- | | [`orblit`](https://github.com/ChxisB/orblit) | The engine | Twenty-one packages: the C++ core, the renderer, the interface layer, scene files, geometry, rigging, cameras, agents, weather, 2D, input, effects, sequencing. [What each one is](https://orblitengine.com/docs/reference/packages/). | | [`orblit-editor`](https://github.com/ChxisB/orblit-editor) | The editor | A desktop application built from the same widgets, drawing with the same renderer. [More](https://orblitengine.com/docs/start/the-editor/). | | [`orblit-examples`](https://github.com/ChxisB/orblit-examples) | Worked examples | The gallery, the viewport app, and a headless simulation. [Running them](https://orblitengine.com/docs/examples/running-them/). | | [`orblit-craft`](https://github.com/ChxisB/orblit-craft) | A whole small game | A block world you walk around, dig and build in. It exists to put weight on the parts the small examples do not. | | [`orblit-net`](https://github.com/ChxisB/orblit-net) | Multiplayer | Replicated component columns, ownership, acknowledged deltas. [More](https://orblitengine.com/docs/guides/multiplayer/). | | [`orblit-script`](https://github.com/ChxisB/orblit-script) | Scripting | TypeScript on QuickJS, as a peer of Dart over the same core. | | [`orblit-filament`](https://github.com/ChxisB/orblit-filament) | The renderer fork | Filament, with the changes the engine needs carried on top. | | [`orblit-site`](https://github.com/ChxisB/orblit-site) | This site | Every page has an "Edit this page" link at the bottom that goes straight to it. | ## What is genuinely useful right now Pre-alpha software needs a particular kind of help, and it isn't the kind people expect. In rough order of how much it does for us: **Try to build something, and tell us where you stopped.** Not a bug report: just the point at which you gave up, and what you were trying to do at the time. That's the single most valuable thing anyone can send, because the people who wrote the engine can't see their own assumptions. **Tell us when a page on this site is wrong.** Every Dart snippet here gets compiled against a real checkout in CI, so the code should be correct. Correct code attached to a misleading explanation is still a broken page, though, and that is the sort we can't catch automatically. **Run it on hardware we haven't got.** [Platform support](https://orblitengine.com/docs/reference/platform-support/) is precise about what renders where, and the gaps are mostly gaps in what has been tested rather than in what has been written. An Apple Silicon Mac, an Intel one, a Steam Deck: whichever way it goes, the result is something we don't currently know. **Argue about an API before it sets.** Nothing is API-stable yet. That's a warning if you're using it, and an opportunity if you're willing to say "this name is wrong" while changing it is still cheap. After 1.0, that conversation gets a great deal more expensive. **Send a pull request.** Fair warning about what that involves: every repository runs `check.sh`, the documentation's snippets get compiled, and versions are checked. The gates are there so that a green build actually means something. ## What to expect back Being straight about this saves everyone time. - **Nothing is API-stable.** Names in these pages can change between commits. Pin a git SHA if you need a week of quiet. - **Rendering has been seen on macOS, the iOS simulator, an Android handset and Chrome.** Linux has only drawn against software rasterisers, and Windows builds but has never drawn a frame. Safari and Firefox haven't been tried. Everything that doesn't draw runs anywhere Dart does, including headless CI. [Platform support](https://orblitengine.com/docs/reference/platform-support/) has the detail. - **There's no asset store, no asset cache and no physics solver.** glTF loads, and FBX and OBJ are converted on the way in. Shapes, raycasts and overlap tests exist. A rigid-body integrator doesn't. - **There's no release cadence yet.** Packages are versioned on every feature. See [VERSIONING.md](https://github.com/ChxisB/orblit/blob/main/VERSIONING.md). If that list would make the project unusable for you, then it will, and it's better to find that out now than in three weeks. If it reads as an invitation, the Discord link is at the top of this page.